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} className - Additional CSS classes
|
||||||
* @param {string} containerClassName - Additional classes for the outer container
|
* @param {string} containerClassName - Additional classes for the outer container
|
||||||
|
* @param {boolean} showSecurityFeatures - Whether to show the security features section (default: true)
|
||||||
*/
|
*/
|
||||||
const GDPRFooter = memo(function GDPRFooter({
|
const GDPRFooter = memo(function GDPRFooter({
|
||||||
className = "",
|
className = "",
|
||||||
containerClassName = ""
|
containerClassName = "",
|
||||||
|
showSecurityFeatures = true,
|
||||||
}) {
|
}) {
|
||||||
const { getThemeClasses } = useUIXTheme();
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
|
||||||
|
|
@ -36,29 +38,31 @@ const GDPRFooter = memo(function GDPRFooter({
|
||||||
<div className={`flex-shrink-0 border-t ${themeClasses.borderSecondary} ${themeClasses.bgCard}/50 backdrop-blur-sm relative z-10 ${containerClassName}`}>
|
<div className={`flex-shrink-0 border-t ${themeClasses.borderSecondary} ${themeClasses.bgCard}/50 backdrop-blur-sm relative z-10 ${containerClassName}`}>
|
||||||
<div className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 ${className}`}>
|
<div className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 ${className}`}>
|
||||||
{/* Security Features */}
|
{/* Security Features */}
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-8 text-sm">
|
{showSecurityFeatures && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-8 text-sm">
|
||||||
<ShieldCheckIcon className={`h-4 w-4 ${getThemeClasses("icon-success")}`} />
|
<div className="flex items-center space-x-2">
|
||||||
<span className={getThemeClasses("text-secondary")}>
|
<ShieldCheckIcon className={`h-4 w-4 ${getThemeClasses("icon-success")}`} />
|
||||||
ChaCha20-Poly1305 Encryption
|
<span className={getThemeClasses("text-secondary")}>
|
||||||
</span>
|
ChaCha20-Poly1305 Encryption
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<ServerIcon className={`h-4 w-4 ${getThemeClasses("icon-info")}`} />
|
||||||
|
<span className={getThemeClasses("text-secondary")}>Canadian Hosted</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<GlobeAltIcon className={`h-4 w-4 ${getThemeClasses("icon-privacy")}`} />
|
||||||
|
<span className={getThemeClasses("text-secondary")}>Privacy First</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<HeartIcon className={`h-4 w-4 ${getThemeClasses("icon-featured")}`} />
|
||||||
|
<span className={getThemeClasses("text-secondary")}>Made in Canada</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
)}
|
||||||
<ServerIcon className={`h-4 w-4 ${getThemeClasses("icon-info")}`} />
|
|
||||||
<span className={getThemeClasses("text-secondary")}>Canadian Hosted</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<GlobeAltIcon className={`h-4 w-4 ${getThemeClasses("icon-privacy")}`} />
|
|
||||||
<span className={getThemeClasses("text-secondary")}>Privacy First</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<HeartIcon className={`h-4 w-4 ${getThemeClasses("icon-featured")}`} />
|
|
||||||
<span className={getThemeClasses("text-secondary")}>Made in Canada</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GDPR Information */}
|
{/* GDPR Information */}
|
||||||
<div className={`mt-4 text-center text-xs ${getThemeClasses("text-secondary")} space-y-2`}>
|
<div className={`${showSecurityFeatures ? 'mt-4' : ''} text-center text-xs ${getThemeClasses("text-secondary")} space-y-2`}>
|
||||||
<p>
|
<p>
|
||||||
<strong>Data Controller:</strong> Maple Open Tech Inc. |{" "}
|
<strong>Data Controller:</strong> Maple Open Tech Inc. |{" "}
|
||||||
<strong>Location:</strong> Canada (Adequate protection under GDPR Art. 45)
|
<strong>Location:</strong> Canada (Adequate protection under GDPR Art. 45)
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,45 @@ const getThemeConfigs = () => {
|
||||||
"success-bg": "bg-green-50",
|
"success-bg": "bg-green-50",
|
||||||
"success-border": "border-green-200",
|
"success-border": "border-green-200",
|
||||||
"success-text": "text-green-800",
|
"success-text": "text-green-800",
|
||||||
|
|
||||||
|
// Help page section colors
|
||||||
|
"help-section-blue-text": "text-blue-600",
|
||||||
|
"help-section-blue-bg": "bg-blue-50",
|
||||||
|
"help-section-green-text": "text-green-600",
|
||||||
|
"help-section-green-bg": "bg-green-50",
|
||||||
|
"help-section-purple-text": "text-purple-600",
|
||||||
|
"help-section-purple-bg": "bg-purple-50",
|
||||||
|
"help-section-pink-text": "text-pink-600",
|
||||||
|
"help-section-pink-bg": "bg-pink-50",
|
||||||
|
"help-section-red-text": "text-red-600",
|
||||||
|
"help-section-red-bg": "bg-red-50",
|
||||||
|
|
||||||
|
// Export page section colors - Success (green)
|
||||||
|
"export-section-success-bg": "bg-green-50",
|
||||||
|
"export-section-success-border": "border-green-200",
|
||||||
|
"export-section-success-icon": "text-green-600",
|
||||||
|
"export-section-success-title": "text-green-900",
|
||||||
|
"export-section-success-text": "text-green-800",
|
||||||
|
"export-section-success-muted": "text-green-700",
|
||||||
|
|
||||||
|
// Export page section colors - Info (blue)
|
||||||
|
"export-section-info-bg": "bg-blue-50",
|
||||||
|
"export-section-info-border": "border-blue-200",
|
||||||
|
"export-section-info-icon": "text-blue-600",
|
||||||
|
"export-section-info-title": "text-blue-900",
|
||||||
|
"export-section-info-text": "text-blue-800",
|
||||||
|
|
||||||
|
// Export page section colors - Warning (yellow)
|
||||||
|
"export-section-warning-bg": "bg-yellow-50",
|
||||||
|
"export-section-warning-border": "border-yellow-200",
|
||||||
|
"export-section-warning-icon": "text-yellow-600",
|
||||||
|
"export-section-warning-title": "text-yellow-900",
|
||||||
|
"export-section-warning-text": "text-yellow-800",
|
||||||
|
"export-section-warning-muted": "text-yellow-700",
|
||||||
|
"export-section-warning-code": "bg-yellow-100",
|
||||||
|
|
||||||
|
// Muted background
|
||||||
|
"bg-muted": "bg-gray-50",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -434,6 +473,45 @@ const getThemeConfigs = () => {
|
||||||
"success-bg": "bg-green-50",
|
"success-bg": "bg-green-50",
|
||||||
"success-border": "border-green-200",
|
"success-border": "border-green-200",
|
||||||
"success-text": "text-green-800",
|
"success-text": "text-green-800",
|
||||||
|
|
||||||
|
// Help page section colors
|
||||||
|
"help-section-blue-text": "text-blue-600",
|
||||||
|
"help-section-blue-bg": "bg-blue-50",
|
||||||
|
"help-section-green-text": "text-green-600",
|
||||||
|
"help-section-green-bg": "bg-green-50",
|
||||||
|
"help-section-purple-text": "text-purple-600",
|
||||||
|
"help-section-purple-bg": "bg-purple-50",
|
||||||
|
"help-section-pink-text": "text-pink-600",
|
||||||
|
"help-section-pink-bg": "bg-pink-50",
|
||||||
|
"help-section-red-text": "text-red-600",
|
||||||
|
"help-section-red-bg": "bg-red-50",
|
||||||
|
|
||||||
|
// Export page section colors - Success (green)
|
||||||
|
"export-section-success-bg": "bg-green-50",
|
||||||
|
"export-section-success-border": "border-green-200",
|
||||||
|
"export-section-success-icon": "text-green-600",
|
||||||
|
"export-section-success-title": "text-green-900",
|
||||||
|
"export-section-success-text": "text-green-800",
|
||||||
|
"export-section-success-muted": "text-green-700",
|
||||||
|
|
||||||
|
// Export page section colors - Info (blue)
|
||||||
|
"export-section-info-bg": "bg-blue-50",
|
||||||
|
"export-section-info-border": "border-blue-200",
|
||||||
|
"export-section-info-icon": "text-blue-600",
|
||||||
|
"export-section-info-title": "text-blue-900",
|
||||||
|
"export-section-info-text": "text-blue-800",
|
||||||
|
|
||||||
|
// Export page section colors - Warning (yellow)
|
||||||
|
"export-section-warning-bg": "bg-yellow-50",
|
||||||
|
"export-section-warning-border": "border-yellow-200",
|
||||||
|
"export-section-warning-icon": "text-yellow-600",
|
||||||
|
"export-section-warning-title": "text-yellow-900",
|
||||||
|
"export-section-warning-text": "text-yellow-800",
|
||||||
|
"export-section-warning-muted": "text-yellow-700",
|
||||||
|
"export-section-warning-code": "bg-yellow-100",
|
||||||
|
|
||||||
|
// Muted background
|
||||||
|
"bg-muted": "bg-gray-50",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -642,6 +720,45 @@ const getThemeConfigs = () => {
|
||||||
"success-bg": "bg-green-50",
|
"success-bg": "bg-green-50",
|
||||||
"success-border": "border-green-200",
|
"success-border": "border-green-200",
|
||||||
"success-text": "text-green-800",
|
"success-text": "text-green-800",
|
||||||
|
|
||||||
|
// Help page section colors
|
||||||
|
"help-section-blue-text": "text-blue-600",
|
||||||
|
"help-section-blue-bg": "bg-blue-50",
|
||||||
|
"help-section-green-text": "text-green-600",
|
||||||
|
"help-section-green-bg": "bg-green-50",
|
||||||
|
"help-section-purple-text": "text-purple-600",
|
||||||
|
"help-section-purple-bg": "bg-purple-50",
|
||||||
|
"help-section-pink-text": "text-pink-600",
|
||||||
|
"help-section-pink-bg": "bg-pink-50",
|
||||||
|
"help-section-red-text": "text-red-600",
|
||||||
|
"help-section-red-bg": "bg-red-50",
|
||||||
|
|
||||||
|
// Export page section colors - Success (green)
|
||||||
|
"export-section-success-bg": "bg-green-50",
|
||||||
|
"export-section-success-border": "border-green-200",
|
||||||
|
"export-section-success-icon": "text-green-600",
|
||||||
|
"export-section-success-title": "text-green-900",
|
||||||
|
"export-section-success-text": "text-green-800",
|
||||||
|
"export-section-success-muted": "text-green-700",
|
||||||
|
|
||||||
|
// Export page section colors - Info (blue)
|
||||||
|
"export-section-info-bg": "bg-blue-50",
|
||||||
|
"export-section-info-border": "border-blue-200",
|
||||||
|
"export-section-info-icon": "text-blue-600",
|
||||||
|
"export-section-info-title": "text-blue-900",
|
||||||
|
"export-section-info-text": "text-blue-800",
|
||||||
|
|
||||||
|
// Export page section colors - Warning (yellow)
|
||||||
|
"export-section-warning-bg": "bg-yellow-50",
|
||||||
|
"export-section-warning-border": "border-yellow-200",
|
||||||
|
"export-section-warning-icon": "text-yellow-600",
|
||||||
|
"export-section-warning-title": "text-yellow-900",
|
||||||
|
"export-section-warning-text": "text-yellow-800",
|
||||||
|
"export-section-warning-muted": "text-yellow-700",
|
||||||
|
"export-section-warning-code": "bg-yellow-100",
|
||||||
|
|
||||||
|
// Muted background
|
||||||
|
"bg-muted": "bg-gray-50",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -849,6 +966,45 @@ const getThemeConfigs = () => {
|
||||||
"success-bg": "bg-green-50",
|
"success-bg": "bg-green-50",
|
||||||
"success-border": "border-green-200",
|
"success-border": "border-green-200",
|
||||||
"success-text": "text-green-800",
|
"success-text": "text-green-800",
|
||||||
|
|
||||||
|
// Help page section colors
|
||||||
|
"help-section-blue-text": "text-blue-600",
|
||||||
|
"help-section-blue-bg": "bg-blue-50",
|
||||||
|
"help-section-green-text": "text-green-600",
|
||||||
|
"help-section-green-bg": "bg-green-50",
|
||||||
|
"help-section-purple-text": "text-purple-600",
|
||||||
|
"help-section-purple-bg": "bg-purple-50",
|
||||||
|
"help-section-pink-text": "text-pink-600",
|
||||||
|
"help-section-pink-bg": "bg-pink-50",
|
||||||
|
"help-section-red-text": "text-red-600",
|
||||||
|
"help-section-red-bg": "bg-red-50",
|
||||||
|
|
||||||
|
// Export page section colors - Success (green)
|
||||||
|
"export-section-success-bg": "bg-green-50",
|
||||||
|
"export-section-success-border": "border-green-200",
|
||||||
|
"export-section-success-icon": "text-green-600",
|
||||||
|
"export-section-success-title": "text-green-900",
|
||||||
|
"export-section-success-text": "text-green-800",
|
||||||
|
"export-section-success-muted": "text-green-700",
|
||||||
|
|
||||||
|
// Export page section colors - Info (blue)
|
||||||
|
"export-section-info-bg": "bg-blue-50",
|
||||||
|
"export-section-info-border": "border-blue-200",
|
||||||
|
"export-section-info-icon": "text-blue-600",
|
||||||
|
"export-section-info-title": "text-blue-900",
|
||||||
|
"export-section-info-text": "text-blue-800",
|
||||||
|
|
||||||
|
// Export page section colors - Warning (yellow)
|
||||||
|
"export-section-warning-bg": "bg-yellow-50",
|
||||||
|
"export-section-warning-border": "border-yellow-200",
|
||||||
|
"export-section-warning-icon": "text-yellow-600",
|
||||||
|
"export-section-warning-title": "text-yellow-900",
|
||||||
|
"export-section-warning-text": "text-yellow-800",
|
||||||
|
"export-section-warning-muted": "text-yellow-700",
|
||||||
|
"export-section-warning-code": "bg-yellow-100",
|
||||||
|
|
||||||
|
// Muted background
|
||||||
|
"bg-muted": "bg-gray-50",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1056,6 +1212,45 @@ const getThemeConfigs = () => {
|
||||||
"success-bg": "bg-green-50",
|
"success-bg": "bg-green-50",
|
||||||
"success-border": "border-green-200",
|
"success-border": "border-green-200",
|
||||||
"success-text": "text-green-800",
|
"success-text": "text-green-800",
|
||||||
|
|
||||||
|
// Help page section colors
|
||||||
|
"help-section-blue-text": "text-blue-600",
|
||||||
|
"help-section-blue-bg": "bg-blue-50",
|
||||||
|
"help-section-green-text": "text-green-600",
|
||||||
|
"help-section-green-bg": "bg-green-50",
|
||||||
|
"help-section-purple-text": "text-purple-600",
|
||||||
|
"help-section-purple-bg": "bg-purple-50",
|
||||||
|
"help-section-pink-text": "text-pink-600",
|
||||||
|
"help-section-pink-bg": "bg-pink-50",
|
||||||
|
"help-section-red-text": "text-red-600",
|
||||||
|
"help-section-red-bg": "bg-red-50",
|
||||||
|
|
||||||
|
// Export page section colors - Success (green)
|
||||||
|
"export-section-success-bg": "bg-green-50",
|
||||||
|
"export-section-success-border": "border-green-200",
|
||||||
|
"export-section-success-icon": "text-green-600",
|
||||||
|
"export-section-success-title": "text-green-900",
|
||||||
|
"export-section-success-text": "text-green-800",
|
||||||
|
"export-section-success-muted": "text-green-700",
|
||||||
|
|
||||||
|
// Export page section colors - Info (blue)
|
||||||
|
"export-section-info-bg": "bg-blue-50",
|
||||||
|
"export-section-info-border": "border-blue-200",
|
||||||
|
"export-section-info-icon": "text-blue-600",
|
||||||
|
"export-section-info-title": "text-blue-900",
|
||||||
|
"export-section-info-text": "text-blue-800",
|
||||||
|
|
||||||
|
// Export page section colors - Warning (yellow)
|
||||||
|
"export-section-warning-bg": "bg-yellow-50",
|
||||||
|
"export-section-warning-border": "border-yellow-200",
|
||||||
|
"export-section-warning-icon": "text-yellow-600",
|
||||||
|
"export-section-warning-title": "text-yellow-900",
|
||||||
|
"export-section-warning-text": "text-yellow-800",
|
||||||
|
"export-section-warning-muted": "text-yellow-700",
|
||||||
|
"export-section-warning-code": "bg-yellow-100",
|
||||||
|
|
||||||
|
// Muted background
|
||||||
|
"bg-muted": "bg-gray-50",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ function LoginPageUIX() {
|
||||||
const authManager = useAuthManager();
|
const authManager = useAuthManager();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const emailInputRef = useRef(null);
|
const emailInputRef = useRef(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// UIX Theme support - defaults to blue theme
|
// UIX Theme support - defaults to blue theme
|
||||||
const { getThemeClasses } = useUIXTheme();
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
|
@ -47,6 +48,14 @@ function LoginPageUIX() {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Cleanup on unmount - prevents state updates after unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Split useEffect 1: Basic initialization
|
// Split useEffect 1: Basic initialization
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasInitialized) return;
|
if (hasInitialized) return;
|
||||||
|
|
@ -145,9 +154,12 @@ function LoginPageUIX() {
|
||||||
return newErrors;
|
return newErrors;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = useCallback(async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Prevent state updates if component is unmounted
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
const validationErrors = validateForm();
|
const validationErrors = validateForm();
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
setErrors(validationErrors);
|
setErrors(validationErrors);
|
||||||
|
|
@ -177,6 +189,8 @@ function LoginPageUIX() {
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("LoginPage: Login successful");
|
console.log("LoginPage: Login successful");
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +217,8 @@ function LoginPageUIX() {
|
||||||
navigate("/login/2fa");
|
navigate("/login/2fa");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.error("LoginPage: Login failed", error);
|
console.error("LoginPage: Login failed", error);
|
||||||
}
|
}
|
||||||
|
|
@ -215,16 +231,18 @@ function LoginPageUIX() {
|
||||||
form?.classList.add("animate-shake");
|
form?.classList.add("animate-shake");
|
||||||
setTimeout(() => form?.classList.remove("animate-shake"), 500);
|
setTimeout(() => form?.classList.remove("animate-shake"), 500);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [formData, rememberMe, authManager, navigate, validateForm]);
|
||||||
|
|
||||||
// Handle Enter key submission - memoized to prevent Input component re-renders
|
// Handle Enter key submission - memoized to prevent Input component re-renders
|
||||||
const handleKeyDown = useCallback((e) => {
|
const handleKeyDown = useCallback((e) => {
|
||||||
if (e.key === "Enter" && !loading) {
|
if (e.key === "Enter" && !loading) {
|
||||||
handleSubmit(e);
|
handleSubmit(e);
|
||||||
}
|
}
|
||||||
}, [loading]); // Depends on loading state to disable during submission
|
}, [loading, handleSubmit]); // Depends on loading state and handleSubmit
|
||||||
|
|
||||||
// Memoized password toggle button - prevents Input re-renders
|
// Memoized password toggle button - prevents Input re-renders
|
||||||
const passwordSuffix = useMemo(() => (
|
const passwordSuffix = useMemo(() => (
|
||||||
|
|
@ -350,7 +368,7 @@ function LoginPageUIX() {
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Remember me"
|
label="Remember me"
|
||||||
checked={rememberMe}
|
checked={rememberMe}
|
||||||
onChange={(e) => setRememberMe(e.target.checked)}
|
onChange={(checked) => setRememberMe(checked)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// File Path: monorepo/web/frontend/src/pages/Anonymous/TwoFA/ValidationPage.jsx
|
// File Path: monorepo/web/frontend/src/pages/Anonymous/TwoFA/ValidationPage.jsx
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import {
|
import {
|
||||||
useAuthManager,
|
useAuthManager,
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
useUIXTheme,
|
useUIXTheme,
|
||||||
useMobileOptimizations,
|
useMobileOptimizations,
|
||||||
OTPInput,
|
OTPInput,
|
||||||
|
Button,
|
||||||
} from "../../../components/UIX";
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
|
|
@ -47,6 +48,15 @@ function TwoFAValidationPageContent() {
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
|
// Cleanup on unmount - prevents state updates after unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
////
|
////
|
||||||
//// Event handling.
|
//// Event handling.
|
||||||
|
|
@ -62,6 +72,9 @@ function TwoFAValidationPageContent() {
|
||||||
const handleSubmit = useCallback(async (e) => {
|
const handleSubmit = useCallback(async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Prevent state updates if component is unmounted
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
// Clear previous errors
|
// Clear previous errors
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
|
||||||
|
|
@ -89,6 +102,8 @@ function TwoFAValidationPageContent() {
|
||||||
onUnauthorized,
|
onUnauthorized,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("TwoFAValidationPage: OTP validation successful");
|
console.log("TwoFAValidationPage: OTP validation successful");
|
||||||
}
|
}
|
||||||
|
|
@ -110,13 +125,17 @@ function TwoFAValidationPageContent() {
|
||||||
navigate("/dashboard");
|
navigate("/dashboard");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.error("TwoFAValidationPage: OTP validation failed", error);
|
console.error("TwoFAValidationPage: OTP validation failed", error);
|
||||||
}
|
}
|
||||||
setErrors(error);
|
setErrors(error);
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [token, twoFactorAuthManager, onUnauthorized, navigate]);
|
}, [token, twoFactorAuthManager, onUnauthorized, navigate]);
|
||||||
|
|
||||||
|
|
@ -258,27 +277,21 @@ function TwoFAValidationPageContent() {
|
||||||
Use Backup Code
|
Use Backup Code
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
disabled={isLoading || token.length !== 6}
|
disabled={isLoading || token.length !== 6}
|
||||||
className={`flex items-center justify-center px-8 py-3 rounded-lg transition-all duration-200 font-medium ${
|
loading={isLoading}
|
||||||
isLoading || token.length !== 6
|
className="px-8"
|
||||||
? `${getThemeClasses("bg-muted")} ${getThemeClasses("text-muted-foreground")} cursor-not-allowed`
|
|
||||||
: getThemeClasses("button-primary")
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{!isLoading && (
|
||||||
<>
|
<span className="inline-flex items-center gap-2">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
|
<span>Continue</span>
|
||||||
Verifying...
|
<ArrowRightIcon className="h-4 w-4" />
|
||||||
</>
|
</span>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Continue
|
|
||||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
{isLoading && "Verifying..."}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back to Login */}
|
{/* Back to Login */}
|
||||||
|
|
@ -328,7 +341,7 @@ function TwoFAValidationPageContent() {
|
||||||
{/* Copyright */}
|
{/* Copyright */}
|
||||||
<div className="text-center mt-6 sm:mt-8">
|
<div className="text-center mt-6 sm:mt-8">
|
||||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
© 2024 Flashpoint Training
|
© 2025 Flashpoint Training
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Download/DownloadPage.jsx
|
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Download/DownloadPage.jsx
|
||||||
// Simple download page for MapleFile desktop application
|
// Simple download page for MapleFile desktop application
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Button, Card } from "../../../components/UIX";
|
import { Button, Card, useUIXTheme } from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
function DownloadPage() {
|
function DownloadPage() {
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
const currentVersion = "1.0.0";
|
const currentVersion = "1.0.0";
|
||||||
|
|
||||||
const downloads = [
|
const downloads = [
|
||||||
|
|
@ -20,30 +21,30 @@ function DownloadPage() {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")}`}>
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="relative z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100">
|
<nav className={`relative z-50 ${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-4">
|
||||||
<Link to="/" className="flex items-center group">
|
<Link to="/" className="flex items-center group">
|
||||||
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200">
|
<div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
|
||||||
<LockClosedIcon className="h-6 w-6 text-white" />
|
<LockClosedIcon className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent">
|
<span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
||||||
MapleFile
|
MapleFile
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden md:flex items-center space-x-6">
|
<div className="hidden md:flex items-center space-x-6">
|
||||||
<Link to="/#how-it-works" className="text-gray-600 hover:text-gray-900 font-medium">
|
<Link to="/#how-it-works" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium`}>
|
||||||
How It Works
|
How It Works
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/#security" className="text-gray-600 hover:text-gray-900 font-medium">
|
<Link to="/#security" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium`}>
|
||||||
Security
|
Security
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/#pricing" className="text-gray-600 hover:text-gray-900 font-medium">
|
<Link to="/#pricing" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium`}>
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/#faq" className="text-gray-600 hover:text-gray-900 font-medium">
|
<Link to="/#faq" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium`}>
|
||||||
FAQ
|
FAQ
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,21 +68,21 @@ function DownloadPage() {
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl font-black text-gray-900 mb-4">
|
<h1 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
Download MapleFile
|
Download MapleFile
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600">
|
<p className={`text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
Desktop app with offline support and automatic sync.
|
Desktop app with offline support and automatic sync.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Downloads List */}
|
{/* Downloads List */}
|
||||||
<Card className="border border-gray-200 mb-8">
|
<Card className={`border ${getThemeClasses("border-muted")} mb-8`}>
|
||||||
<div className="divide-y divide-gray-100">
|
<div className={`divide-y ${getThemeClasses("divide-muted")}`}>
|
||||||
{downloads.map((item, index) => (
|
{downloads.map((item, index) => (
|
||||||
<div key={index} className="flex items-center justify-between px-6 py-4">
|
<div key={index} className="flex items-center justify-between px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-900">{item.platform}</span>
|
<span className={`font-medium ${getThemeClasses("text-primary")}`}>{item.platform}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.available ? (
|
{item.available ? (
|
||||||
<Button variant="secondary" size="sm">
|
<Button variant="secondary" size="sm">
|
||||||
|
|
@ -89,26 +90,26 @@ function DownloadPage() {
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-400 font-medium">Coming Soon</span>
|
<span className={`text-sm ${getThemeClasses("text-secondary")} font-medium`}>Coming Soon</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-gray-500 text-sm mb-12">
|
<p className={`text-center ${getThemeClasses("text-secondary")} text-sm mb-12`}>
|
||||||
<Link to="/register" className="text-red-800 hover:text-red-900 font-medium">
|
<Link to="/register" className={`${getThemeClasses("link-primary")} font-medium`}>
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
{" "}to be notified when downloads are available.
|
{" "}to be notified when downloads are available.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Web App CTA */}
|
{/* Web App CTA */}
|
||||||
<Card className="border-2 border-red-800 bg-gradient-to-br from-red-50 to-white p-8 text-center">
|
<Card className={`border-2 ${getThemeClasses("border-accent")} ${getThemeClasses("bg-accent-light")} p-8 text-center`}>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Use the Web App Now
|
Use the Web App Now
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className={`${getThemeClasses("text-secondary")} mb-6`}>
|
||||||
No download required. Get 10 GB free.
|
No download required. Get 10 GB free.
|
||||||
</p>
|
</p>
|
||||||
<Link to="/register">
|
<Link to="/register">
|
||||||
|
|
@ -123,17 +124,17 @@ function DownloadPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-900 text-white py-8">
|
<footer className={`${getThemeClasses("bg-card")} border-t ${getThemeClasses("border-muted")} py-8`}>
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center mb-4 md:mb-0">
|
<div className="flex items-center mb-4 md:mb-0">
|
||||||
<LockClosedIcon className="h-5 w-5 text-red-500 mr-2" />
|
<LockClosedIcon className={`h-5 w-5 ${getThemeClasses("text-accent")} mr-2`} />
|
||||||
<span className="font-bold">MapleFile</span>
|
<span className={`font-bold ${getThemeClasses("text-primary")}`}>MapleFile</span>
|
||||||
<span className="ml-3 text-gray-400 text-sm">Made with ❤️ in Canada</span>
|
<span className={`ml-3 ${getThemeClasses("text-secondary")} text-sm`}>Made with ❤️ in Canada</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-6 text-sm text-gray-400">
|
<div className={`flex space-x-6 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
<Link to="/" className="hover:text-white">Home</Link>
|
<Link to="/" className={getThemeClasses("hover:text-accent")}>Home</Link>
|
||||||
<Link to="/register" className="hover:text-white">Register</Link>
|
<Link to="/register" className={getThemeClasses("hover:text-accent")}>Register</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Index/IndexPage.jsx
|
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Index/IndexPage.jsx
|
||||||
// UIX version - Red theme landing page with UIX components
|
// UIX version - Red theme landing page with UIX components
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Button, Card, Alert } from "../../../components/UIX";
|
import { Button, Card, Alert, useUIXTheme } from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
|
|
@ -22,8 +22,22 @@ import {
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
function IndexPage() {
|
function IndexPage() {
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
const [authMessage, setAuthMessage] = useState("");
|
const [authMessage, setAuthMessage] = useState("");
|
||||||
const [openFaq, setOpenFaq] = useState(null);
|
const [openFaq, setOpenFaq] = useState(null);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for auth redirect message
|
// Check for auth redirect message
|
||||||
|
|
@ -33,11 +47,11 @@ function IndexPage() {
|
||||||
sessionStorage.removeItem("auth_redirect_message");
|
sessionStorage.removeItem("auth_redirect_message");
|
||||||
|
|
||||||
// Clear message after 10 seconds
|
// Clear message after 10 seconds
|
||||||
const timer = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
setAuthMessage("");
|
if (isMountedRef.current) {
|
||||||
|
setAuthMessage("");
|
||||||
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -175,28 +189,28 @@ function IndexPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="relative z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100">
|
<nav className={`relative z-50 ${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-4">
|
||||||
<div className="flex items-center group">
|
<div className="flex items-center group">
|
||||||
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200">
|
<div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
|
||||||
<LockClosedIcon className="h-6 w-6 text-white" />
|
<LockClosedIcon className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent">
|
<span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
||||||
MapleFile
|
MapleFile
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex items-center space-x-6">
|
<div className="hidden md:flex items-center space-x-6">
|
||||||
<a href="#how-it-works" className="text-gray-600 hover:text-gray-900 font-medium">
|
<a href="#how-it-works" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} font-medium`}>
|
||||||
How It Works
|
How It Works
|
||||||
</a>
|
</a>
|
||||||
<a href="#security" className="text-gray-600 hover:text-gray-900 font-medium">
|
<a href="#security" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} font-medium`}>
|
||||||
Security
|
Security
|
||||||
</a>
|
</a>
|
||||||
<a href="#pricing" className="text-gray-600 hover:text-gray-900 font-medium">
|
<a href="#pricing" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} font-medium`}>
|
||||||
Pricing
|
Pricing
|
||||||
</a>
|
</a>
|
||||||
<a href="#faq" className="text-gray-600 hover:text-gray-900 font-medium">
|
<a href="#faq" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} font-medium`}>
|
||||||
FAQ
|
FAQ
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -220,32 +234,32 @@ function IndexPage() {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<div className="relative bg-gradient-to-br from-gray-50 via-white to-red-50 overflow-hidden">
|
<div className={`relative ${getThemeClasses("bg-gradient-primary")} overflow-hidden`}>
|
||||||
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-16 pb-24">
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-16 pb-24">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
{/* Trust Badge */}
|
{/* Trust Badge */}
|
||||||
<div className="flex justify-center mb-6 animate-fade-in">
|
<div className="flex justify-center mb-6 animate-fade-in">
|
||||||
<div className="inline-flex items-center bg-red-50 border border-red-200 rounded-full px-4 py-2 text-sm">
|
<div className={`inline-flex items-center ${getThemeClasses("bg-accent-light")} border ${getThemeClasses("border-accent")} rounded-full px-4 py-2 text-sm`}>
|
||||||
<span className="text-lg mr-2">🍁</span>
|
<span className="text-lg mr-2">🍁</span>
|
||||||
<span className="text-red-800 font-medium">Proudly Canadian • 100% Encrypted • Open Source</span>
|
<span className={`${getThemeClasses("text-accent")} font-medium`}>Proudly Canadian • 100% Encrypted • Open Source</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-4xl md:text-6xl lg:text-7xl tracking-tight font-black text-gray-900 mb-6">
|
<h1 className={`text-4xl md:text-6xl lg:text-7xl tracking-tight font-black ${getThemeClasses("text-primary")} mb-6`}>
|
||||||
<span className="block animate-fade-in-up">
|
<span className="block animate-fade-in-up">
|
||||||
Your Files. Your Privacy.
|
Your Files. Your Privacy.
|
||||||
</span>
|
</span>
|
||||||
<span className="block bg-gradient-to-r from-red-800 via-red-700 to-red-900 bg-clip-text text-transparent animate-fade-in-up-delay">
|
<span className={`block ${getThemeClasses("text-accent")} animate-fade-in-up-delay`}>
|
||||||
Zero Compromises.
|
Zero Compromises.
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-6 max-w-2xl mx-auto text-lg md:text-xl text-gray-600 leading-relaxed animate-fade-in-up-delay-2">
|
<p className={`mt-6 max-w-2xl mx-auto text-lg md:text-xl ${getThemeClasses("text-secondary")} leading-relaxed animate-fade-in-up-delay-2`}>
|
||||||
Unlike Dropbox or Google Drive, MapleFile uses{" "}
|
Unlike Dropbox or Google Drive, MapleFile uses{" "}
|
||||||
<span className="font-semibold text-gray-900">true end-to-end encryption</span>.
|
<span className={`font-semibold ${getThemeClasses("text-primary")}`}>true end-to-end encryption</span>.
|
||||||
{" "}Your files are encrypted before they leave your device.
|
{" "}Your files are encrypted before they leave your device.
|
||||||
{" "}<span className="font-semibold text-red-800">We can't see them. Nobody can.</span>
|
{" "}<span className={`font-semibold ${getThemeClasses("text-accent")}`}>We can't see them. Nobody can.</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center items-center animate-fade-in-up-delay-3">
|
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center items-center animate-fade-in-up-delay-3">
|
||||||
|
|
@ -264,7 +278,7 @@ function IndexPage() {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-sm text-gray-500 animate-fade-in-up-delay-3">
|
<p className={`mt-6 text-sm ${getThemeClasses("text-secondary")} animate-fade-in-up-delay-3`}>
|
||||||
No credit card required • Setup in 2 minutes • Cancel anytime
|
No credit card required • Setup in 2 minutes • Cancel anytime
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -272,7 +286,7 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Problem/Solution Section */}
|
{/* Problem/Solution Section */}
|
||||||
<div className="bg-gray-900 py-16">
|
<div className="bg-gray-900 dark:bg-gray-950 py-16">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl font-black text-white mb-4">
|
<h2 className="text-3xl font-black text-white mb-4">
|
||||||
|
|
@ -287,13 +301,13 @@ function IndexPage() {
|
||||||
{problemsWeSolve.map((item, index) => (
|
{problemsWeSolve.map((item, index) => (
|
||||||
<div key={index} className="text-center">
|
<div key={index} className="text-center">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<span className="inline-block bg-red-900/50 text-red-300 text-sm font-medium px-3 py-1 rounded-full mb-4">
|
<span className={`inline-block ${getThemeClasses("bg-error-light")} ${getThemeClasses("text-error")} text-sm font-medium px-3 py-1 rounded-full mb-4`}>
|
||||||
The Problem
|
The Problem
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-400 text-lg">{item.problem}</p>
|
<p className="text-gray-400 text-lg">{item.problem}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-gray-700 pt-4 mt-4">
|
<div className="border-t border-gray-700 pt-4 mt-4">
|
||||||
<span className="inline-block bg-green-900/50 text-green-300 text-sm font-medium px-3 py-1 rounded-full mb-4">
|
<span className={`inline-block ${getThemeClasses("bg-success-light")} ${getThemeClasses("text-success")} text-sm font-medium px-3 py-1 rounded-full mb-4`}>
|
||||||
Our Solution
|
Our Solution
|
||||||
</span>
|
</span>
|
||||||
<p className="text-white font-semibold text-lg">{item.solution}</p>
|
<p className="text-white font-semibold text-lg">{item.solution}</p>
|
||||||
|
|
@ -305,39 +319,39 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Section */}
|
{/* Stats Section */}
|
||||||
<div className="bg-gradient-to-r from-red-800 to-red-900 py-12">
|
<div className={`${getThemeClasses("bg-gradient-secondary")} py-12`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<div className="text-4xl font-black mb-2">256-bit</div>
|
<div className="text-4xl font-black mb-2">256-bit</div>
|
||||||
<div className="text-red-100 font-medium">
|
<div className="text-white/80 font-medium">
|
||||||
Encryption Strength
|
Encryption Strength
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<div className="text-4xl font-black mb-2">100%</div>
|
<div className="text-4xl font-black mb-2">100%</div>
|
||||||
<div className="text-red-100 font-medium">Canadian Hosted</div>
|
<div className="text-white/80 font-medium">Canadian Hosted</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<div className="text-4xl font-black mb-2">Zero</div>
|
<div className="text-4xl font-black mb-2">Zero</div>
|
||||||
<div className="text-red-100 font-medium">Knowledge Access</div>
|
<div className="text-white/80 font-medium">Knowledge Access</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<div className="text-4xl font-black mb-2">10 GB</div>
|
<div className="text-4xl font-black mb-2">10 GB</div>
|
||||||
<div className="text-red-100 font-medium">Free Forever</div>
|
<div className="text-white/80 font-medium">Free Forever</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How It Works Section */}
|
{/* How It Works Section */}
|
||||||
<div id="how-it-works" className="bg-white py-20">
|
<div id="how-it-works" className={`${getThemeClasses("bg-card")} py-20`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-black text-gray-900 mb-4">
|
<h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
How MapleFile Works
|
How MapleFile Works
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
|
||||||
Secure file storage shouldn't be complicated. Get started in three simple steps.
|
Secure file storage shouldn't be complicated. Get started in three simple steps.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -346,20 +360,20 @@ function IndexPage() {
|
||||||
{howItWorks.map((item, index) => (
|
{howItWorks.map((item, index) => (
|
||||||
<div key={index} className="text-center relative">
|
<div key={index} className="text-center relative">
|
||||||
{index < howItWorks.length - 1 && (
|
{index < howItWorks.length - 1 && (
|
||||||
<div className="hidden md:block absolute top-12 left-1/2 w-full h-0.5 bg-gradient-to-r from-red-200 to-red-100"></div>
|
<div className={`hidden md:block absolute top-12 left-1/2 w-full h-0.5 ${getThemeClasses("bg-accent-light")}`}></div>
|
||||||
)}
|
)}
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="inline-flex items-center justify-center h-24 w-24 bg-gradient-to-br from-red-800 to-red-900 rounded-2xl mb-6 shadow-lg">
|
<div className={`inline-flex items-center justify-center h-24 w-24 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl mb-6 shadow-lg`}>
|
||||||
<item.icon className="h-12 w-12 text-white" />
|
<item.icon className="h-12 w-12 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-2 -right-2 md:right-auto md:-top-2 md:-left-2 h-8 w-8 bg-white border-2 border-red-800 rounded-full flex items-center justify-center text-red-800 font-bold text-sm shadow-md">
|
<div className={`absolute -top-2 -right-2 md:right-auto md:-top-2 md:-left-2 h-8 w-8 ${getThemeClasses("bg-card")} border-2 ${getThemeClasses("border-accent")} rounded-full flex items-center justify-center ${getThemeClasses("text-accent")} font-bold text-sm shadow-md`}>
|
||||||
{item.step}
|
{item.step}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-3">
|
<h3 className={`text-xl font-bold ${getThemeClasses("text-primary")} mb-3`}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 leading-relaxed">
|
<p className={`${getThemeClasses("text-secondary")} leading-relaxed`}>
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,13 +394,13 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Features Section */}
|
{/* Security Features Section */}
|
||||||
<div id="security" className="bg-gradient-to-br from-gray-50 to-white py-20">
|
<div id="security" className={`${getThemeClasses("bg-gradient-primary")} py-20`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-black text-gray-900 mb-4">
|
<h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
Security That's Actually Secure
|
Security That's Actually Secure
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
|
||||||
We use the same cryptographic standards trusted by governments and security professionals worldwide.
|
We use the same cryptographic standards trusted by governments and security professionals worldwide.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -395,20 +409,20 @@ function IndexPage() {
|
||||||
{securityFeatures.map((feature, index) => (
|
{securityFeatures.map((feature, index) => (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={index}
|
||||||
className="group bg-white border-gray-200 shadow-lg hover:shadow-xl transform hover:scale-[1.02] transition-all duration-300"
|
className={`group ${getThemeClasses("border-muted")} shadow-lg hover:shadow-xl transform hover:scale-[1.02] transition-all duration-300`}
|
||||||
>
|
>
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="inline-flex items-center justify-center h-14 w-14 bg-gradient-to-br from-red-800 to-red-900 rounded-xl group-hover:scale-110 transition-transform duration-200">
|
<div className={`inline-flex items-center justify-center h-14 w-14 ${getThemeClasses("bg-gradient-secondary")} rounded-xl group-hover:scale-110 transition-transform duration-200`}>
|
||||||
<feature.icon className="h-7 w-7 text-white" />
|
<feature.icon className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-6">
|
<div className="ml-6">
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
<h3 className={`text-xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 leading-relaxed">
|
<p className={`${getThemeClasses("text-secondary")} leading-relaxed`}>
|
||||||
{feature.description}
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -421,44 +435,44 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comparison Section */}
|
{/* Comparison Section */}
|
||||||
<div className="bg-white py-20">
|
<div className={`${getThemeClasses("bg-card")} py-20`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-black text-gray-900 mb-4">
|
<h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
MapleFile vs. Traditional Cloud Storage
|
MapleFile vs. Traditional Cloud Storage
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
|
||||||
See why privacy-conscious users are switching to MapleFile
|
See why privacy-conscious users are switching to MapleFile
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<Card className="overflow-hidden shadow-xl">
|
<Card className="overflow-hidden shadow-xl">
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
<div className={`${getThemeClasses("bg-muted")} px-6 py-4 border-b ${getThemeClasses("border-muted")}`}>
|
||||||
<div className="grid grid-cols-3 gap-4 text-center font-bold">
|
<div className="grid grid-cols-3 gap-4 text-center font-bold">
|
||||||
<div className="text-gray-600">Feature</div>
|
<div className={getThemeClasses("text-secondary")}>Feature</div>
|
||||||
<div className="text-red-800">MapleFile</div>
|
<div className={getThemeClasses("text-accent")}>MapleFile</div>
|
||||||
<div className="text-gray-500">Others</div>
|
<div className={getThemeClasses("text-secondary")}>Others</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-100">
|
<div className={`divide-y ${getThemeClasses("divide-muted")}`}>
|
||||||
{comparisonFeatures.map((item, index) => (
|
{comparisonFeatures.map((item, index) => (
|
||||||
<div key={index} className="grid grid-cols-3 gap-4 px-6 py-4 text-center items-center hover:bg-gray-50 transition-colors">
|
<div key={index} className={`grid grid-cols-3 gap-4 px-6 py-4 text-center items-center ${getThemeClasses("hover:bg-muted")} transition-colors`}>
|
||||||
<div className="text-gray-700 font-medium text-left">{item.feature}</div>
|
<div className={`${getThemeClasses("text-primary")} font-medium text-left`}>{item.feature}</div>
|
||||||
<div>
|
<div>
|
||||||
{item.maplefile === true ? (
|
{item.maplefile === true ? (
|
||||||
<CheckIcon className="h-6 w-6 text-green-500 mx-auto" />
|
<CheckIcon className={`h-6 w-6 ${getThemeClasses("text-success")} mx-auto`} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400">—</span>
|
<span className={getThemeClasses("text-secondary")}>—</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{item.others === true ? (
|
{item.others === true ? (
|
||||||
<CheckIcon className="h-6 w-6 text-green-500 mx-auto" />
|
<CheckIcon className={`h-6 w-6 ${getThemeClasses("text-success")} mx-auto`} />
|
||||||
) : item.others === false ? (
|
) : item.others === false ? (
|
||||||
<span className="text-red-400 font-medium">No</span>
|
<span className={`${getThemeClasses("text-error")} font-medium`}>No</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400 text-sm">{item.others}</span>
|
<span className={`${getThemeClasses("text-secondary")} text-sm`}>{item.others}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -470,13 +484,13 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Use Cases Section */}
|
{/* Use Cases Section */}
|
||||||
<div className="bg-gradient-to-br from-gray-50 to-white py-20">
|
<div className={`${getThemeClasses("bg-gradient-primary")} py-20`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-black text-gray-900 mb-4">
|
<h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
Perfect For
|
Perfect For
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
|
||||||
Whether you're protecting client files or personal documents, MapleFile has you covered.
|
Whether you're protecting client files or personal documents, MapleFile has you covered.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -485,16 +499,16 @@ function IndexPage() {
|
||||||
{useCases.map((useCase, index) => (
|
{useCases.map((useCase, index) => (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={index}
|
||||||
className="group bg-white border-gray-200 shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-300"
|
className={`group ${getThemeClasses("border-muted")} shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-300`}
|
||||||
>
|
>
|
||||||
<div className="p-6 text-center">
|
<div className="p-6 text-center">
|
||||||
<div className="inline-flex items-center justify-center h-14 w-14 bg-red-50 rounded-xl mb-4 group-hover:bg-red-100 transition-colors duration-200">
|
<div className={`inline-flex items-center justify-center h-14 w-14 ${getThemeClasses("bg-accent-light")} rounded-xl mb-4 group-hover:opacity-80 transition-colors duration-200`}>
|
||||||
<useCase.icon className="h-7 w-7 text-red-800" />
|
<useCase.icon className={`h-7 w-7 ${getThemeClasses("text-accent")}`} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-gray-900 mb-2">
|
<h3 className={`text-lg font-bold ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
{useCase.title}
|
{useCase.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm leading-relaxed">
|
<p className={`${getThemeClasses("text-secondary")} text-sm leading-relaxed`}>
|
||||||
{useCase.description}
|
{useCase.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -505,34 +519,34 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing Section */}
|
{/* Pricing Section */}
|
||||||
<div id="pricing" className="bg-gradient-to-br from-gray-50 to-white py-20">
|
<div id="pricing" className={`${getThemeClasses("bg-gradient-primary")} py-20`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-black text-gray-900 mb-4">
|
<h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
Simple, Transparent Pricing
|
Simple, Transparent Pricing
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
|
||||||
Start free with 10 GB. Upgrade when you need more.
|
Start free with 10 GB. Upgrade when you need more.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||||
{/* Free Plan */}
|
{/* Free Plan */}
|
||||||
<Card className="border-2 border-gray-200 shadow-lg hover:shadow-xl transition-shadow duration-300">
|
<Card className={`border-2 ${getThemeClasses("border-muted")} shadow-lg hover:shadow-xl transition-shadow duration-300`}>
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-2xl font-black text-gray-900 mb-2">
|
<h3 className={`text-2xl font-black ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Personal
|
Personal
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-8">
|
<p className={`${getThemeClasses("text-secondary")} mb-8`}>
|
||||||
Everything you need to get started
|
Everything you need to get started
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<span className="text-5xl font-black text-gray-900">
|
<span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
|
||||||
Free
|
Free
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg text-gray-600 ml-2">forever</span>
|
<span className={`text-lg ${getThemeClasses("text-secondary")} ml-2`}>forever</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mb-8 text-left">
|
<div className="space-y-4 mb-8 text-left">
|
||||||
|
|
@ -545,8 +559,8 @@ function IndexPage() {
|
||||||
"Community support",
|
"Community support",
|
||||||
].map((feature, idx) => (
|
].map((feature, idx) => (
|
||||||
<div key={idx} className="flex items-start">
|
<div key={idx} className="flex items-start">
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" />
|
<CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
|
||||||
<span className="text-gray-700 text-sm font-medium">
|
<span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
|
||||||
{feature}
|
{feature}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -558,7 +572,7 @@ function IndexPage() {
|
||||||
Get Started Free
|
Get Started Free
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-3 text-sm text-gray-500">
|
<p className={`mt-3 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
No credit card required
|
No credit card required
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -567,26 +581,26 @@ function IndexPage() {
|
||||||
|
|
||||||
{/* Pro Plan */}
|
{/* Pro Plan */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute -inset-4 bg-gradient-to-r from-red-800 to-red-900 rounded-3xl blur opacity-20"></div>
|
<div className={`absolute -inset-4 ${getThemeClasses("bg-gradient-secondary")} rounded-3xl blur opacity-20`}></div>
|
||||||
<Card className="relative bg-gradient-to-br from-white to-gray-50 border-2 border-red-800 shadow-2xl">
|
<Card className={`relative border-2 ${getThemeClasses("border-accent")} shadow-2xl`}>
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 bg-gray-400 text-white text-sm font-bold py-2 px-6 rounded-full shadow-lg">
|
<div className={`absolute -top-4 left-1/2 transform -translate-x-1/2 ${getThemeClasses("bg-secondary")} text-white text-sm font-bold py-2 px-6 rounded-full shadow-lg`}>
|
||||||
COMING SOON
|
COMING SOON
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 pt-12">
|
<div className="p-8 pt-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-2xl font-black text-gray-900 mb-2">
|
<h3 className={`text-2xl font-black ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Pro
|
Pro
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-8">
|
<p className={`${getThemeClasses("text-secondary")} mb-8`}>
|
||||||
For power users and professionals
|
For power users and professionals
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<span className="text-5xl font-black text-gray-900">
|
<span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
|
||||||
$9.99
|
$9.99
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg text-gray-600 ml-2">
|
<span className={`text-lg ${getThemeClasses("text-secondary")} ml-2`}>
|
||||||
CAD/month
|
CAD/month
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -601,8 +615,8 @@ function IndexPage() {
|
||||||
"Early access to features",
|
"Early access to features",
|
||||||
].map((feature, idx) => (
|
].map((feature, idx) => (
|
||||||
<div key={idx} className="flex items-start">
|
<div key={idx} className="flex items-start">
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" />
|
<CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
|
||||||
<span className="text-gray-700 text-sm font-medium">
|
<span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
|
||||||
{feature}
|
{feature}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -617,7 +631,7 @@ function IndexPage() {
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-3 text-sm text-gray-500">
|
<p className={`mt-3 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
Then $9.99 CAD/month
|
Then $9.99 CAD/month
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -626,24 +640,24 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Plan */}
|
{/* Team Plan */}
|
||||||
<Card className="border-2 border-gray-200 shadow-lg hover:shadow-xl transition-shadow duration-300">
|
<Card className={`border-2 ${getThemeClasses("border-muted")} shadow-lg hover:shadow-xl transition-shadow duration-300`}>
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-2xl font-black text-gray-900 mb-2">
|
<h3 className={`text-2xl font-black ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Team
|
Team
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-8">
|
<p className={`${getThemeClasses("text-secondary")} mb-8`}>
|
||||||
For teams and organizations
|
For teams and organizations
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<span className="text-5xl font-black text-gray-900">
|
<span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
|
||||||
$49.99
|
$49.99
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg text-gray-600 ml-2">
|
<span className={`text-lg ${getThemeClasses("text-secondary")} ml-2`}>
|
||||||
CAD/month
|
CAD/month
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-gray-500 mt-1">Up to 10 users</p>
|
<p className={`text-sm ${getThemeClasses("text-secondary")} mt-1`}>Up to 10 users</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mb-8 text-left">
|
<div className="space-y-4 mb-8 text-left">
|
||||||
|
|
@ -656,8 +670,8 @@ function IndexPage() {
|
||||||
"Dedicated support",
|
"Dedicated support",
|
||||||
].map((feature, idx) => (
|
].map((feature, idx) => (
|
||||||
<div key={idx} className="flex items-start">
|
<div key={idx} className="flex items-start">
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" />
|
<CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
|
||||||
<span className="text-gray-700 text-sm font-medium">
|
<span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
|
||||||
{feature}
|
{feature}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -677,13 +691,13 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAQ Section */}
|
{/* FAQ Section */}
|
||||||
<div id="faq" className="bg-gradient-to-br from-gray-50 to-white py-20">
|
<div id="faq" className={`${getThemeClasses("bg-gradient-primary")} py-20`}>
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-black text-gray-900 mb-4">
|
<h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
Frequently Asked Questions
|
Frequently Asked Questions
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600">
|
<p className={`text-xl ${getThemeClasses("text-secondary")}`}>
|
||||||
Have questions? We've got answers.
|
Have questions? We've got answers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -692,7 +706,7 @@ function IndexPage() {
|
||||||
{faqs.map((faq, index) => (
|
{faqs.map((faq, index) => (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={index}
|
||||||
className={`border border-gray-200 shadow-sm overflow-hidden transition-all duration-300 ${
|
className={`border ${getThemeClasses("border-muted")} shadow-sm overflow-hidden transition-all duration-300 ${
|
||||||
openFaq === index ? "shadow-lg" : "hover:shadow-md"
|
openFaq === index ? "shadow-lg" : "hover:shadow-md"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -700,16 +714,16 @@ function IndexPage() {
|
||||||
onClick={() => setOpenFaq(openFaq === index ? null : index)}
|
onClick={() => setOpenFaq(openFaq === index ? null : index)}
|
||||||
className="w-full px-6 py-5 text-left flex items-center justify-between"
|
className="w-full px-6 py-5 text-left flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span className="font-bold text-gray-900">{faq.question}</span>
|
<span className={`font-bold ${getThemeClasses("text-primary")}`}>{faq.question}</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={`h-5 w-5 text-gray-500 transition-transform duration-300 ${
|
className={`h-5 w-5 ${getThemeClasses("text-secondary")} transition-transform duration-300 ${
|
||||||
openFaq === index ? "rotate-180" : ""
|
openFaq === index ? "rotate-180" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{openFaq === index && (
|
{openFaq === index && (
|
||||||
<div className="px-6 pb-5">
|
<div className="px-6 pb-5">
|
||||||
<p className="text-gray-600 leading-relaxed">{faq.answer}</p>
|
<p className={`${getThemeClasses("text-secondary")} leading-relaxed`}>{faq.answer}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -719,12 +733,12 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<div className="bg-gradient-to-r from-red-800 to-red-900 py-20">
|
<div className={`${getThemeClasses("bg-gradient-secondary")} py-20`}>
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
<h2 className="text-4xl font-black text-white mb-6">
|
<h2 className="text-4xl font-black text-white mb-6">
|
||||||
Ready to Take Control of Your Privacy?
|
Ready to Take Control of Your Privacy?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-red-100 mb-10 max-w-2xl mx-auto">
|
<p className="text-xl text-white/80 mb-10 max-w-2xl mx-auto">
|
||||||
Join thousands of Canadians who trust MapleFile with their most sensitive files.
|
Join thousands of Canadians who trust MapleFile with their most sensitive files.
|
||||||
Get started free with 10 GB of encrypted storage.
|
Get started free with 10 GB of encrypted storage.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -733,7 +747,7 @@ function IndexPage() {
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-white text-red-800 hover:bg-gray-100 shadow-xl hover:shadow-2xl transform hover:scale-105 px-8"
|
className="shadow-xl hover:shadow-2xl transform hover:scale-105 px-8"
|
||||||
>
|
>
|
||||||
<span className="flex items-center whitespace-nowrap">
|
<span className="flex items-center whitespace-nowrap">
|
||||||
Get Started Free
|
Get Started Free
|
||||||
|
|
@ -755,43 +769,43 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-900 text-white py-16">
|
<footer className={`${getThemeClasses("bg-card")} border-t ${getThemeClasses("border-muted")} py-16`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mb-12">
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mb-12">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<div className="flex items-center mb-6">
|
<div className="flex items-center mb-6">
|
||||||
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3">
|
<div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3`}>
|
||||||
<LockClosedIcon className="h-6 w-6 text-white" />
|
<LockClosedIcon className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-white">MapleFile</span>
|
<span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>MapleFile</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 mb-6 max-w-md">
|
<p className={`${getThemeClasses("text-secondary")} mb-6 max-w-md`}>
|
||||||
Secure, encrypted file storage built in Canada. Your data stays private
|
Secure, encrypted file storage built in Canada. Your data stays private
|
||||||
with military-grade encryption and zero-knowledge architecture.
|
with military-grade encryption and zero-knowledge architecture.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
<div className={`flex items-center space-x-2 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
<span className="text-lg">🍁</span>
|
<span className="text-lg">🍁</span>
|
||||||
<span>Proudly Canadian</span>
|
<span>Proudly Canadian</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-gray-300 tracking-wider uppercase mb-4">
|
<h3 className={`text-sm font-bold ${getThemeClasses("text-secondary")} tracking-wider uppercase mb-4`}>
|
||||||
Product
|
Product
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
<li>
|
<li>
|
||||||
<a href="#security" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<a href="#security" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
Security
|
Security
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#pricing" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<a href="#pricing" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
Pricing
|
Pricing
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#faq" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<a href="#faq" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
FAQ
|
FAQ
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -799,22 +813,22 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-gray-300 tracking-wider uppercase mb-4">
|
<h3 className={`text-sm font-bold ${getThemeClasses("text-secondary")} tracking-wider uppercase mb-4`}>
|
||||||
Account
|
Account
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
<li>
|
<li>
|
||||||
<Link to="/login" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<Link to="/login" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link to="/register" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<Link to="/register" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
Create Account
|
Create Account
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link to="/recovery" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<Link to="/recovery" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
Account Recovery
|
Account Recovery
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -822,19 +836,19 @@ function IndexPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-8">
|
<div className={`border-t ${getThemeClasses("border-muted")} pt-8`}>
|
||||||
<div className="md:flex md:items-center md:justify-between">
|
<div className="md:flex md:items-center md:justify-between">
|
||||||
<p className="text-gray-400 text-sm">
|
<p className={`${getThemeClasses("text-secondary")} text-sm`}>
|
||||||
© 2025 MapleFile Inc. All rights reserved. Made with ❤️ in Canada.
|
© 2025 MapleFile Inc. All rights reserved. Made with ❤️ in Canada.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 md:mt-0 flex space-x-6 text-sm">
|
<div className="mt-4 md:mt-0 flex space-x-6 text-sm">
|
||||||
<a href="mailto:privacy@maplefile.com" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<a href="mailto:privacy@maplefile.com" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</a>
|
</a>
|
||||||
<a href="mailto:legal@maplefile.com" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<a href="mailto:legal@maplefile.com" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</a>
|
</a>
|
||||||
<a href="mailto:support@maplefile.com" className="text-gray-400 hover:text-white transition-colors duration-200">
|
<a href="mailto:support@maplefile.com" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
|
||||||
Contact
|
Contact
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Login/CompleteLogin.jsx
|
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Login/CompleteLogin.jsx
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { useNavigate, Link } from "react-router";
|
import { useNavigate, Link } from "react-router"; // Link still used for "Forgot password?"
|
||||||
import { useServices } from "../../../services/Services";
|
import { useServices } from "../../../services/Services";
|
||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
|
|
@ -15,12 +15,18 @@ import {
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
// UIX Components
|
// UIX Components
|
||||||
import Input from "../../../components/UIX/Input/Input";
|
import {
|
||||||
import Button from "../../../components/UIX/Button/Button";
|
Input,
|
||||||
import Checkbox from "../../../components/UIX/Checkbox/Checkbox";
|
Button,
|
||||||
import Alert from "../../../components/UIX/Alert/Alert";
|
Checkbox,
|
||||||
import Card from "../../../components/UIX/Card/Card";
|
Alert,
|
||||||
import GDPRFooter from "../../../components/UIX/GDPRFooter/GDPRFooter";
|
Card,
|
||||||
|
GDPRFooter,
|
||||||
|
PageContainer,
|
||||||
|
Navigation,
|
||||||
|
ProgressIndicator,
|
||||||
|
PageHeader,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import { useUIXTheme } from "../../../components/UIX/themes/useUIXTheme";
|
import { useUIXTheme } from "../../../components/UIX/themes/useUIXTheme";
|
||||||
|
|
||||||
const CompleteLogin = () => {
|
const CompleteLogin = () => {
|
||||||
|
|
@ -114,18 +120,34 @@ const CompleteLogin = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get verification data
|
// Try to get verification data with schema validation
|
||||||
try {
|
try {
|
||||||
const sessionData = sessionStorage.getItem("otpVerificationResult");
|
const sessionData = sessionStorage.getItem("otpVerificationResult");
|
||||||
if (sessionData) {
|
if (sessionData) {
|
||||||
storedVerificationData = JSON.parse(sessionData);
|
const parsed = JSON.parse(sessionData);
|
||||||
if (import.meta.env.DEV) {
|
// Validate expected structure to prevent injection attacks
|
||||||
console.log(
|
if (
|
||||||
"[CompleteLogin] Using verification data from sessionStorage",
|
parsed &&
|
||||||
);
|
typeof parsed === 'object' &&
|
||||||
|
(typeof parsed.challengeId === 'string' || typeof parsed.challenge_id === 'string')
|
||||||
|
) {
|
||||||
|
storedVerificationData = parsed;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(
|
||||||
|
"[CompleteLogin] Using verification data from sessionStorage",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Invalid structure - clear corrupted data
|
||||||
|
sessionStorage.removeItem("otpVerificationResult");
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn("[CompleteLogin] Invalid verification data structure, cleared");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Parse error - clear corrupted data
|
||||||
|
sessionStorage.removeItem("otpVerificationResult");
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[CompleteLogin] Could not parse verification data from sessionStorage:",
|
"[CompleteLogin] Could not parse verification data from sessionStorage:",
|
||||||
|
|
@ -480,11 +502,27 @@ const CompleteLogin = () => {
|
||||||
// Memoize display email
|
// Memoize display email
|
||||||
const displayEmail = useMemo(() => email || "", [email]);
|
const displayEmail = useMemo(() => email || "", [email]);
|
||||||
|
|
||||||
|
// Memoize progress steps
|
||||||
|
const progressSteps = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Email", completed: true },
|
||||||
|
{ label: "Verify", completed: true },
|
||||||
|
{ label: "Access", completed: false },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize navigation links
|
||||||
|
const navLinks = useMemo(
|
||||||
|
() => [{ to: "/register", text: "Create account", variant: "secondary" }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Early return if verification data is not available
|
// Early return if verification data is not available
|
||||||
if (!verificationData) {
|
if (!verificationData) {
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex items-center justify-center`}>
|
<PageContainer>
|
||||||
<Card className="text-center p-8">
|
<Card className="text-center p-8 max-w-md mx-auto mt-20">
|
||||||
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>
|
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
Loading...
|
Loading...
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -492,86 +530,28 @@ const CompleteLogin = () => {
|
||||||
Preparing secure login...
|
Preparing secure login...
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}>
|
<PageContainer showBlobs>
|
||||||
{/* Decorative Background Blobs */}
|
{/* Navigation */}
|
||||||
<div className={`${getThemeClasses("decorative-blob-1")}`}></div>
|
<Navigation icon={LockClosedIcon} logoText="MapleFile" links={navLinks} />
|
||||||
<div className={`${getThemeClasses("decorative-blob-2")}`}></div>
|
|
||||||
<div className={`${getThemeClasses("decorative-blob-3")}`}></div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex-shrink-0 relative z-10">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<Link to="/" className="inline-flex items-center space-x-3 group">
|
|
||||||
<div className="relative">
|
|
||||||
<div className={`absolute inset-0 ${getThemeClasses("bg-gradient-secondary")} rounded-xl opacity-0 group-hover:opacity-100 blur-xl transition-opacity duration-300`}></div>
|
|
||||||
<div className={`relative flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-xl shadow-md group-hover:shadow-lg transform group-hover:scale-105 transition-all duration-200`}>
|
|
||||||
<LockClosedIcon className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={`text-xl font-bold ${getThemeClasses("text-primary")} transition-colors duration-200`}>
|
|
||||||
MapleFile
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 py-12 relative z-10">
|
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 py-12 relative z-10">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
{/* Progress Indicator */}
|
{/* Progress Indicator */}
|
||||||
<div className="flex items-center justify-center">
|
<ProgressIndicator steps={progressSteps} currentStep={3} />
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 bg-green-600 rounded-full text-white text-sm font-bold">
|
|
||||||
<CheckIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
|
|
||||||
Email
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 h-0.5 bg-green-600"></div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 bg-green-600 rounded-full text-white text-sm font-bold">
|
|
||||||
<CheckIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
|
|
||||||
Verify
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 h-0.5 bg-green-600"></div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("pagination-active")} rounded-full text-sm font-bold shadow-lg`}>
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}>
|
|
||||||
Access
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center">
|
<PageHeader
|
||||||
<div className="flex justify-center mb-6">
|
icon={KeyIcon}
|
||||||
<div className="relative">
|
title="Unlock Your Account"
|
||||||
<div className={`absolute inset-0 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur-2xl opacity-20 animate-pulse`}></div>
|
subtitle="Enter your master password to decrypt your data."
|
||||||
<div className={`relative h-16 w-16 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl flex items-center justify-center shadow-xl`}>
|
className="text-center"
|
||||||
<KeyIcon className="h-8 w-8 text-white" />
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 className={`text-3xl font-bold ${getThemeClasses("text-primary")}`}>
|
|
||||||
Unlock Your Account
|
|
||||||
</h1>
|
|
||||||
<p className={`mt-2 ${getThemeClasses("text-secondary")}`}>
|
|
||||||
Enter your master password to decrypt your data.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GDPR Notice - Master Password Privacy */}
|
{/* GDPR Notice - Master Password Privacy */}
|
||||||
<Alert type="info">
|
<Alert type="info">
|
||||||
|
|
@ -748,7 +728,7 @@ const CompleteLogin = () => {
|
||||||
|
|
||||||
{/* Footer - GDPR Rights */}
|
{/* Footer - GDPR Rights */}
|
||||||
<GDPRFooter />
|
<GDPRFooter />
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,46 @@
|
||||||
// File: src/pages/Anonymous/Login/SessionExpired.jsx
|
// File: src/pages/Anonymous/Login/SessionExpired.jsx
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useNavigate, useLocation, Link } from "react-router";
|
import { useNavigate, useLocation, Link } from "react-router";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Alert,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
LockClosedIcon,
|
LockClosedIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ExclamationTriangleIcon,
|
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
|
HomeIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const SessionExpired = () => {
|
const SessionExpired = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
const [countdown, setCountdown] = useState(30);
|
const [countdown, setCountdown] = useState(30);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// Get the reason and message from location state
|
// Get the reason and message from location state
|
||||||
const { reason, message, from } = location.state || {};
|
const { reason, from } = location.state || {};
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Determine the appropriate message and icon based on reason
|
// Determine the appropriate message and icon based on reason
|
||||||
const getSessionInfo = () => {
|
const getSessionInfo = useCallback(() => {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case "inactivity_timeout":
|
case "inactivity_timeout":
|
||||||
return {
|
return {
|
||||||
|
|
@ -29,8 +49,7 @@ const SessionExpired = () => {
|
||||||
description:
|
description:
|
||||||
"For your security, we automatically log you out after 60 minutes of inactivity. This helps protect your encrypted files from unauthorized access.",
|
"For your security, we automatically log you out after 60 minutes of inactivity. This helps protect your encrypted files from unauthorized access.",
|
||||||
icon: ClockIcon,
|
icon: ClockIcon,
|
||||||
iconColor: "text-amber-600",
|
type: "warning",
|
||||||
iconBg: "bg-amber-100",
|
|
||||||
};
|
};
|
||||||
case "manual_clear":
|
case "manual_clear":
|
||||||
return {
|
return {
|
||||||
|
|
@ -39,8 +58,7 @@ const SessionExpired = () => {
|
||||||
description:
|
description:
|
||||||
"Your session has been cleared. This might have happened if you logged out from another tab or if there was a security-related action.",
|
"Your session has been cleared. This might have happened if you logged out from another tab or if there was a security-related action.",
|
||||||
icon: ShieldCheckIcon,
|
icon: ShieldCheckIcon,
|
||||||
iconColor: "text-blue-600",
|
type: "info",
|
||||||
iconBg: "bg-blue-100",
|
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
|
|
@ -49,17 +67,18 @@ const SessionExpired = () => {
|
||||||
description:
|
description:
|
||||||
"For your security, your session has expired. Please sign in again to continue accessing your encrypted files.",
|
"For your security, your session has expired. Please sign in again to continue accessing your encrypted files.",
|
||||||
icon: LockClosedIcon,
|
icon: LockClosedIcon,
|
||||||
iconColor: "text-red-600",
|
type: "error",
|
||||||
iconBg: "bg-red-100",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
}, [reason]);
|
||||||
|
|
||||||
const sessionInfo = getSessionInfo();
|
const sessionInfo = getSessionInfo();
|
||||||
|
|
||||||
// Auto-redirect countdown
|
// Auto-redirect countdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
setCountdown((prev) => {
|
setCountdown((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
navigate("/login", {
|
navigate("/login", {
|
||||||
|
|
@ -75,46 +94,50 @@ const SessionExpired = () => {
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [navigate, from]);
|
}, [navigate, from]);
|
||||||
|
|
||||||
const handleSignInNow = () => {
|
const handleSignInNow = useCallback(() => {
|
||||||
navigate("/login", {
|
navigate("/login", {
|
||||||
state: {
|
state: {
|
||||||
from: from,
|
from: from,
|
||||||
reason: "session_expired",
|
reason: "session_expired",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
}, [navigate, from]);
|
||||||
|
|
||||||
const handleGoHome = () => {
|
const handleGoHome = useCallback(() => {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
};
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex flex-col">
|
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}>
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="bg-white/95 backdrop-blur-sm border-b border-gray-100">
|
<nav className={`${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-4">
|
||||||
<Link to="/" className="flex items-center group">
|
<Link to="/" className="flex items-center group">
|
||||||
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200">
|
<div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
|
||||||
<LockClosedIcon className="h-6 w-6 text-white" />
|
<LockClosedIcon className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent">
|
<span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
||||||
MapleFile
|
MapleFile
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
<Link
|
<Link
|
||||||
to="/register"
|
to="/register"
|
||||||
className="text-base font-medium text-gray-700 hover:text-red-800 transition-colors duration-200"
|
className={`text-base font-medium ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Need an account?
|
Need an account?
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/recovery"
|
to="/recovery"
|
||||||
className="text-base font-medium text-gray-700 hover:text-red-800 transition-colors duration-200"
|
className={`text-base font-medium ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -130,107 +153,106 @@ const SessionExpired = () => {
|
||||||
<div className="text-center animate-fade-in-up">
|
<div className="text-center animate-fade-in-up">
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-center mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div className={`flex items-center justify-center h-16 w-16 ${
|
||||||
className={`flex items-center justify-center h-16 w-16 ${sessionInfo.iconBg} rounded-2xl shadow-lg`}
|
sessionInfo.type === "warning" ? getThemeClasses("bg-warning-light") :
|
||||||
>
|
sessionInfo.type === "info" ? getThemeClasses("bg-info-light") :
|
||||||
<sessionInfo.icon
|
getThemeClasses("bg-error-light")
|
||||||
className={`h-8 w-8 ${sessionInfo.iconColor}`}
|
} rounded-2xl shadow-lg`}>
|
||||||
/>
|
<sessionInfo.icon className={`h-8 w-8 ${
|
||||||
|
sessionInfo.type === "warning" ? getThemeClasses("text-warning") :
|
||||||
|
sessionInfo.type === "info" ? getThemeClasses("text-info") :
|
||||||
|
getThemeClasses("text-error")
|
||||||
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -inset-1 bg-gradient-to-r from-red-800 to-red-900 rounded-2xl blur opacity-20"></div>
|
<div className={`absolute -inset-1 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur opacity-20`}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-black text-gray-900 mb-2">
|
<h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
{sessionInfo.title}
|
{sessionInfo.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-4">{sessionInfo.subtitle}</p>
|
<p className={`${getThemeClasses("text-secondary")} mb-4`}>{sessionInfo.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Session Info Card */}
|
{/* Session Info Card */}
|
||||||
<div className="bg-white rounded-2xl shadow-2xl border border-gray-100 p-8 animate-fade-in-up-delay">
|
<Card className="shadow-2xl p-8 animate-fade-in-up-delay">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-100">
|
<Alert type="info" className="mb-6">
|
||||||
<div className="flex items-start">
|
<div>
|
||||||
<InformationCircleIcon className="h-5 w-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5" />
|
<strong className="font-semibold">What happened?</strong>
|
||||||
<div>
|
<p className="mt-1">{sessionInfo.description}</p>
|
||||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">
|
|
||||||
What happened?
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
{sessionInfo.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* Security Info */}
|
{/* Security Info */}
|
||||||
<div className="mb-6 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-100">
|
<Alert type="success" className="mb-6">
|
||||||
<div className="flex items-start">
|
<div>
|
||||||
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-3 flex-shrink-0 mt-0.5" />
|
<strong className="font-semibold">Your data is secure</strong>
|
||||||
<div>
|
<ul className="mt-1 space-y-1">
|
||||||
<h3 className="text-sm font-semibold text-green-900 mb-2">
|
<li>• Your files remain encrypted and protected</li>
|
||||||
Your data is secure
|
<li>• No one can access your data without your password</li>
|
||||||
</h3>
|
<li>• Session expiry is a security feature, not a problem</li>
|
||||||
<ul className="text-sm text-green-800 space-y-1">
|
</ul>
|
||||||
<li>• Your files remain encrypted and protected</li>
|
|
||||||
<li>• No one can access your data without your password</li>
|
|
||||||
<li>
|
|
||||||
• Session expiry is a security feature, not a problem
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* Auto-redirect notice */}
|
{/* Auto-redirect notice */}
|
||||||
<div className="mb-6 p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-lg border border-amber-100">
|
<Alert type="warning" className="mb-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<ClockIcon className="h-5 w-5 text-amber-500 mr-3" />
|
<ClockIcon className="h-5 w-5 mr-3 flex-shrink-0" />
|
||||||
<p className="text-sm text-amber-800">
|
<p>
|
||||||
Automatically redirecting to sign in page in{" "}
|
Automatically redirecting to sign in page in{" "}
|
||||||
<span className="font-bold text-amber-900">{countdown}</span>{" "}
|
<span className="font-bold">{countdown}</span>{" "}
|
||||||
seconds
|
seconds
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
<Button
|
||||||
onClick={handleSignInNow}
|
onClick={handleSignInNow}
|
||||||
className="group w-full flex justify-center items-center py-3 px-4 border border-transparent text-base font-semibold rounded-lg text-white bg-gradient-to-r from-red-800 to-red-900 hover:from-red-900 hover:to-red-950 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl"
|
variant="primary"
|
||||||
|
fullWidth
|
||||||
|
className="py-3"
|
||||||
>
|
>
|
||||||
Sign In Now
|
<span className="inline-flex items-center gap-2">
|
||||||
<ArrowRightIcon className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform duration-200" />
|
<span>Sign In Now</span>
|
||||||
</button>
|
<ArrowRightIcon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
onClick={handleGoHome}
|
onClick={handleGoHome}
|
||||||
className="w-full flex justify-center items-center py-3 px-4 border-2 border-gray-300 text-base font-semibold rounded-lg text-gray-700 bg-white hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl"
|
variant="secondary"
|
||||||
|
fullWidth
|
||||||
|
className="py-3"
|
||||||
>
|
>
|
||||||
Go to Homepage
|
<span className="inline-flex items-center gap-2">
|
||||||
</button>
|
<HomeIcon className="h-4 w-4" />
|
||||||
|
<span>Go to Homepage</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alternative Actions */}
|
{/* Alternative Actions */}
|
||||||
<div className="mt-6 flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0 text-sm">
|
<div className="mt-6 flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0 text-sm">
|
||||||
<Link
|
<Link
|
||||||
to="/register"
|
to="/register"
|
||||||
className="text-red-600 hover:text-red-700 font-medium hover:underline transition-colors duration-200"
|
className={`${getThemeClasses("link-primary")} font-medium hover:underline transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Create new account
|
Create new account
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/recovery"
|
to="/recovery"
|
||||||
className="text-gray-600 hover:text-gray-700 font-medium hover:underline transition-colors duration-200"
|
className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium hover:underline transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Additional Info */}
|
{/* Additional Info */}
|
||||||
<div className="text-center text-sm text-gray-500 animate-fade-in-up-delay-2">
|
<div className={`text-center text-sm ${getThemeClasses("text-secondary")} animate-fade-in-up-delay-2`}>
|
||||||
<p>Session timeout: 60 minutes of inactivity</p>
|
<p>Session timeout: 60 minutes of inactivity</p>
|
||||||
<p className="mt-1">This helps keep your encrypted files secure</p>
|
<p className="mt-1">This helps keep your encrypted files secure</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -238,26 +260,26 @@ const SessionExpired = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-white border-t border-gray-100 py-8">
|
<footer className={`${getThemeClasses("bg-card")} border-t ${getThemeClasses("border-muted")} py-8`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center text-sm text-gray-500">
|
<div className={`text-center text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
<p>© 2025 MapleFile Inc. All rights reserved.</p>
|
<p>© 2025 MapleFile Inc. All rights reserved.</p>
|
||||||
<div className="mt-2 space-x-4">
|
<div className="mt-2 space-x-4">
|
||||||
<Link
|
<Link
|
||||||
to="#"
|
to="/privacy"
|
||||||
className="hover:text-gray-700 transition-colors duration-200"
|
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="#"
|
to="/terms"
|
||||||
className="hover:text-gray-700 transition-colors duration-200"
|
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="#"
|
to="/support"
|
||||||
className="hover:text-gray-700 transition-colors duration-200"
|
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Support
|
Support
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -401,9 +401,11 @@ const VerifyOTT = () => {
|
||||||
|
|
||||||
const handleOttChange = useCallback((value) => {
|
const handleOttChange = useCallback((value) => {
|
||||||
setOtt(value);
|
setOtt(value);
|
||||||
if (generalError) setGeneralError("");
|
// Clear errors only once when user starts typing - uses functional updates
|
||||||
if (Object.keys(fieldErrors).length > 0) setFieldErrors({});
|
// to avoid dependencies on error state values
|
||||||
}, [generalError, fieldErrors]);
|
setGeneralError((prev) => prev ? "" : prev);
|
||||||
|
setFieldErrors((prev) => Object.keys(prev).length > 0 ? {} : prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Memoize display email
|
// Memoize display email
|
||||||
const displayEmail = useMemo(() => email || "", [email]);
|
const displayEmail = useMemo(() => email || "", [email]);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Recovery/CompleteRecovery.jsx
|
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Recovery/CompleteRecovery.jsx
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { useNavigate, Link } from "react-router";
|
import { useNavigate, Link } from "react-router";
|
||||||
import { useServices } from "../../../services/Services";
|
import { useServices } from "../../../services/Services";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Spinner,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
|
|
@ -13,16 +21,17 @@ import {
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
EyeSlashIcon,
|
EyeSlashIcon,
|
||||||
DocumentTextIcon,
|
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
LockOpenIcon,
|
LockOpenIcon,
|
||||||
ServerIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const CompleteRecovery = () => {
|
const CompleteRecovery = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { recoveryManager } = useServices();
|
const { recoveryManager } = useServices();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [recoveryPhrase, setRecoveryPhrase] = useState("");
|
const [recoveryPhrase, setRecoveryPhrase] = useState("");
|
||||||
|
|
@ -31,7 +40,14 @@ const CompleteRecovery = () => {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [showRecoveryPhrase, setShowRecoveryPhrase] = useState(false);
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if we have completed verification
|
// Check if we have completed verification
|
||||||
|
|
@ -39,9 +55,9 @@ const CompleteRecovery = () => {
|
||||||
const isVerified = recoveryManager.isVerificationComplete();
|
const isVerified = recoveryManager.isVerificationComplete();
|
||||||
|
|
||||||
if (!recoveryEmail || !isVerified) {
|
if (!recoveryEmail || !isVerified) {
|
||||||
console.log(
|
if (import.meta.env.DEV) {
|
||||||
"[CompleteRecovery] No verified recovery session, redirecting",
|
console.log("[CompleteRecovery] No verified recovery session, redirecting");
|
||||||
);
|
}
|
||||||
navigate("/recovery/initiate");
|
navigate("/recovery/initiate");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -49,8 +65,11 @@ const CompleteRecovery = () => {
|
||||||
setEmail(recoveryEmail);
|
setEmail(recoveryEmail);
|
||||||
}, [navigate, recoveryManager]);
|
}, [navigate, recoveryManager]);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = useCallback(async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
|
|
@ -77,15 +96,21 @@ const CompleteRecovery = () => {
|
||||||
// Join words with single space
|
// Join words with single space
|
||||||
const normalizedPhrase = words.join(" ");
|
const normalizedPhrase = words.join(" ");
|
||||||
|
|
||||||
console.log("[CompleteRecovery] Completing recovery with new password");
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[CompleteRecovery] Completing recovery with new password");
|
||||||
|
}
|
||||||
|
|
||||||
// Complete recovery with both recovery phrase and new password
|
// Complete recovery with both recovery phrase and new password
|
||||||
const response = await recoveryManager.completeRecoveryWithPhrase(
|
await recoveryManager.completeRecoveryWithPhrase(
|
||||||
normalizedPhrase,
|
normalizedPhrase,
|
||||||
newPassword,
|
newPassword,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("[CompleteRecovery] Recovery completed successfully");
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[CompleteRecovery] Recovery completed successfully");
|
||||||
|
}
|
||||||
|
|
||||||
// Show success modal or alert
|
// Show success modal or alert
|
||||||
alert(
|
alert(
|
||||||
|
|
@ -94,50 +119,88 @@ const CompleteRecovery = () => {
|
||||||
|
|
||||||
// Navigate to login
|
// Navigate to login
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error("[CompleteRecovery] Recovery completion failed:", error);
|
if (!isMountedRef.current) return;
|
||||||
setError(error.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackToVerify = () => {
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[CompleteRecovery] Recovery completion failed:", err);
|
||||||
|
}
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [newPassword, confirmPassword, recoveryPhrase, recoveryManager, navigate]);
|
||||||
|
|
||||||
|
const handleBackToVerify = useCallback(() => {
|
||||||
navigate("/recovery/verify");
|
navigate("/recovery/verify");
|
||||||
};
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Memoize word count calculation
|
||||||
|
const wordCount = useMemo(() => {
|
||||||
|
return recoveryPhrase.trim() ? recoveryPhrase.trim().split(/\s+/).length : 0;
|
||||||
|
}, [recoveryPhrase]);
|
||||||
|
|
||||||
|
// Memoize password toggle buttons
|
||||||
|
const newPasswordSuffix = useMemo(() => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
className="flex items-center touch-manipulation"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showNewPassword ? (
|
||||||
|
<EyeSlashIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")}`} />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")}`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
), [showNewPassword, getThemeClasses]);
|
||||||
|
|
||||||
|
const confirmPasswordSuffix = useMemo(() => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="flex items-center touch-manipulation"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeSlashIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")}`} />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")}`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
), [showConfirmPassword, getThemeClasses]);
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex items-center justify-center">
|
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex items-center justify-center`}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Loading...</h2>
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
<p className="text-gray-600">Checking recovery session...</p>
|
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>Loading...</h2>
|
||||||
|
<p className={getThemeClasses("text-secondary")}>Checking recovery session...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count words in recovery phrase
|
|
||||||
const wordCount = recoveryPhrase.trim()
|
|
||||||
? recoveryPhrase.trim().split(/\s+/).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex flex-col">
|
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}>
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="bg-white/95 backdrop-blur-sm border-b border-gray-100">
|
<nav className={`${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-4">
|
||||||
<Link to="/" className="flex items-center group">
|
<Link to="/" className="flex items-center group">
|
||||||
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200">
|
<div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
|
||||||
<LockClosedIcon className="h-6 w-6 text-white" />
|
<LockClosedIcon className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent">
|
<span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
||||||
MapleFile
|
MapleFile
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
<span className="text-base font-medium text-gray-500">
|
<span className={`text-base font-medium ${getThemeClasses("text-secondary")}`}>
|
||||||
Step 3 of 3
|
Step 3 of 3
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -152,28 +215,28 @@ const CompleteRecovery = () => {
|
||||||
<div className="flex items-center justify-center mb-8">
|
<div className="flex items-center justify-center mb-8">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center justify-center w-8 h-8 bg-green-500 rounded-full text-white text-sm font-bold">
|
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-success")} rounded-full text-white text-sm font-bold`}>
|
||||||
<CheckIcon className="h-4 w-4" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-2 text-sm font-semibold text-green-600">
|
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
|
||||||
Email
|
Email
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-0.5 bg-green-500"></div>
|
<div className={`w-12 h-0.5 ${getThemeClasses("bg-success")}`}></div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center justify-center w-8 h-8 bg-green-500 rounded-full text-white text-sm font-bold">
|
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-success")} rounded-full text-white text-sm font-bold`}>
|
||||||
<CheckIcon className="h-4 w-4" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-2 text-sm font-semibold text-green-600">
|
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
|
||||||
Verify
|
Verify
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-0.5 bg-green-500"></div>
|
<div className={`w-12 h-0.5 ${getThemeClasses("bg-success")}`}></div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center justify-center w-8 h-8 bg-gradient-to-r from-red-800 to-red-900 rounded-full text-white text-sm font-bold">
|
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-gradient-secondary")} rounded-full text-white text-sm font-bold`}>
|
||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-2 text-sm font-semibold text-gray-900">
|
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}>
|
||||||
Reset
|
Reset
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,20 +247,20 @@ const CompleteRecovery = () => {
|
||||||
<div className="text-center animate-fade-in-up">
|
<div className="text-center animate-fade-in-up">
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-center mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-center justify-center h-16 w-16 bg-gradient-to-br from-red-800 to-red-900 rounded-2xl shadow-lg animate-pulse">
|
<div className={`flex items-center justify-center h-16 w-16 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl shadow-lg animate-pulse`}>
|
||||||
<LockOpenIcon className="h-8 w-8 text-white" />
|
<LockOpenIcon className="h-8 w-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -inset-1 bg-gradient-to-r from-red-800 to-red-900 rounded-2xl blur opacity-20 animate-pulse"></div>
|
<div className={`absolute -inset-1 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur opacity-20 animate-pulse`}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-black text-gray-900 mb-2">
|
<h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Set Your New Password
|
Set Your New Password
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-2">
|
<p className={`${getThemeClasses("text-secondary")} mb-2`}>
|
||||||
Final step: Create a new password for {email}
|
Final step: Create a new password for {email}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center space-x-2 text-sm text-gray-500">
|
<div className={`flex items-center justify-center space-x-2 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
<ArrowPathIcon className="h-4 w-4 text-green-600" />
|
<ArrowPathIcon className={`h-4 w-4 ${getThemeClasses("text-success")}`} />
|
||||||
<span>
|
<span>
|
||||||
Your encryption keys will be re-encrypted with the new password
|
Your encryption keys will be re-encrypted with the new password
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -205,38 +268,33 @@ const CompleteRecovery = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form Card */}
|
{/* Form Card */}
|
||||||
<div className="bg-white rounded-2xl shadow-2xl border border-gray-100 p-8 animate-fade-in-up-delay">
|
<Card className="shadow-2xl p-8 animate-fade-in-up-delay">
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 p-4 rounded-lg bg-red-50 border border-red-200 animate-fade-in">
|
<Alert
|
||||||
<div className="flex items-center">
|
type="error"
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0" />
|
dismissible
|
||||||
<div>
|
onDismiss={() => setError("")}
|
||||||
<h3 className="text-sm font-semibold text-red-800">
|
className="mb-6 animate-fade-in"
|
||||||
Recovery Error
|
>
|
||||||
</h3>
|
<div>
|
||||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
<strong className="font-semibold">Recovery Error</strong>
|
||||||
</div>
|
<p className="mt-1">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Security Notice */}
|
{/* Security Notice */}
|
||||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
<Alert type="info" className="mb-6">
|
||||||
<div className="flex items-start">
|
<div>
|
||||||
<InformationCircleIcon className="h-5 w-5 text-blue-600 mr-3 flex-shrink-0 mt-0.5" />
|
<strong className="font-semibold">Why enter your recovery phrase again?</strong>
|
||||||
<div className="flex-1">
|
<p className="mt-1">
|
||||||
<h3 className="text-sm font-semibold text-blue-800 mb-1">
|
We need your recovery phrase to decrypt your master key and
|
||||||
Why enter your recovery phrase again?
|
re-encrypt it with your new password. This ensures
|
||||||
</h3>
|
continuous access to your encrypted files.
|
||||||
<p className="text-sm text-blue-700">
|
</p>
|
||||||
We need your recovery phrase to decrypt your master key and
|
|
||||||
re-encrypt it with your new password. This ensures
|
|
||||||
continuous access to your encrypted files.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|
@ -245,17 +303,17 @@ const CompleteRecovery = () => {
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="recoveryPhrase"
|
htmlFor="recoveryPhrase"
|
||||||
className="block text-sm font-semibold text-gray-700"
|
className={`block text-sm font-semibold ${getThemeClasses("text-primary")}`}
|
||||||
>
|
>
|
||||||
Recovery Phrase (Required Again)
|
Recovery Phrase (Required Again)
|
||||||
</label>
|
</label>
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium ${
|
className={`text-xs font-medium ${
|
||||||
wordCount === 12
|
wordCount === 12
|
||||||
? "text-green-600"
|
? getThemeClasses("text-success")
|
||||||
: wordCount > 0
|
: wordCount > 0
|
||||||
? "text-amber-600"
|
? getThemeClasses("text-warning")
|
||||||
: "text-gray-500"
|
: getThemeClasses("text-secondary")
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{wordCount}/12 words
|
{wordCount}/12 words
|
||||||
|
|
@ -270,112 +328,56 @@ const CompleteRecovery = () => {
|
||||||
rows={3}
|
rows={3}
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 disabled:bg-gray-50 disabled:cursor-not-allowed text-gray-900 placeholder-gray-500 font-mono text-sm leading-relaxed resize-none ${
|
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 ${getThemeClasses("focus:ring-accent")} ${getThemeClasses("focus:border-accent")} transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${getThemeClasses("text-primary")} ${getThemeClasses("placeholder-secondary")} font-mono text-sm leading-relaxed resize-none ${getThemeClasses("bg-card")} ${
|
||||||
wordCount === 12
|
wordCount === 12
|
||||||
? "border-green-300 bg-green-50"
|
? `${getThemeClasses("border-success")} ${getThemeClasses("bg-success-light")}`
|
||||||
: "border-gray-300"
|
: getThemeClasses("border-muted")
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{showRecoveryPhrase && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowRecoveryPhrase(!showRecoveryPhrase)}
|
|
||||||
className="absolute top-3 right-3"
|
|
||||||
>
|
|
||||||
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Password Section */}
|
{/* New Password Section */}
|
||||||
<div className="space-y-4 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-100">
|
<div className={`space-y-4 p-4 ${getThemeClasses("bg-success-light")} rounded-lg border ${getThemeClasses("border-success")}`}>
|
||||||
<h3 className="text-sm font-semibold text-green-900 flex items-center">
|
<h3 className={`text-sm font-semibold ${getThemeClasses("text-primary")} flex items-center`}>
|
||||||
<KeyIcon className="h-4 w-4 mr-2" />
|
<KeyIcon className="h-4 w-4 mr-2" />
|
||||||
Create Your New Password
|
Create Your New Password
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div>
|
<Input
|
||||||
<label
|
label="New Password"
|
||||||
htmlFor="newPassword"
|
type={showNewPassword ? "text" : "password"}
|
||||||
className="block text-sm font-semibold text-gray-700 mb-2"
|
name="newPassword"
|
||||||
>
|
placeholder="Enter your new password"
|
||||||
New Password
|
value={newPassword}
|
||||||
</label>
|
onChange={(value) => setNewPassword(value)}
|
||||||
<div className="relative">
|
disabled={loading}
|
||||||
<input
|
required
|
||||||
type={showNewPassword ? "text" : "password"}
|
autoComplete="new-password"
|
||||||
id="newPassword"
|
icon={LockClosedIcon}
|
||||||
value={newPassword}
|
suffix={newPasswordSuffix}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
helperText="Password must be at least 8 characters long"
|
||||||
placeholder="Enter your new password"
|
error={error && error.includes("password") ? error : null}
|
||||||
required
|
/>
|
||||||
disabled={loading}
|
|
||||||
autoComplete="new-password"
|
|
||||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 text-gray-900 placeholder-gray-500 pr-12 ${
|
|
||||||
error && error.includes("password")
|
|
||||||
? "border-red-300"
|
|
||||||
: "border-gray-300"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
||||||
>
|
|
||||||
{showNewPassword ? (
|
|
||||||
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
|
||||||
) : (
|
|
||||||
<EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Password must be at least 8 characters long
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirm Password */}
|
{/* Confirm Password */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<Input
|
||||||
htmlFor="confirmPassword"
|
label="Confirm New Password"
|
||||||
className="block text-sm font-semibold text-gray-700 mb-2"
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
>
|
name="confirmPassword"
|
||||||
Confirm New Password
|
placeholder="Confirm your new password"
|
||||||
</label>
|
value={confirmPassword}
|
||||||
<div className="relative">
|
onChange={(value) => setConfirmPassword(value)}
|
||||||
<input
|
disabled={loading}
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
required
|
||||||
id="confirmPassword"
|
autoComplete="new-password"
|
||||||
value={confirmPassword}
|
icon={LockClosedIcon}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
suffix={confirmPasswordSuffix}
|
||||||
placeholder="Confirm your new password"
|
/>
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
autoComplete="new-password"
|
|
||||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 text-gray-900 placeholder-gray-500 pr-12 ${
|
|
||||||
confirmPassword && newPassword === confirmPassword
|
|
||||||
? "border-green-300 bg-green-50"
|
|
||||||
: "border-gray-300"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setShowConfirmPassword(!showConfirmPassword)
|
|
||||||
}
|
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
||||||
>
|
|
||||||
{showConfirmPassword ? (
|
|
||||||
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
|
||||||
) : (
|
|
||||||
<EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{confirmPassword && newPassword === confirmPassword && (
|
{confirmPassword && newPassword === confirmPassword && (
|
||||||
<p className="mt-1 text-xs text-green-600 flex items-center">
|
<p className={`mt-1 text-xs ${getThemeClasses("text-success")} flex items-center`}>
|
||||||
<CheckIcon className="h-3 w-3 mr-1" />
|
<CheckIcon className="h-3 w-3 mr-1" />
|
||||||
Passwords match
|
Passwords match
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -384,99 +386,82 @@ const CompleteRecovery = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
fullWidth
|
||||||
disabled={
|
disabled={
|
||||||
loading ||
|
loading ||
|
||||||
wordCount !== 12 ||
|
wordCount !== 12 ||
|
||||||
!newPassword ||
|
!newPassword ||
|
||||||
newPassword !== confirmPassword
|
newPassword !== confirmPassword
|
||||||
}
|
}
|
||||||
className="group w-full flex justify-center items-center py-3 px-4 border border-transparent text-base font-semibold rounded-lg text-white bg-gradient-to-r from-red-800 to-red-900 hover:from-red-900 hover:to-red-950 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:from-gray-400 disabled:to-gray-500 disabled:cursor-not-allowed transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl"
|
loading={loading}
|
||||||
|
className="py-3"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{!loading && (
|
||||||
<>
|
<span className="inline-flex items-center gap-2">
|
||||||
<svg
|
<CheckCircleIcon className="h-4 w-4" />
|
||||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
<span>Complete Recovery</span>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<ArrowRightIcon className="h-4 w-4" />
|
||||||
fill="none"
|
</span>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Setting New Password...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircleIcon className="mr-2 h-4 w-4" />
|
|
||||||
Complete Recovery
|
|
||||||
<ArrowRightIcon className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform duration-200" />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
{loading && "Setting New Password..."}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
fullWidth
|
||||||
onClick={handleBackToVerify}
|
onClick={handleBackToVerify}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full flex justify-center items-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:bg-gray-50 disabled:cursor-not-allowed transition-all duration-200"
|
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
<span className="inline-flex items-center gap-2">
|
||||||
Back to Verification
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
</button>
|
<span>Back to Verification</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* What Happens Next */}
|
{/* What Happens Next */}
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-100 p-6 animate-fade-in-up-delay-2">
|
<div className={`${getThemeClasses("bg-info-light")} rounded-lg border ${getThemeClasses("border-info")} p-6 animate-fade-in-up-delay-2`}>
|
||||||
<h3 className="text-sm font-semibold text-blue-900 mb-3 flex items-center">
|
<h3 className={`text-sm font-semibold ${getThemeClasses("text-primary")} mb-3 flex items-center`}>
|
||||||
<InformationCircleIcon className="h-4 w-4 mr-2" />
|
<InformationCircleIcon className={`h-4 w-4 mr-2 ${getThemeClasses("text-info")}`} />
|
||||||
What Happens Next?
|
What Happens Next?
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="text-sm text-blue-800 space-y-2">
|
<ul className={`text-sm ${getThemeClasses("text-secondary")} space-y-2`}>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-blue-500 mr-2 mt-0.5">•</span>
|
<span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}>•</span>
|
||||||
Your master key will be decrypted using your recovery key
|
Your master key will be decrypted using your recovery key
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-blue-500 mr-2 mt-0.5">•</span>
|
<span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}>•</span>
|
||||||
New encryption keys will be generated
|
New encryption keys will be generated
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-blue-500 mr-2 mt-0.5">•</span>
|
<span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}>•</span>
|
||||||
All keys will be re-encrypted with your new password
|
All keys will be re-encrypted with your new password
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-blue-500 mr-2 mt-0.5">•</span>
|
<span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}>•</span>
|
||||||
Your recovery phrase remains the same for future use
|
Your recovery phrase remains the same for future use
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-blue-500 mr-2 mt-0.5">•</span>
|
<span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}>•</span>
|
||||||
You'll be able to log in immediately with your new password
|
You'll be able to log in immediately with your new password
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Notes */}
|
{/* Security Notes */}
|
||||||
<div className="bg-gray-50 rounded-lg border border-gray-200 p-4 animate-fade-in-up-delay-3">
|
<div className={`${getThemeClasses("bg-muted")} rounded-lg border ${getThemeClasses("border-muted")} p-4 animate-fade-in-up-delay-3`}>
|
||||||
<h4 className="text-xs font-semibold text-gray-700 mb-2 flex items-center">
|
<h4 className={`text-xs font-semibold ${getThemeClasses("text-primary")} mb-2 flex items-center`}>
|
||||||
<ShieldCheckIcon className="h-4 w-4 mr-1" />
|
<ShieldCheckIcon className="h-4 w-4 mr-1" />
|
||||||
Security Notes
|
Security Notes
|
||||||
</h4>
|
</h4>
|
||||||
<div className="text-xs text-gray-600 space-y-1">
|
<div className={`text-xs ${getThemeClasses("text-secondary")} space-y-1`}>
|
||||||
<p>• Choose a strong, unique password</p>
|
<p>• Choose a strong, unique password</p>
|
||||||
<p>• Your new password will be used to encrypt your keys</p>
|
<p>• Your new password will be used to encrypt your keys</p>
|
||||||
<p>• Keep your recovery phrase safe - it hasn't changed</p>
|
<p>• Keep your recovery phrase safe - it hasn't changed</p>
|
||||||
|
|
@ -487,26 +472,26 @@ const CompleteRecovery = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-white border-t border-gray-100 py-8">
|
<footer className={`${getThemeClasses("bg-card")} border-t ${getThemeClasses("border-muted")} py-8`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center text-sm text-gray-500">
|
<div className={`text-center text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
<p>© 2025 MapleFile Inc. All rights reserved.</p>
|
<p>© 2025 MapleFile Inc. All rights reserved.</p>
|
||||||
<div className="mt-2 space-x-4">
|
<div className="mt-2 space-x-4">
|
||||||
<Link
|
<Link
|
||||||
to="/privacy"
|
to="/privacy"
|
||||||
className="hover:text-gray-700 transition-colors duration-200"
|
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/terms"
|
to="/terms"
|
||||||
className="hover:text-gray-700 transition-colors duration-200"
|
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/support"
|
to="/support"
|
||||||
className="hover:text-gray-700 transition-colors duration-200"
|
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
Support
|
Support
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ const VerifyRecovery = () => {
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex items-center justify-center">
|
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex items-center justify-center`}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>Loading...</h2>
|
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>Loading...</h2>
|
||||||
<p className={getThemeClasses("text-secondary")}>Checking recovery session...</p>
|
<p className={getThemeClasses("text-secondary")}>Checking recovery session...</p>
|
||||||
|
|
@ -176,16 +176,16 @@ const VerifyRecovery = () => {
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex flex-col">
|
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}>
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className={`${getThemeClasses("bg-card")} backdrop-blur-sm ${getThemeClasses("border")}`}>
|
<nav className={`${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-4">
|
||||||
<Link to="/" className="flex items-center group">
|
<Link to="/" className="flex items-center group">
|
||||||
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200">
|
<div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
|
||||||
<LockClosedIcon className="h-6 w-6 text-white" />
|
<LockClosedIcon className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent">
|
<span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
||||||
MapleFile
|
MapleFile
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -205,16 +205,16 @@ const VerifyRecovery = () => {
|
||||||
<div className="flex items-center justify-center mb-8">
|
<div className="flex items-center justify-center mb-8">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("badge-success")} rounded-full text-white text-sm font-bold`}>
|
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-success")} rounded-full text-white text-sm font-bold`}>
|
||||||
<CheckIcon className="h-4 w-4" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
|
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
|
||||||
Email
|
Email
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`w-12 h-0.5 ${getThemeClasses("badge-success")}`}></div>
|
<div className={`w-12 h-0.5 ${getThemeClasses("bg-success")}`}></div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center justify-center w-8 h-8 bg-gradient-to-r from-red-800 to-red-900 rounded-full text-white text-sm font-bold">
|
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-gradient-secondary")} rounded-full text-white text-sm font-bold`}>
|
||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}>
|
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}>
|
||||||
|
|
@ -235,10 +235,10 @@ const VerifyRecovery = () => {
|
||||||
<div className="text-center animate-fade-in-up">
|
<div className="text-center animate-fade-in-up">
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-center mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-center justify-center h-16 w-16 bg-gradient-to-br from-red-800 to-red-900 rounded-2xl shadow-lg animate-pulse">
|
<div className={`flex items-center justify-center h-16 w-16 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl shadow-lg animate-pulse`}>
|
||||||
<DocumentTextIcon className="h-8 w-8 text-white" />
|
<DocumentTextIcon className="h-8 w-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -inset-1 bg-gradient-to-r from-red-800 to-red-900 rounded-2xl blur opacity-20 animate-pulse"></div>
|
<div className={`absolute -inset-1 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur opacity-20 animate-pulse`}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
|
<h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
|
|
@ -325,10 +325,10 @@ const VerifyRecovery = () => {
|
||||||
rows={4}
|
rows={4}
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={`w-full px-4 py-3 border-2 rounded-xl ${getThemeClasses("input-focus-ring")} transition-all duration-200 ${getThemeClasses("bg-disabled")} disabled:cursor-not-allowed ${getThemeClasses("text-primary")} placeholder-gray-400 font-mono text-sm leading-relaxed resize-none ${
|
className={`w-full px-4 py-3 border-2 rounded-xl focus:ring-2 ${getThemeClasses("focus:ring-accent")} ${getThemeClasses("focus:border-accent")} transition-all duration-200 ${getThemeClasses("bg-card")} disabled:opacity-50 disabled:cursor-not-allowed ${getThemeClasses("text-primary")} ${getThemeClasses("placeholder-secondary")} font-mono text-sm leading-relaxed resize-none ${
|
||||||
wordCount === 12
|
wordCount === 12
|
||||||
? "border-green-300 bg-green-50"
|
? `${getThemeClasses("border-success")} ${getThemeClasses("bg-success-light")}`
|
||||||
: getThemeClasses("input-border")
|
: getThemeClasses("border-muted")
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -177,112 +177,200 @@ const RecoveryCode = () => {
|
||||||
}, [recoveryMnemonic]);
|
}, [recoveryMnemonic]);
|
||||||
|
|
||||||
const handlePrint = useCallback(() => {
|
const handlePrint = useCallback(() => {
|
||||||
// HTML escape function to prevent XSS
|
const printWindow = window.open("", "_blank");
|
||||||
const escapeHtml = (text) => {
|
if (!printWindow) {
|
||||||
const div = document.createElement('div');
|
// Popup blocked - fall back to alert
|
||||||
div.textContent = text;
|
alert("Please allow popups to print your recovery phrase.");
|
||||||
return div.innerHTML;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = printWindow.document;
|
||||||
|
|
||||||
|
// Build document using safe DOM manipulation (no document.write)
|
||||||
|
// Create style element
|
||||||
|
const style = doc.createElement("style");
|
||||||
|
style.textContent = `
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.mnemonic {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
.word {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.privacy-notice {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.privacy-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
doc.head.appendChild(style);
|
||||||
|
|
||||||
|
// Set title safely
|
||||||
|
doc.title = "MapleFile Recovery Phrase";
|
||||||
|
|
||||||
|
// Build body content using textContent for user data (XSS-safe)
|
||||||
|
const body = doc.body;
|
||||||
|
|
||||||
|
// Header section
|
||||||
|
const header = doc.createElement("div");
|
||||||
|
header.className = "header";
|
||||||
|
|
||||||
|
const h1 = doc.createElement("h1");
|
||||||
|
h1.textContent = "MapleFile Recovery Phrase";
|
||||||
|
header.appendChild(h1);
|
||||||
|
|
||||||
|
const accountP = doc.createElement("p");
|
||||||
|
const accountStrong = doc.createElement("strong");
|
||||||
|
accountStrong.textContent = "Account: ";
|
||||||
|
accountP.appendChild(accountStrong);
|
||||||
|
accountP.appendChild(doc.createTextNode(email)); // Safe: textContent equivalent
|
||||||
|
header.appendChild(accountP);
|
||||||
|
|
||||||
|
const dateP = doc.createElement("p");
|
||||||
|
const dateStrong = doc.createElement("strong");
|
||||||
|
dateStrong.textContent = "Generated: ";
|
||||||
|
dateP.appendChild(dateStrong);
|
||||||
|
dateP.appendChild(doc.createTextNode(new Date().toLocaleString()));
|
||||||
|
header.appendChild(dateP);
|
||||||
|
|
||||||
|
body.appendChild(header);
|
||||||
|
|
||||||
|
// Warning section
|
||||||
|
const warning = doc.createElement("div");
|
||||||
|
warning.className = "warning";
|
||||||
|
const warningH3 = doc.createElement("h3");
|
||||||
|
warningH3.textContent = "⚠️ IMPORTANT SECURITY NOTICE";
|
||||||
|
warning.appendChild(warningH3);
|
||||||
|
const warningP = doc.createElement("p");
|
||||||
|
warningP.textContent = "This recovery phrase is the ONLY way to recover your account if you forget your password. Keep it safe and never share it with anyone.";
|
||||||
|
warning.appendChild(warningP);
|
||||||
|
body.appendChild(warning);
|
||||||
|
|
||||||
|
// Mnemonic words section
|
||||||
|
const mnemonic = doc.createElement("div");
|
||||||
|
mnemonic.className = "mnemonic";
|
||||||
|
recoveryMnemonic.split(" ").forEach((word, index) => {
|
||||||
|
const wordSpan = doc.createElement("span");
|
||||||
|
wordSpan.className = "word";
|
||||||
|
wordSpan.textContent = `${index + 1}. ${word}`; // Safe: textContent
|
||||||
|
mnemonic.appendChild(wordSpan);
|
||||||
|
});
|
||||||
|
body.appendChild(mnemonic);
|
||||||
|
|
||||||
|
// Footer section
|
||||||
|
const footer = doc.createElement("div");
|
||||||
|
footer.className = "footer";
|
||||||
|
|
||||||
|
const tipsP = doc.createElement("p");
|
||||||
|
const tipsStrong = doc.createElement("strong");
|
||||||
|
tipsStrong.textContent = "Security Tips:";
|
||||||
|
tipsP.appendChild(tipsStrong);
|
||||||
|
footer.appendChild(tipsP);
|
||||||
|
|
||||||
|
const tipsList = doc.createElement("ul");
|
||||||
|
const tips = [
|
||||||
|
"Store this phrase in a secure location (safe, safety deposit box)",
|
||||||
|
"Consider making multiple copies and storing them separately",
|
||||||
|
"Never store this digitally (computer files, cloud storage, photos)",
|
||||||
|
"Never share this phrase with anyone, including MapleFile support",
|
||||||
|
"Write clearly and double-check each word"
|
||||||
|
];
|
||||||
|
tips.forEach((tip) => {
|
||||||
|
const li = doc.createElement("li");
|
||||||
|
li.textContent = tip;
|
||||||
|
tipsList.appendChild(li);
|
||||||
|
});
|
||||||
|
footer.appendChild(tipsList);
|
||||||
|
|
||||||
|
// Privacy notice
|
||||||
|
const privacyDiv = doc.createElement("div");
|
||||||
|
privacyDiv.className = "privacy-notice";
|
||||||
|
|
||||||
|
const privacyTitleP = doc.createElement("p");
|
||||||
|
const privacyStrong = doc.createElement("strong");
|
||||||
|
privacyStrong.textContent = "Privacy Notice:";
|
||||||
|
privacyTitleP.appendChild(privacyStrong);
|
||||||
|
privacyDiv.appendChild(privacyTitleP);
|
||||||
|
|
||||||
|
const privacyTextP = doc.createElement("p");
|
||||||
|
privacyTextP.className = "privacy-text";
|
||||||
|
privacyTextP.textContent = "This recovery phrase is your personal cryptographic data. Data Controller: Maple Open Tech Inc. (Canada). Your GDPR rights: Access, rectification, erasure, restriction, portability, objection, and complaint to supervisory authority. Contact: privacy@mapleopentech.ca | This document was generated locally and contains no tracking.";
|
||||||
|
privacyDiv.appendChild(privacyTextP);
|
||||||
|
|
||||||
|
footer.appendChild(privacyDiv);
|
||||||
|
body.appendChild(footer);
|
||||||
|
|
||||||
|
doc.close();
|
||||||
|
|
||||||
|
// Track if print has been triggered to prevent double printing
|
||||||
|
let printTriggered = false;
|
||||||
|
|
||||||
|
const triggerPrint = () => {
|
||||||
|
if (printTriggered || printWindow.closed) return;
|
||||||
|
printTriggered = true;
|
||||||
|
|
||||||
|
printWindow.focus();
|
||||||
|
|
||||||
|
// Close window after print dialog is dismissed (print or cancel)
|
||||||
|
printWindow.onafterprint = () => {
|
||||||
|
printWindow.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
printWindow.print();
|
||||||
|
|
||||||
|
// Fallback: close after delay if onafterprint isn't supported
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!printWindow.closed) {
|
||||||
|
printWindow.close();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const printWindow = window.open("", "_blank");
|
// Trigger print once content is ready
|
||||||
|
if (printWindow.document.readyState === 'complete') {
|
||||||
// Sanitize user-controlled data to prevent XSS
|
triggerPrint();
|
||||||
const safeEmail = escapeHtml(email);
|
} else {
|
||||||
const safeDate = escapeHtml(new Date().toLocaleString());
|
printWindow.onload = triggerPrint;
|
||||||
const safeWords = recoveryMnemonic
|
// Fallback timeout in case onload doesn't fire
|
||||||
.split(" ")
|
setTimeout(triggerPrint, 250);
|
||||||
.map((word, index) =>
|
}
|
||||||
`<span class="word">${index + 1}. ${escapeHtml(word)}</span>`
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
printWindow.document.write(`
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>MapleFile Recovery Phrase</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
padding: 20px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.warning {
|
|
||||||
background: #fff3cd;
|
|
||||||
border: 1px solid #ffeaa7;
|
|
||||||
padding: 15px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.mnemonic {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 2px solid #dee2e6;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 2;
|
|
||||||
}
|
|
||||||
.word {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 5px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
margin-top: 30px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<h1>MapleFile Recovery Phrase</h1>
|
|
||||||
<p><strong>Account:</strong> ${safeEmail}</p>
|
|
||||||
<p><strong>Generated:</strong> ${safeDate}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="warning">
|
|
||||||
<h3>⚠️ IMPORTANT SECURITY NOTICE</h3>
|
|
||||||
<p>This recovery phrase is the ONLY way to recover your account if you forget your password. Keep it safe and never share it with anyone.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mnemonic">
|
|
||||||
${safeWords}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p><strong>Security Tips:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li>Store this phrase in a secure location (safe, safety deposit box)</li>
|
|
||||||
<li>Consider making multiple copies and storing them separately</li>
|
|
||||||
<li>Never store this digitally (computer files, cloud storage, photos)</li>
|
|
||||||
<li>Never share this phrase with anyone, including MapleFile support</li>
|
|
||||||
<li>Write clearly and double-check each word</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
|
|
||||||
<p><strong>Privacy Notice:</strong></p>
|
|
||||||
<p style="font-size: 10px; color: #666;">
|
|
||||||
This recovery phrase is your personal cryptographic data. Data Controller: Maple Open Tech Inc. (Canada).
|
|
||||||
Your GDPR rights: Access, rectification, erasure, restriction, portability, objection, and complaint to supervisory authority.
|
|
||||||
Contact: privacy@mapleopentech.ca | This document was generated locally and contains no tracking.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
printWindow.document.close();
|
|
||||||
printWindow.print();
|
|
||||||
}, [email, recoveryMnemonic]);
|
}, [email, recoveryMnemonic]);
|
||||||
|
|
||||||
const handleCheckboxChange = useCallback((checked) => {
|
const handleCheckboxChange = useCallback((checked) => {
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,14 @@ const VerifyEmail = () => {
|
||||||
const sanitized = value.replace(/\D/g, ""); // Only allow digits
|
const sanitized = value.replace(/\D/g, ""); // Only allow digits
|
||||||
if (sanitized.length <= 8) {
|
if (sanitized.length <= 8) {
|
||||||
setVerificationCode(sanitized);
|
setVerificationCode(sanitized);
|
||||||
// Clear errors when user types
|
// Clear errors when user types - use functional updates to avoid unnecessary re-renders
|
||||||
setGeneralError("");
|
setGeneralError((prev) => prev ? "" : prev);
|
||||||
setFieldErrors((prev) => ({ ...prev, code: "" }));
|
setFieldErrors((prev) => {
|
||||||
|
if (prev.code) {
|
||||||
|
return { ...prev, code: "" };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -341,42 +346,16 @@ const VerifyEmail = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GDPR Notice */}
|
|
||||||
<Alert type="info">
|
|
||||||
<p className="text-xs">
|
|
||||||
We process your email and verification code for account verification (legal basis: contract); email retained for account duration, codes expire after 72 hours; contact privacy@mapleopentech.ca for GDPR rights.
|
|
||||||
</p>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* Form Card */}
|
{/* Form Card */}
|
||||||
<Card className="shadow-2xl animate-fade-in-up-delay">
|
<Card className="shadow-2xl animate-fade-in-up-delay">
|
||||||
{/* Error Message Box with Field Errors */}
|
{/* Error Message Box - only show after form submission attempt */}
|
||||||
{(generalError || Object.keys(fieldErrors).length > 0) && (
|
{generalError && (
|
||||||
<Alert type="error" className="mb-6">
|
<Alert type="error" className="mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold mb-2">
|
<h3 className="text-base font-bold mb-2">
|
||||||
Verification Failed
|
Verification Failed
|
||||||
</h3>
|
</h3>
|
||||||
{generalError && (
|
<p className="text-sm">{generalError}</p>
|
||||||
<p className="text-sm mb-3">{generalError}</p>
|
|
||||||
)}
|
|
||||||
{Object.keys(fieldErrors).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold mb-2">
|
|
||||||
Please fix the following errors:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1.5 text-sm">
|
|
||||||
{Object.entries(fieldErrors).map(([field, message]) => (
|
|
||||||
<li key={field} className="ml-1">
|
|
||||||
<span className="font-medium">
|
|
||||||
{field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}:
|
|
||||||
</span>{' '}
|
|
||||||
{message}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -436,8 +415,7 @@ const VerifyEmail = () => {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="mr-2 h-4 w-4 inline-block" />
|
<CheckIcon className="mr-2 h-4 w-4 inline-block" />
|
||||||
<span className="inline-block">Verify Email & Complete</span>
|
<span className="inline-block">Submit</span>
|
||||||
<ArrowRightIcon className="ml-2 h-4 w-4 inline-block" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -488,8 +466,7 @@ const VerifyEmail = () => {
|
||||||
{/* Help Section */}
|
{/* Help Section */}
|
||||||
<Alert type="info" className="animate-fade-in-up-delay-2">
|
<Alert type="info" className="animate-fade-in-up-delay-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-3 flex items-center">
|
<h3 className="text-sm font-semibold mb-3">
|
||||||
<InformationCircleIcon className="h-4 w-4 mr-2" />
|
|
||||||
Having trouble?
|
Having trouble?
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="text-sm space-y-2">
|
<ul className="text-sm space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ const VerifySuccess = () => {
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
setCountdown((prev) => {
|
setCountdown((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
navigate("/login");
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return prev - 1;
|
return prev - 1;
|
||||||
|
|
@ -106,8 +105,18 @@ const VerifySuccess = () => {
|
||||||
clearInterval(timerRef.current);
|
clearInterval(timerRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []);
|
||||||
// navigate is stable but we only want this to run once on mount
|
|
||||||
|
// Separate effect to handle navigation when countdown reaches 0
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown === 0) {
|
||||||
|
// Clear session storage before redirecting
|
||||||
|
sessionStorage.removeItem("registrationResult");
|
||||||
|
sessionStorage.removeItem("registeredEmail");
|
||||||
|
sessionStorage.removeItem("userRole");
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
}, [countdown, navigate]);
|
||||||
|
|
||||||
const getUserRoleText = useCallback((role) => {
|
const getUserRoleText = useCallback((role) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
|
|
@ -362,10 +371,11 @@ const VerifySuccess = () => {
|
||||||
onClick={handleGoToLogin}
|
onClick={handleGoToLogin}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<LockClosedIcon className="mr-2 h-5 w-5" />
|
<LockClosedIcon className="mr-2 h-5 w-5 inline-block" />
|
||||||
Sign In Now
|
<span className="inline-block">Sign In Now</span>
|
||||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
<ArrowRightIcon className="ml-2 h-4 w-4 inline-block" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,7 @@ import {
|
||||||
} from "../../../services/Services";
|
} from "../../../services/Services";
|
||||||
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Layout from "../../../components/Layout/Layout";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
import { Card, Button, Alert, useUIXTheme } from "../../../components/UIX";
|
import { Card, Button, Alert, Spinner, useUIXTheme } from "../../../components/UIX";
|
||||||
import {
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
|
||||||
import {
|
import {
|
||||||
CloudArrowUpIcon,
|
CloudArrowUpIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
|
|
@ -29,10 +20,19 @@ import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
ArrowTrendingUpIcon,
|
ArrowTrendingUpIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
ChartBarIcon,
|
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// Cache the dynamic import to avoid repeated imports
|
||||||
|
let FileCryptoServiceCache = null;
|
||||||
|
const getFileCryptoService = async () => {
|
||||||
|
if (!FileCryptoServiceCache) {
|
||||||
|
const module = await import("../../../services/Crypto/FileCryptoService.js");
|
||||||
|
FileCryptoServiceCache = module.default;
|
||||||
|
}
|
||||||
|
return FileCryptoServiceCache;
|
||||||
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -47,6 +47,7 @@ const Dashboard = () => {
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [generalError, setGeneralError] = useState("");
|
const [generalError, setGeneralError] = useState("");
|
||||||
const [fieldErrors, setFieldErrors] = useState({});
|
const [fieldErrors, setFieldErrors] = useState({});
|
||||||
const [dashboardData, setDashboardData] = useState(null);
|
const [dashboardData, setDashboardData] = useState(null);
|
||||||
|
|
@ -77,38 +78,38 @@ const Dashboard = () => {
|
||||||
async (files) => {
|
async (files) => {
|
||||||
if (!files || files.length === 0) return [];
|
if (!files || files.length === 0) return [];
|
||||||
|
|
||||||
const { default: FileCryptoService } = await import(
|
// Use cached import for better performance
|
||||||
"../../../services/Crypto/FileCryptoService.js"
|
const FileCryptoService = await getFileCryptoService();
|
||||||
);
|
|
||||||
const decryptedFiles = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
// Process files in parallel for better performance
|
||||||
try {
|
const decryptedFiles = await Promise.all(
|
||||||
const collectionKey = CollectionCryptoService.getCachedCollectionKey(
|
files.map(async (file) => {
|
||||||
file.collection_id,
|
try {
|
||||||
);
|
const collectionKey = CollectionCryptoService.getCachedCollectionKey(
|
||||||
if (!collectionKey) {
|
file.collection_id,
|
||||||
decryptedFiles.push({
|
);
|
||||||
|
if (!collectionKey) {
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
name: "Locked File",
|
||||||
|
_isDecrypted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedFile = await FileCryptoService.decryptFileFromAPI(
|
||||||
|
file,
|
||||||
|
collectionKey,
|
||||||
|
);
|
||||||
|
return decryptedFile;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
...file,
|
...file,
|
||||||
name: "Locked File",
|
name: "Locked File",
|
||||||
_isDecrypted: false,
|
_isDecrypted: false,
|
||||||
});
|
};
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
const decryptedFile = await FileCryptoService.decryptFileFromAPI(
|
);
|
||||||
file,
|
|
||||||
collectionKey,
|
|
||||||
);
|
|
||||||
decryptedFiles.push(decryptedFile);
|
|
||||||
} catch {
|
|
||||||
decryptedFiles.push({
|
|
||||||
...file,
|
|
||||||
name: "Locked File",
|
|
||||||
_isDecrypted: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return decryptedFiles;
|
return decryptedFiles;
|
||||||
},
|
},
|
||||||
|
|
@ -149,6 +150,7 @@ const Dashboard = () => {
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setDashboardData(data);
|
setDashboardData(data);
|
||||||
|
setIsInitialized(true);
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("[Dashboard] Dashboard loaded successfully");
|
console.log("[Dashboard] Dashboard loaded successfully");
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +172,7 @@ const Dashboard = () => {
|
||||||
} else {
|
} else {
|
||||||
setGeneralError("Could not load your dashboard. Please try again.");
|
setGeneralError("Could not load your dashboard. Please try again.");
|
||||||
}
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -212,19 +215,21 @@ const Dashboard = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (dashboardManager && authManager?.isAuthenticated()) {
|
// Initial load when managers become available
|
||||||
|
useEffect(() => {
|
||||||
|
if (dashboardManager && authManager?.isAuthenticated() && !isInitialized) {
|
||||||
// Always force refresh on page load to ensure fresh data
|
// Always force refresh on page load to ensure fresh data
|
||||||
// This handles cases where sharing/unsharing happened in other tabs
|
// This handles cases where sharing/unsharing happened in other tabs
|
||||||
// or when the user was removed from a shared collection
|
// or when the user was removed from a shared collection
|
||||||
clearDashboardCache();
|
clearDashboardCache();
|
||||||
loadDashboardData(true);
|
loadDashboardData(true);
|
||||||
}
|
}
|
||||||
|
}, [dashboardManager, authManager, isInitialized, loadDashboardData, clearDashboardCache]);
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, [dashboardManager, authManager, loadDashboardData, clearDashboardCache]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (createFileManager) {
|
if (createFileManager) {
|
||||||
|
|
@ -379,18 +384,6 @@ const Dashboard = () => {
|
||||||
bgColor: getThemeClasses("stat-folders-bg"),
|
bgColor: getThemeClasses("stat-folders-bg"),
|
||||||
textColor: getThemeClasses("stat-folders-text"),
|
textColor: getThemeClasses("stat-folders-text"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Current Storage",
|
|
||||||
value:
|
|
||||||
dashboardManager?.formatStorageValue(
|
|
||||||
dashboardData.summary?.storage_used,
|
|
||||||
) || "0 B",
|
|
||||||
subtitle: "in use now",
|
|
||||||
icon: ChartBarIcon,
|
|
||||||
gradient: getThemeClasses("stat-storage-gradient"),
|
|
||||||
bgColor: getThemeClasses("stat-storage-bg"),
|
|
||||||
textColor: getThemeClasses("stat-storage-text"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Storage Limit",
|
label: "Storage Limit",
|
||||||
value:
|
value:
|
||||||
|
|
@ -408,23 +401,6 @@ const Dashboard = () => {
|
||||||
[dashboardData, dashboardManager, getThemeClasses]
|
[dashboardData, dashboardManager, getThemeClasses]
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() =>
|
|
||||||
dashboardData?.storage_usage_trend?.data_points?.map((point) => ({
|
|
||||||
name: new Date(point.date).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}),
|
|
||||||
usage: Math.round(point.usage?.value || 0),
|
|
||||||
unit: point.usage?.unit || "GB",
|
|
||||||
})) || [],
|
|
||||||
[dashboardData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const chartColors = useMemo(() => ({
|
|
||||||
primary: getThemeClasses("chart-primary"),
|
|
||||||
primaryLight: getThemeClasses("chart-primary-light"),
|
|
||||||
}), [getThemeClasses]);
|
|
||||||
|
|
||||||
const recentFilesDisplay = useMemo(() =>
|
const recentFilesDisplay = useMemo(() =>
|
||||||
dashboardData?.recent_files?.slice(0, 5) || [],
|
dashboardData?.recent_files?.slice(0, 5) || [],
|
||||||
[dashboardData]
|
[dashboardData]
|
||||||
|
|
@ -475,7 +451,7 @@ const Dashboard = () => {
|
||||||
{isLoading && !dashboardData && (
|
{isLoading && !dashboardData && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${getThemeClasses("border-primary")} mx-auto mb-4`}></div>
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
<p className={getThemeClasses("text-secondary")}>Loading your dashboard...</p>
|
<p className={getThemeClasses("text-secondary")}>Loading your dashboard...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -513,10 +489,12 @@ const Dashboard = () => {
|
||||||
{dashboardData && (
|
{dashboardData && (
|
||||||
<>
|
<>
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8" role="list" aria-label="Dashboard statistics">
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat, index) => (
|
||||||
<Card
|
<Card
|
||||||
key={stat.label}
|
key={stat.label}
|
||||||
|
role="listitem"
|
||||||
|
aria-label={`${stat.label}: ${stat.value}${stat.subtitle ? `, ${stat.subtitle}` : ""}`}
|
||||||
className="hover:shadow-lg transform hover:-translate-y-1 transition-all duration-300 animate-fade-in-up overflow-hidden"
|
className="hover:shadow-lg transform hover:-translate-y-1 transition-all duration-300 animate-fade-in-up overflow-hidden"
|
||||||
style={{ animationDelay: `${index * 100}ms` }}
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
>
|
>
|
||||||
|
|
@ -544,140 +522,6 @@ const Dashboard = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Section */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
|
||||||
{/* Storage Usage Chart */}
|
|
||||||
<Card className="lg:col-span-2 animate-fade-in-up">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}>
|
|
||||||
Storage Usage Trend
|
|
||||||
</h2>
|
|
||||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Last 7 days</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className={`h-3 w-3 ${getThemeClasses("bg-gradient-secondary")} rounded-full`}></div>
|
|
||||||
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
|
||||||
Storage (GB)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-64">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart
|
|
||||||
data={chartData}
|
|
||||||
margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id="colorUsage"
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="5%"
|
|
||||||
stopColor={chartColors.primary}
|
|
||||||
stopOpacity={0.3}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="95%"
|
|
||||||
stopColor={chartColors.primary}
|
|
||||||
stopOpacity={0}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
vertical={false}
|
|
||||||
stroke="#f3f4f6"
|
|
||||||
/>
|
|
||||||
<XAxis dataKey="name" stroke="#9ca3af" fontSize={12} />
|
|
||||||
<YAxis
|
|
||||||
unit={` ${dashboardData.storage_usage_trend.data_points[0]?.usage?.unit || "GB"}`}
|
|
||||||
stroke="#9ca3af"
|
|
||||||
fontSize={12}
|
|
||||||
tickFormatter={(value) => Math.round(value)}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: "0.5rem",
|
|
||||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
|
||||||
}}
|
|
||||||
formatter={(value, name, props) => [
|
|
||||||
`${Math.round(value)} ${props.payload?.unit || "GB"}`,
|
|
||||||
"Storage Used",
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="usage"
|
|
||||||
stroke={chartColors.primary}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="url(#colorUsage)"
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Storage Summary */}
|
|
||||||
<Card
|
|
||||||
className="animate-fade-in-up"
|
|
||||||
style={{ animationDelay: "200ms" }}
|
|
||||||
>
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-6`}>
|
|
||||||
Storage Overview
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
|
|
||||||
Used Storage
|
|
||||||
</span>
|
|
||||||
<span className={`text-sm font-semibold ${getThemeClasses("text-primary")}`}>
|
|
||||||
{dashboardData.summary?.storage_usage_percentage || 0}
|
|
||||||
%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`${getThemeClasses("bg-gradient-secondary")} h-3 rounded-full transition-all duration-1000 ease-out`}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(dashboardData.summary?.storage_usage_percentage || 0, 100)}%`,
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between mt-2">
|
|
||||||
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
|
|
||||||
{dashboardManager?.formatStorageValue(
|
|
||||||
dashboardData.summary?.storage_used,
|
|
||||||
) || "0 GB"}{" "}
|
|
||||||
used
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
|
|
||||||
{dashboardManager?.formatStorageValue(
|
|
||||||
dashboardData.summary?.storage_limit,
|
|
||||||
) || "0 GB"}{" "}
|
|
||||||
total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Files */}
|
{/* Recent Files */}
|
||||||
<Card
|
<Card
|
||||||
className="animate-fade-in-up"
|
className="animate-fade-in-up"
|
||||||
|
|
@ -698,15 +542,24 @@ const Dashboard = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentFilesDisplay.length > 0 ? (
|
{recentFilesDisplay.length > 0 ? (
|
||||||
<div className="divide-y divide-gray-100">
|
<div role="list" aria-label="Recent files" className="divide-y divide-gray-100">
|
||||||
{recentFilesDisplay.map((file) => (
|
{recentFilesDisplay.map((file) => (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
className="p-4 hover:bg-gray-50 transition-colors duration-200 group"
|
role="listitem"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${file.name || "Locked File"}, ${formatFileSize(file.size)}, ${getTimeAgo(file.created_at)}`}
|
||||||
|
className="p-4 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 transition-colors duration-200 group"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.key === "Enter" || e.key === " ") && file._isDecrypted && !downloadingFiles.has(file.id)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDownloadFile(file.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="h-10 w-10 bg-gray-50 rounded-lg flex items-center justify-center group-hover:bg-gray-100 transition-colors duration-200">
|
<div className="h-10 w-10 bg-gray-50 rounded-lg flex items-center justify-center group-hover:bg-gray-100 transition-colors duration-200" aria-hidden="true">
|
||||||
{getFileIcon(file.name)}
|
{getFileIcon(file.name)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -717,27 +570,30 @@ const Dashboard = () => {
|
||||||
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
|
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
|
||||||
{formatFileSize(file.size)}
|
{formatFileSize(file.size)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400">•</span>
|
<span className="text-xs text-gray-400" aria-hidden="true">•</span>
|
||||||
<span className={`text-xs ${getThemeClasses("text-secondary")} flex items-center`}>
|
<span className={`text-xs ${getThemeClasses("text-secondary")} flex items-center`}>
|
||||||
<ClockIcon className="h-3 w-3 mr-1" />
|
<ClockIcon className="h-3 w-3 mr-1" aria-hidden="true" />
|
||||||
{getTimeAgo(file.created_at)}
|
{getTimeAgo(file.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleDownloadFile(file.id)}
|
onClick={() => handleDownloadFile(file.id)}
|
||||||
disabled={
|
disabled={
|
||||||
!file._isDecrypted || downloadingFiles.has(file.id)
|
!file._isDecrypted || downloadingFiles.has(file.id)
|
||||||
}
|
}
|
||||||
className="opacity-0 group-hover:opacity-100 p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-all duration-200 disabled:opacity-50"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-200"
|
||||||
|
aria-label={`Download ${file.name || 'file'}`}
|
||||||
>
|
>
|
||||||
{downloadingFiles.has(file.id) ? (
|
{downloadingFiles.has(file.id) ? (
|
||||||
<ArrowPathIcon className="h-4 w-4 animate-spin" />
|
<ArrowPathIcon className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowDownTrayIcon className="h-4 w-4" />
|
<ArrowDownTrayIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -758,40 +614,8 @@ const Dashboard = () => {
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CSS Animations */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes fade-in-down {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-down {
|
|
||||||
animation: fade-in-down 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fade-in-up 0.6s ease-out;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -420,7 +420,7 @@ const CollectionCreate = () => {
|
||||||
className={`relative flex cursor-pointer rounded-lg border p-4 transition-all ${
|
className={`relative flex cursor-pointer rounded-lg border p-4 transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? `${getThemeClasses("input-border")} ${getThemeClasses("alert-info-bg")}`
|
? `${getThemeClasses("input-border")} ${getThemeClasses("alert-info-bg")}`
|
||||||
: `${getThemeClasses("border-secondary")} hover:bg-gray-50`
|
: `${getThemeClasses("border-secondary")} ${getThemeClasses("hover:bg-muted")}`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|
@ -474,7 +474,7 @@ const CollectionCreate = () => {
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} bg-gray-50`}>
|
<div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
|
||||||
<CollectionIconPreview
|
<CollectionIconPreview
|
||||||
customIcon={customIcon}
|
customIcon={customIcon}
|
||||||
collectionType={collectionType}
|
collectionType={collectionType}
|
||||||
|
|
@ -495,7 +495,7 @@ const CollectionCreate = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenIconPicker}
|
onClick={handleOpenIconPicker}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("text-primary")} hover:bg-gray-100 transition-colors disabled:opacity-50`}
|
className={`px-3 py-1.5 text-sm font-medium rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("text-primary")} ${getThemeClasses("hover:bg-muted")} transition-colors disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
{customIcon ? "Change Icon" : "Choose Icon"}
|
{customIcon ? "Change Icon" : "Choose Icon"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -504,7 +504,7 @@ const CollectionCreate = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCustomIcon("")}
|
onClick={() => setCustomIcon("")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg ${getThemeClasses("text-secondary")} hover:text-gray-700 transition-colors disabled:opacity-50`}
|
className={`px-3 py-1.5 text-sm font-medium rounded-lg ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} transition-colors disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
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)
|
// Export members list (GDPR Article 20 - Data Portability)
|
||||||
const handleExportMembers = useCallback(() => {
|
const handleExportMembers = useCallback(() => {
|
||||||
const exportData = {
|
const exportData = {
|
||||||
|
|
@ -124,12 +132,14 @@ const CollectionShare = () => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${collection?.name || 'folder'}_sharing_${Date.now()}.json`;
|
// Sanitize collection name for safe filename
|
||||||
|
const safeName = sanitizeFilename(collection?.name || 'folder');
|
||||||
|
a.download = `${safeName}_sharing_${Date.now()}.json`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [collection, ownerEmail, collectionMembers]);
|
}, [collection, ownerEmail, collectionMembers, sanitizeFilename]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (collectionId && getCollectionManager && shareCollectionManager) {
|
if (collectionId && getCollectionManager && shareCollectionManager) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// File: monorepo/web/maplefile-frontend/src/pages/User/FileManager/FileManagerIndex.jsx
|
// File: monorepo/web/maplefile-frontend/src/pages/User/FileManager/FileManagerIndex.jsx
|
||||||
// UIX version - Theme-aware File Manager with Layout
|
// UIX version - Theme-aware File Manager with Layout
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router";
|
import { useNavigate, useLocation } from "react-router";
|
||||||
import { useFiles, useCrypto, useAuth, useTags } from "../../../services/Services";
|
import { useFiles, useCrypto, useAuth, useTags } from "../../../services/Services";
|
||||||
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
|
|
@ -10,6 +10,10 @@ import {
|
||||||
Input,
|
Input,
|
||||||
Alert,
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
|
Checkbox,
|
||||||
|
Spinner,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
useUIXTheme,
|
useUIXTheme,
|
||||||
CollectionIcon,
|
CollectionIcon,
|
||||||
} from "../../../components/UIX";
|
} from "../../../components/UIX";
|
||||||
|
|
@ -31,8 +35,19 @@ import {
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
|
HomeIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// Cache the dynamic import to avoid repeated imports in loops
|
||||||
|
let CollectionCryptoServiceClassCache = null;
|
||||||
|
const getCollectionCryptoServiceClass = async () => {
|
||||||
|
if (!CollectionCryptoServiceClassCache) {
|
||||||
|
const module = await import("../../../services/Crypto/CollectionCryptoService.js");
|
||||||
|
CollectionCryptoServiceClassCache = module.default;
|
||||||
|
}
|
||||||
|
return CollectionCryptoServiceClassCache;
|
||||||
|
};
|
||||||
|
|
||||||
const FileManagerIndex = () => {
|
const FileManagerIndex = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -50,6 +65,7 @@ const FileManagerIndex = () => {
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [generalError, setGeneralError] = useState("");
|
const [generalError, setGeneralError] = useState("");
|
||||||
const [collectionTagsMap, setCollectionTagsMap] = useState({});
|
const [collectionTagsMap, setCollectionTagsMap] = useState({});
|
||||||
const [fieldErrors, setFieldErrors] = useState({});
|
const [fieldErrors, setFieldErrors] = useState({});
|
||||||
|
|
@ -105,9 +121,8 @@ const FileManagerIndex = () => {
|
||||||
if (!Array.isArray(rawCollections) || rawCollections.length === 0)
|
if (!Array.isArray(rawCollections) || rawCollections.length === 0)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
const processedCollections = [];
|
// Process single collection - extracted for parallel execution
|
||||||
|
const processSingleCollection = async (collection) => {
|
||||||
for (const collection of rawCollections) {
|
|
||||||
try {
|
try {
|
||||||
let processedCollection = { ...collection };
|
let processedCollection = { ...collection };
|
||||||
|
|
||||||
|
|
@ -140,9 +155,7 @@ const FileManagerIndex = () => {
|
||||||
|
|
||||||
if (collectionKey) {
|
if (collectionKey) {
|
||||||
try {
|
try {
|
||||||
const { default: CollectionCryptoServiceClass } = await import(
|
const CollectionCryptoServiceClass = await getCollectionCryptoServiceClass();
|
||||||
"../../../services/Crypto/CollectionCryptoService.js"
|
|
||||||
);
|
|
||||||
|
|
||||||
const decryptedCollection =
|
const decryptedCollection =
|
||||||
await CollectionCryptoServiceClass.decryptCollectionFromAPI(
|
await CollectionCryptoServiceClass.decryptCollectionFromAPI(
|
||||||
|
|
@ -180,9 +193,9 @@ const FileManagerIndex = () => {
|
||||||
processedCollection.isOwned =
|
processedCollection.isOwned =
|
||||||
collection._isOwned !== undefined ? collection._isOwned : true;
|
collection._isOwned !== undefined ? collection._isOwned : true;
|
||||||
|
|
||||||
processedCollections.push(processedCollection);
|
return processedCollection;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
processedCollections.push({
|
return {
|
||||||
...collection,
|
...collection,
|
||||||
name: "Locked Folder",
|
name: "Locked Folder",
|
||||||
type: "folder",
|
type: "folder",
|
||||||
|
|
@ -190,9 +203,14 @@ const FileManagerIndex = () => {
|
||||||
_isDecrypted: false,
|
_isDecrypted: false,
|
||||||
isShared: false,
|
isShared: false,
|
||||||
isOwned: true,
|
isOwned: true,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Process all collections in parallel for better performance
|
||||||
|
const processedCollections = await Promise.all(
|
||||||
|
rawCollections.map(processSingleCollection)
|
||||||
|
);
|
||||||
|
|
||||||
return processedCollections;
|
return processedCollections;
|
||||||
},
|
},
|
||||||
|
|
@ -283,6 +301,7 @@ const FileManagerIndex = () => {
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setCollections(processedCollections);
|
setCollections(processedCollections);
|
||||||
|
setIsInitialized(true);
|
||||||
// Load tags for the collections
|
// Load tags for the collections
|
||||||
loadCollectionTags(processedCollections);
|
loadCollectionTags(processedCollections);
|
||||||
}
|
}
|
||||||
|
|
@ -303,6 +322,7 @@ const FileManagerIndex = () => {
|
||||||
} else {
|
} else {
|
||||||
setGeneralError("Could not load your folders. Please try again.");
|
setGeneralError("Could not load your folders. Please try again.");
|
||||||
}
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
|
|
@ -444,6 +464,28 @@ const FileManagerIndex = () => {
|
||||||
setShowDeleteConfirm(true);
|
setShowDeleteConfirm(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Memoized handler for collection card navigation
|
||||||
|
const handleCollectionClick = useCallback((collectionId) => {
|
||||||
|
navigate(`/file-manager/collections/${collectionId}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Memoized handler for collection card keyboard navigation
|
||||||
|
const handleCollectionKeyDown = useCallback((e, collectionId) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/file-manager/collections/${collectionId}`);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Memoized handlers for hover state
|
||||||
|
const handleCollectionMouseEnter = useCallback((collectionId) => {
|
||||||
|
setHoveredCollection(collectionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCollectionMouseLeave = useCallback(() => {
|
||||||
|
setHoveredCollection(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCancelDelete = useCallback(() => {
|
const handleCancelDelete = useCallback(() => {
|
||||||
setCollectionToDelete(null);
|
setCollectionToDelete(null);
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
|
|
@ -536,11 +578,12 @@ const FileManagerIndex = () => {
|
||||||
loadCollections,
|
loadCollections,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Initial load when managers become available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listCollectionManager && authManager?.isAuthenticated()) {
|
if (listCollectionManager && authManager?.isAuthenticated() && !isInitialized) {
|
||||||
loadCollections();
|
loadCollections();
|
||||||
}
|
}
|
||||||
}, [listCollectionManager, authManager, loadCollections]);
|
}, [listCollectionManager, authManager, isInitialized, loadCollections]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCollectionEvent = () => {
|
const handleCollectionEvent = () => {
|
||||||
|
|
@ -617,29 +660,33 @@ const FileManagerIndex = () => {
|
||||||
location.pathname,
|
location.pathname,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filteredCollections = collections.filter((collection) => {
|
// Memoize filtered collections to prevent recalculation on every render
|
||||||
// Filter by search query
|
const filteredCollections = useMemo(() => {
|
||||||
if (searchQuery) {
|
return collections.filter((collection) => {
|
||||||
const query = searchQuery.toLowerCase();
|
// Filter by search query
|
||||||
if (!(collection.name || "Locked Folder").toLowerCase().includes(query)) {
|
if (searchQuery) {
|
||||||
return false;
|
const query = searchQuery.toLowerCase();
|
||||||
|
if (!(collection.name || "Locked Folder").toLowerCase().includes(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by selected tags (collection must have ALL selected tags)
|
// Filter by selected tags (collection must have ALL selected tags)
|
||||||
if (selectedTagIds.length > 0) {
|
if (selectedTagIds.length > 0) {
|
||||||
const collectionTags = collectionTagsMap[collection.id] || [];
|
const collectionTags = collectionTagsMap[collection.id] || [];
|
||||||
const collectionTagIdSet = new Set(collectionTags.map(tag => tag.id));
|
const collectionTagIdSet = new Set(collectionTags.map(tag => tag.id));
|
||||||
const hasAllSelectedTags = selectedTagIds.every(tagId => collectionTagIdSet.has(tagId));
|
const hasAllSelectedTags = selectedTagIds.every(tagId => collectionTagIdSet.has(tagId));
|
||||||
if (!hasAllSelectedTags) {
|
if (!hasAllSelectedTags) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
}, [collections, searchQuery, selectedTagIds, collectionTagsMap]);
|
||||||
|
|
||||||
const filterTypes = [
|
// Memoize filter types to prevent recreation on every render
|
||||||
|
const filterTypes = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: "owned",
|
key: "owned",
|
||||||
label: "My Folders",
|
label: "My Folders",
|
||||||
|
|
@ -661,77 +708,102 @@ const FileManagerIndex = () => {
|
||||||
description: "All folders you can access",
|
description: "All folders you can access",
|
||||||
count: collectionCounts.all,
|
count: collectionCounts.all,
|
||||||
},
|
},
|
||||||
];
|
], [collectionCounts]);
|
||||||
|
|
||||||
const currentFilter = filterTypes.find((f) => f.key === filterType);
|
const currentFilter = useMemo(
|
||||||
|
() => filterTypes.find((f) => f.key === filterType),
|
||||||
|
[filterTypes, filterType]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize breadcrumb items (static)
|
||||||
|
const breadcrumbItems = useMemo(() => [
|
||||||
|
{
|
||||||
|
label: "My Files",
|
||||||
|
icon: HomeIcon,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Header */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="mb-8">
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
className={`text-3xl font-bold flex items-center ${getThemeClasses("text-primary")}`}
|
|
||||||
>
|
|
||||||
My Files
|
|
||||||
<SparklesIcon
|
|
||||||
className={`h-8 w-8 ml-2 ${getThemeClasses("text-accent")}`}
|
|
||||||
/>
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Organize and manage your encrypted files
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
{/* Main Card */}
|
||||||
|
<Card>
|
||||||
|
{/* Header with icon, title, and action buttons */}
|
||||||
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||||
|
<FolderIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
|
</div>
|
||||||
|
{/* Title and subtitle */}
|
||||||
|
<div>
|
||||||
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
|
My Files
|
||||||
|
</h1>
|
||||||
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Organize and manage your encrypted files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||||
{/* Tag Filter Menu */}
|
{/* Tag Filter Menu */}
|
||||||
{availableTags.length > 0 && (
|
{availableTags.length > 0 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowTagFilterMenu(!showTagFilterMenu)}
|
onClick={() => setShowTagFilterMenu(!showTagFilterMenu)}
|
||||||
variant={selectedTagIds.length > 0 ? "primary" : "secondary"}
|
variant={selectedTagIds.length > 0 ? "primary" : "secondary"}
|
||||||
className="flex items-center"
|
|
||||||
>
|
>
|
||||||
{selectedTagIds.length > 0 ? (
|
<span className="inline-flex items-center whitespace-nowrap">
|
||||||
<>
|
{selectedTagIds.length > 0 ? (
|
||||||
<div className="flex items-center -space-x-1 mr-2">
|
<>
|
||||||
{selectedTagIds.slice(0, 3).map(tagId => {
|
<span className="flex items-center -space-x-1 mr-2 flex-shrink-0">
|
||||||
const tag = availableTags.find(t => t.id === tagId);
|
{selectedTagIds.slice(0, 3).map(tagId => {
|
||||||
return (
|
const tag = availableTags.find(t => t.id === tagId);
|
||||||
<span
|
return (
|
||||||
key={tagId}
|
<span
|
||||||
className="h-3 w-3 rounded-full border border-white"
|
key={tagId}
|
||||||
style={{ backgroundColor: tag?.color || '#6b7280' }}
|
className="h-3 w-3 rounded-full border border-white"
|
||||||
></span>
|
style={{ backgroundColor: tag?.color || '#6b7280' }}
|
||||||
);
|
></span>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
{selectedTagIds.length === 1
|
</span>
|
||||||
? availableTags.find(t => t.id === selectedTagIds[0])?.name || 'Tag'
|
{selectedTagIds.length === 1
|
||||||
: `${selectedTagIds.length} tags`}
|
? availableTags.find(t => t.id === selectedTagIds[0])?.name || 'Tag'
|
||||||
<button
|
: `${selectedTagIds.length} tags`}
|
||||||
onClick={(e) => {
|
<Button
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setSelectedTagIds([]);
|
e.stopPropagation();
|
||||||
}}
|
setSelectedTagIds([]);
|
||||||
className="ml-2 hover:opacity-70"
|
}}
|
||||||
>
|
variant="ghost"
|
||||||
<span className="text-xs">✕</span>
|
size="sm"
|
||||||
</button>
|
className="ml-1 p-0 min-w-0 h-auto"
|
||||||
</>
|
aria-label="Clear tag filter"
|
||||||
) : (
|
>
|
||||||
<>
|
<span className="text-xs">✕</span>
|
||||||
<TagIcon className="h-4 w-4 mr-2" />
|
</Button>
|
||||||
All Tags
|
</>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
<ChevronRightIcon
|
<TagIcon className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||||
className={`h-4 w-4 ml-2 transition-transform duration-200 ${
|
All Tags
|
||||||
showTagFilterMenu ? "rotate-90" : ""
|
</>
|
||||||
}`}
|
)}
|
||||||
/>
|
<ChevronRightIcon
|
||||||
|
className={`h-4 w-4 ml-2 flex-shrink-0 transition-transform duration-200 ${
|
||||||
|
showTagFilterMenu ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showTagFilterMenu && (
|
{showTagFilterMenu && (
|
||||||
|
|
@ -749,12 +821,14 @@ const FileManagerIndex = () => {
|
||||||
Filter by Tags
|
Filter by Tags
|
||||||
</span>
|
</span>
|
||||||
{selectedTagIds.length > 0 && (
|
{selectedTagIds.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => setSelectedTagIds([])}
|
onClick={() => setSelectedTagIds([])}
|
||||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -762,8 +836,17 @@ const FileManagerIndex = () => {
|
||||||
{availableTags.map((tag) => {
|
{availableTags.map((tag) => {
|
||||||
const isSelected = selectedTagIds.includes(tag.id);
|
const isSelected = selectedTagIds.includes(tag.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
|
role="checkbox"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-checked={isSelected}
|
||||||
|
aria-label={`Filter by tag: ${tag.name}`}
|
||||||
|
className={`w-full px-4 py-2.5 transition-colors duration-200 cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? getThemeClasses("bg-muted")
|
||||||
|
: `hover:${getThemeClasses("bg-hover")}`
|
||||||
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
|
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
|
||||||
|
|
@ -771,18 +854,27 @@ const FileManagerIndex = () => {
|
||||||
setSelectedTagIds([...selectedTagIds, tag.id]);
|
setSelectedTagIds([...selectedTagIds, tag.id]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-4 py-2.5 transition-colors duration-200 ${
|
onKeyDown={(e) => {
|
||||||
isSelected
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
? getThemeClasses("bg-muted")
|
e.preventDefault();
|
||||||
: `hover:${getThemeClasses("bg-hover")}`
|
if (isSelected) {
|
||||||
}`}
|
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
|
||||||
|
} else {
|
||||||
|
setSelectedTagIds([...selectedTagIds, tag.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => {}}
|
onChange={(checked) => {
|
||||||
className="h-4 w-4 rounded text-blue-600 focus:ring-blue-500"
|
if (checked) {
|
||||||
|
setSelectedTagIds([...selectedTagIds, tag.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="h-4 w-4 rounded-full flex-shrink-0"
|
className="h-4 w-4 rounded-full flex-shrink-0"
|
||||||
|
|
@ -798,7 +890,7 @@ const FileManagerIndex = () => {
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -821,15 +913,16 @@ const FileManagerIndex = () => {
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex items-center"
|
|
||||||
>
|
>
|
||||||
<FunnelIcon className="h-4 w-4 mr-2" />
|
<span className="inline-flex items-center whitespace-nowrap">
|
||||||
{currentFilter.label}
|
<FunnelIcon className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||||
<ChevronRightIcon
|
{currentFilter.label}
|
||||||
className={`h-4 w-4 ml-2 transition-transform duration-200 ${
|
<ChevronRightIcon
|
||||||
showFilterMenu ? "rotate-90" : ""
|
className={`h-4 w-4 ml-2 flex-shrink-0 transition-transform duration-200 ${
|
||||||
}`}
|
showFilterMenu ? "rotate-90" : ""
|
||||||
/>
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showFilterMenu && (
|
{showFilterMenu && (
|
||||||
|
|
@ -842,10 +935,13 @@ const FileManagerIndex = () => {
|
||||||
className={`absolute right-0 mt-2 w-64 ${getThemeClasses("bg-card")} rounded-xl shadow-xl border ${getThemeClasses("border")} py-2 z-20`}
|
className={`absolute right-0 mt-2 w-64 ${getThemeClasses("bg-card")} rounded-xl shadow-xl border ${getThemeClasses("border")} py-2 z-20`}
|
||||||
>
|
>
|
||||||
{filterTypes.map((filter) => (
|
{filterTypes.map((filter) => (
|
||||||
<button
|
<div
|
||||||
key={filter.key}
|
key={filter.key}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => handleFilterChange(filter.key)}
|
onClick={() => handleFilterChange(filter.key)}
|
||||||
className={`w-full text-left px-4 py-3 transition-colors duration-200 ${
|
onKeyDown={(e) => e.key === 'Enter' && handleFilterChange(filter.key)}
|
||||||
|
className={`w-full text-left px-4 py-3 transition-colors duration-200 cursor-pointer ${
|
||||||
filterType === filter.key
|
filterType === filter.key
|
||||||
? getThemeClasses("bg-muted")
|
? getThemeClasses("bg-muted")
|
||||||
: `hover:${getThemeClasses("bg-hover")}`
|
: `hover:${getThemeClasses("bg-hover")}`
|
||||||
|
|
@ -885,7 +981,7 @@ const FileManagerIndex = () => {
|
||||||
{filter.count}
|
{filter.count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -896,101 +992,108 @@ const FileManagerIndex = () => {
|
||||||
onClick={() => loadCollections(true)}
|
onClick={() => loadCollections(true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
icon={ArrowPathIcon}
|
||||||
|
className={isLoading ? "[&>svg]:animate-spin" : ""}
|
||||||
>
|
>
|
||||||
<ArrowPathIcon
|
|
||||||
className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate("/file-manager/upload")}
|
onClick={() => navigate("/file-manager/upload")}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
icon={CloudArrowUpIcon}
|
||||||
>
|
>
|
||||||
<CloudArrowUpIcon className="h-4 w-4 mr-2" />
|
|
||||||
Upload
|
Upload
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="relative max-w-2xl">
|
|
||||||
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search folders..."
|
|
||||||
className="pl-12 h-12 text-base w-full"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{(generalError || Object.keys(fieldErrors).length > 0) && (
|
|
||||||
<Alert type="error" className="mb-8">
|
|
||||||
{generalError && <p>{generalError}</p>}
|
|
||||||
{Object.keys(fieldErrors).length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="font-semibold mb-1">
|
|
||||||
Please fix the following errors:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
{Object.entries(fieldErrors).map(([field, message]) => (
|
|
||||||
<li key={field}>
|
|
||||||
<span className="font-medium capitalize">{field}:</span>{" "}
|
|
||||||
{message}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collections Grid */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-24">
|
|
||||||
<div className="text-center">
|
|
||||||
<div
|
|
||||||
className={`h-12 w-12 spinner mx-auto mb-4 ${getThemeClasses("border-primary")}`}
|
|
||||||
></div>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Loading folders...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative max-w-2xl">
|
||||||
|
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
placeholder="Search folders..."
|
||||||
|
aria-label="Search folders"
|
||||||
|
className="pl-12 h-12 text-base w-full"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-4 top-1/2 transform -translate-y-1/2"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{(generalError || Object.keys(fieldErrors).length > 0) && (
|
||||||
|
<Alert type="error" className="mb-6">
|
||||||
|
{generalError && <p>{generalError}</p>}
|
||||||
|
{Object.keys(fieldErrors).length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="font-semibold mb-1">
|
||||||
|
Please fix the following errors:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
{Object.entries(fieldErrors).map(([field, message]) => (
|
||||||
|
<li key={field}>
|
||||||
|
<span className="font-medium capitalize">{field}:</span>{" "}
|
||||||
|
{message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collections Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="text-center">
|
||||||
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading folders...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
{filteredCollections.map((collection, index) => (
|
{filteredCollections.map((collection, index) => (
|
||||||
<div
|
<div
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
className={`${getThemeClasses("bg-card")} rounded-xl border ${getThemeClasses("border")} shadow-sm hover:shadow-md cursor-pointer transition-all duration-200 group relative`}
|
role="article"
|
||||||
onClick={() =>
|
tabIndex={0}
|
||||||
navigate(`/file-manager/collections/${collection.id}`)
|
aria-label={`Folder: ${collection.name || "Locked Folder"}, ${collection.fileCount} ${collection.fileCount === 1 ? "file" : "files"}${collection.isShared ? ", shared" : ""}`}
|
||||||
}
|
className={`${getThemeClasses("bg-card")} rounded-xl border ${getThemeClasses("border")} shadow-sm hover:shadow-md cursor-pointer transition-all duration-200 group relative focus:outline-none focus:ring-2 ${getThemeClasses("focus:ring-primary")}`}
|
||||||
onMouseEnter={() => setHoveredCollection(collection.id)}
|
onClick={() => handleCollectionClick(collection.id)}
|
||||||
onMouseLeave={() => setHoveredCollection(null)}
|
onKeyDown={(e) => handleCollectionKeyDown(e, collection.id)}
|
||||||
|
onMouseEnter={() => handleCollectionMouseEnter(collection.id)}
|
||||||
|
onMouseLeave={handleCollectionMouseLeave}
|
||||||
>
|
>
|
||||||
{/* Delete Button - Only show if user is owner */}
|
{/* Delete Button - Only show if user is owner */}
|
||||||
{collection.isOwned && (
|
{collection.isOwned && (
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => handleDeleteClick(e, collection)}
|
onClick={(e) => handleDeleteClick(e, collection)}
|
||||||
className={`absolute top-4 right-4 p-2 bg-red-50 hover:bg-red-100 text-red-600 hover:text-red-700 rounded-lg transition-all duration-200 opacity-0 group-hover:opacity-100 z-10`}
|
variant="danger"
|
||||||
title="Delete folder"
|
size="sm"
|
||||||
|
className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 z-10"
|
||||||
|
aria-label={`Delete folder ${collection.name || 'Locked Folder'}`}
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-4 w-4" />
|
<TrashIcon className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
|
@ -1065,8 +1168,8 @@ const FileManagerIndex = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-700">
|
<div className={`flex items-center justify-between pt-3 border-t ${getThemeClasses("border-muted")}`}>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
|
||||||
{getTimeAgo(collection.modified)}
|
{getTimeAgo(collection.modified)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|
@ -1074,8 +1177,8 @@ const FileManagerIndex = () => {
|
||||||
<div
|
<div
|
||||||
className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium ${
|
className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
collection.isOwned
|
collection.isOwned
|
||||||
? "bg-green-100 text-green-700"
|
? `${getThemeClasses("bg-success-light")} ${getThemeClasses("text-success")}`
|
||||||
: "bg-blue-100 text-blue-700"
|
: `${getThemeClasses("bg-info-light")} ${getThemeClasses("text-info")}`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShareIcon className="h-3 w-3" />
|
<ShareIcon className="h-3 w-3" />
|
||||||
|
|
@ -1099,10 +1202,20 @@ const FileManagerIndex = () => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(filterType === "owned" || filterType === "all") && (
|
{/* Only show New Folder card when there are existing folders */}
|
||||||
|
{(filterType === "owned" || filterType === "all") && filteredCollections.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Create new folder"
|
||||||
onClick={() => navigate("/file-manager/collections/create")}
|
onClick={() => navigate("/file-manager/collections/create")}
|
||||||
className={`group ${getThemeClasses("bg-card")} rounded-xl border-2 border-dashed ${getThemeClasses("border")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-300`}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/file-manager/collections/create");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`group ${getThemeClasses("bg-card")} rounded-xl border-2 border-dashed ${getThemeClasses("border")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-300 focus:outline-none focus:ring-2 ${getThemeClasses("focus:ring-primary")}`}
|
||||||
>
|
>
|
||||||
<div className="p-6 text-center">
|
<div className="p-6 text-center">
|
||||||
<div
|
<div
|
||||||
|
|
@ -1121,101 +1234,102 @@ const FileManagerIndex = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tag Filter Empty State */}
|
{/* Tag Filter Empty State */}
|
||||||
{!isLoading && filteredCollections.length === 0 && selectedTagIds.length > 0 && !searchQuery && (
|
{!isLoading && filteredCollections.length === 0 && selectedTagIds.length > 0 && !searchQuery && (
|
||||||
<div className="text-center py-24">
|
<div className="text-center py-24">
|
||||||
<div
|
<div
|
||||||
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
||||||
>
|
>
|
||||||
<TagIcon className="h-10 w-10 text-gray-400" />
|
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h3
|
||||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||||
>
|
>
|
||||||
No folders with {selectedTagIds.length === 1 ? 'this tag' : 'these tags'}
|
No folders with {selectedTagIds.length === 1 ? 'this tag' : 'these tags'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||||||
No folders are tagged with {selectedTagIds.length === 1
|
No folders are tagged with {selectedTagIds.length === 1
|
||||||
? `"${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'this tag'}"`
|
? `"${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'this tag'}"`
|
||||||
: `all ${selectedTagIds.length} selected tags`}
|
: `all ${selectedTagIds.length} selected tags`}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
|
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
|
||||||
<TagIcon className="h-4 w-4 mr-2" />
|
<TagIcon className="h-4 w-4 mr-2" />
|
||||||
Clear Tag Filter
|
Clear Tag Filter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{!isLoading && filteredCollections.length === 0 && !searchQuery && selectedTagIds.length === 0 && (
|
{!isLoading && filteredCollections.length === 0 && !searchQuery && selectedTagIds.length === 0 && (
|
||||||
<div className="text-center py-24">
|
<div className="text-center">
|
||||||
<div
|
<div
|
||||||
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-0.5`}
|
||||||
>
|
>
|
||||||
<currentFilter.icon className="h-10 w-10 text-gray-400" />
|
<currentFilter.icon className="h-10 w-10 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h3
|
||||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||||
>
|
>
|
||||||
{filterType === "shared"
|
{filterType === "shared"
|
||||||
? "No shared folders"
|
? "No shared folders"
|
||||||
: filterType === "all"
|
: filterType === "all"
|
||||||
? "No folders found"
|
? "No folders found"
|
||||||
: "No folders yet"}
|
: "No folders yet"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||||||
{filterType === "shared"
|
{filterType === "shared"
|
||||||
? "When someone shares a folder with you, it will appear here"
|
? "When someone shares a folder with you, it will appear here"
|
||||||
: filterType === "all"
|
: "Create your first folder now"}
|
||||||
? "Create your first folder or wait for someone to share with you"
|
</p>
|
||||||
: "Create your first encrypted folder to start organizing your files"}
|
{filterType !== "shared" && (
|
||||||
</p>
|
<Button
|
||||||
{filterType !== "shared" && (
|
onClick={() => navigate("/file-manager/collections/create")}
|
||||||
<Button
|
variant="primary"
|
||||||
onClick={() => navigate("/file-manager/collections/create")}
|
icon={PlusIcon}
|
||||||
variant="primary"
|
>
|
||||||
>
|
Create Your First Folder
|
||||||
<PlusIcon className="h-4 w-4 mr-2" />
|
</Button>
|
||||||
Create Your First Folder
|
)}
|
||||||
</Button>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Empty State */}
|
||||||
|
{!isLoading && filteredCollections.length === 0 && searchQuery && (
|
||||||
|
<div className="text-center py-24">
|
||||||
|
<div
|
||||||
|
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
||||||
|
>
|
||||||
|
<MagnifyingGlassIcon className="h-10 w-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||||
|
>
|
||||||
|
No folders found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||||
|
No folders match your search for "{searchQuery}"
|
||||||
|
{selectedTagIds.length > 0 && ` with ${selectedTagIds.length === 1
|
||||||
|
? `tag "${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'selected tag'}"`
|
||||||
|
: `${selectedTagIds.length} selected tags`}`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center space-x-3">
|
||||||
|
<Button onClick={() => setSearchQuery("")} variant="secondary">
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
{selectedTagIds.length > 0 && (
|
||||||
|
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
|
||||||
|
Clear tag filter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
|
|
||||||
{/* Search Empty State */}
|
|
||||||
{!isLoading && filteredCollections.length === 0 && searchQuery && (
|
|
||||||
<div className="text-center py-24">
|
|
||||||
<div
|
|
||||||
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
|
||||||
>
|
|
||||||
<MagnifyingGlassIcon className="h-10 w-10 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h3
|
|
||||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
|
||||||
>
|
|
||||||
No folders found
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
|
||||||
No folders match your search for "{searchQuery}"
|
|
||||||
{selectedTagIds.length > 0 && ` with ${selectedTagIds.length === 1
|
|
||||||
? `tag "${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'selected tag'}"`
|
|
||||||
: `${selectedTagIds.length} selected tags`}`}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-center space-x-3">
|
|
||||||
<Button onClick={() => setSearchQuery("")} variant="secondary">
|
|
||||||
Clear search
|
|
||||||
</Button>
|
|
||||||
{selectedTagIds.length > 0 && (
|
|
||||||
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
|
|
||||||
Clear tag filter
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
|
|
@ -1236,46 +1350,39 @@ const FileManagerIndex = () => {
|
||||||
onClick={handleConfirmDelete}
|
onClick={handleConfirmDelete}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
loading={isDeleting}
|
||||||
|
loadingText="Deleting..."
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
<TrashIcon className="h-4 w-4 mr-2" />
|
||||||
<>
|
Delete Folder
|
||||||
<div className="h-4 w-4 spinner border-white mr-2"></div>
|
|
||||||
Deleting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TrashIcon className="h-4 w-4 mr-2" />
|
|
||||||
Delete Folder
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{collectionToDelete && (
|
{collectionToDelete && (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
<p className={`${getThemeClasses("text-secondary")} mb-4`}>
|
||||||
Are you sure you want to delete "
|
Are you sure you want to delete "
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{collectionToDelete.name || "Locked Folder"}
|
{collectionToDelete.name || "Locked Folder"}
|
||||||
</span>
|
</span>
|
||||||
"? This will permanently delete:
|
"? This will permanently delete:
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2 mb-6 bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
<ul className={`text-sm ${getThemeClasses("text-secondary")} space-y-2 mb-6 ${getThemeClasses("bg-error-light")} p-4 rounded-lg`}>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-red-600 mr-2">•</span>
|
<span className={`${getThemeClasses("text-error")} mr-2`}>•</span>
|
||||||
<span>All files in this folder</span>
|
<span>All files in this folder</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-red-600 mr-2">•</span>
|
<span className={`${getThemeClasses("text-error")} mr-2`}>•</span>
|
||||||
<span>All sub-folders and their contents</span>
|
<span>All sub-folders and their contents</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-red-600 mr-2">•</span>
|
<span className={`${getThemeClasses("text-error")} mr-2`}>•</span>
|
||||||
<span>All files in sub-folders</span>
|
<span>All files in sub-folders</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-sm text-red-600 font-medium">
|
<p className={`text-sm ${getThemeClasses("text-error")} font-medium`}>
|
||||||
This action cannot be undone.
|
This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -735,7 +735,7 @@ const FileDetails = () => {
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedTagIds.includes(tag.id)}
|
checked={selectedTagIds.includes(tag.id)}
|
||||||
onChange={() => handleToggleTag(tag.id)}
|
onChange={() => handleToggleTag(tag.id)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className={`h-4 w-4 rounded ${getThemeClasses("checkbox-focus")}`}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="w-4 h-4 rounded-full ml-3 mr-2 flex-shrink-0"
|
className="w-4 h-4 rounded-full ml-3 mr-2 flex-shrink-0"
|
||||||
|
|
@ -749,7 +749,7 @@ const FileDetails = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 mt-4 pt-4 border-t border-gray-200">
|
<div className={`flex justify-end space-x-3 mt-4 pt-4 border-t ${getThemeClasses("border-secondary")}`}>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCloseTagEditor}
|
onClick={handleCloseTagEditor}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
useUIXTheme,
|
useUIXTheme,
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Card,
|
Card,
|
||||||
|
Select,
|
||||||
|
Checkbox,
|
||||||
} from "../../../../components/UIX";
|
} from "../../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
CloudArrowUpIcon,
|
CloudArrowUpIcon,
|
||||||
|
|
@ -63,7 +65,8 @@ const FileUpload = () => {
|
||||||
preSelectedCollectionId || "",
|
preSelectedCollectionId || "",
|
||||||
);
|
);
|
||||||
const [availableCollections, setAvailableCollections] = useState([]);
|
const [availableCollections, setAvailableCollections] = useState([]);
|
||||||
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
const [isLoadingCollections, setIsLoadingCollections] = useState(true); // Start with loading true
|
||||||
|
const [isCollectionsInitialized, setIsCollectionsInitialized] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
@ -104,19 +107,24 @@ const FileUpload = () => {
|
||||||
}, [authManager]);
|
}, [authManager]);
|
||||||
|
|
||||||
const loadCollections = useCallback(async () => {
|
const loadCollections = useCallback(async () => {
|
||||||
|
if (!listCollectionManager) return;
|
||||||
|
|
||||||
setIsLoadingCollections(true);
|
setIsLoadingCollections(true);
|
||||||
try {
|
try {
|
||||||
const result = await listCollectionManager.listCollections(false);
|
const result = await listCollectionManager.listCollections(false);
|
||||||
if (
|
if (isMountedRef.current) {
|
||||||
result.collections &&
|
if (result.collections && result.collections.length > 0) {
|
||||||
result.collections.length > 0 &&
|
setAvailableCollections(result.collections);
|
||||||
isMountedRef.current
|
}
|
||||||
) {
|
setIsCollectionsInitialized(true);
|
||||||
setAvailableCollections(result.collections);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[FileUpload] Could not load folders:", err);
|
||||||
|
}
|
||||||
setError("Could not load folders");
|
setError("Could not load folders");
|
||||||
|
setIsCollectionsInitialized(true);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
|
|
@ -125,11 +133,12 @@ const FileUpload = () => {
|
||||||
}
|
}
|
||||||
}, [listCollectionManager]);
|
}, [listCollectionManager]);
|
||||||
|
|
||||||
|
// Load collections when manager becomes available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (createCollectionManager && listCollectionManager) {
|
if (createCollectionManager && listCollectionManager && !isCollectionsInitialized) {
|
||||||
loadCollections();
|
loadCollections();
|
||||||
}
|
}
|
||||||
}, [createCollectionManager, listCollectionManager, loadCollections]);
|
}, [createCollectionManager, listCollectionManager, isCollectionsInitialized, loadCollections]);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e) => {
|
const handleDragOver = useCallback((e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -665,6 +674,14 @@ const FileUpload = () => {
|
||||||
errorFiles,
|
errorFiles,
|
||||||
} = fileStats;
|
} = fileStats;
|
||||||
|
|
||||||
|
// Memoize collection options for Select component
|
||||||
|
const collectionOptions = useMemo(() => {
|
||||||
|
return availableCollections.map((collection) => ({
|
||||||
|
value: collection.id,
|
||||||
|
label: collection.name || "Unnamed Folder",
|
||||||
|
}));
|
||||||
|
}, [availableCollections]);
|
||||||
|
|
||||||
// Build breadcrumb
|
// Build breadcrumb
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{
|
{
|
||||||
|
|
@ -756,39 +773,73 @@ const FileUpload = () => {
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 pb-6 pt-5">
|
<div className="px-6 pb-6 pt-5">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className={`grid grid-cols-1 gap-8 ${preSelectedCollectionId ? "lg:grid-cols-3" : ""}`}>
|
||||||
{/* Upload Area */}
|
{/* Upload Area */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className={`space-y-6 ${preSelectedCollectionId ? "lg:col-span-2" : ""}`}>
|
||||||
{/* Collection Selector (only show if no pre-selected collection) */}
|
{/* Collection Selector with Upload Button (only show if no pre-selected collection) */}
|
||||||
{!preSelectedCollectionId && (
|
{!preSelectedCollectionId && (
|
||||||
<div>
|
<div className="flex items-end gap-3">
|
||||||
<label
|
<Select
|
||||||
className={`block text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}
|
label="Select destination folder"
|
||||||
>
|
|
||||||
Select destination folder
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedCollection}
|
value={selectedCollection}
|
||||||
onChange={(e) => setSelectedCollection(e.target.value)}
|
onChange={setSelectedCollection}
|
||||||
|
options={collectionOptions}
|
||||||
disabled={isLoadingCollections || isUploading}
|
disabled={isLoadingCollections || isUploading}
|
||||||
className={`w-full px-4 py-3 border ${getThemeClasses("border-secondary")} rounded-lg ${getThemeClasses("bg-card")} ${getThemeClasses("text-primary")} focus:ring-2 ${getThemeClasses("focus:ring-primary")} ${getThemeClasses("focus:border-primary")} transition-all duration-200`}
|
placeholder="Choose a folder..."
|
||||||
>
|
size="md"
|
||||||
<option value="">Choose a folder...</option>
|
className="flex-1"
|
||||||
{availableCollections.map((collection) => (
|
/>
|
||||||
<option key={collection.id} value={collection.id}>
|
<Button
|
||||||
{collection.name || "Unnamed Folder"}
|
onClick={startUpload}
|
||||||
</option>
|
disabled={
|
||||||
))}
|
!selectedCollection ||
|
||||||
</select>
|
!fileManager ||
|
||||||
|
files.length === 0 ||
|
||||||
|
isUploading ||
|
||||||
|
pendingFiles.length === 0 ||
|
||||||
|
!gdprConsent
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
className="flex-shrink-0 h-[50px]"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
||||||
|
<span>
|
||||||
|
Uploading {uploadingFiles.length} of {files.length}...
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowUpTrayIcon className="h-5 w-5" />
|
||||||
|
<span>
|
||||||
|
Upload {pendingFiles.length} File
|
||||||
|
{pendingFiles.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Drop Zone */}
|
{/* Drop Zone */}
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={isDragging ? "Drop files here to upload" : "Click or drag files here to upload"}
|
||||||
|
aria-disabled={isUploading}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => !isUploading && fileInputRef.current?.click()}
|
onClick={() => !isUploading && fileInputRef.current?.click()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.key === "Enter" || e.key === " ") && !isUploading) {
|
||||||
|
e.preventDefault();
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-300 ${
|
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-300 ${
|
||||||
isDragging
|
isDragging
|
||||||
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
|
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
|
||||||
|
|
@ -833,6 +884,8 @@ const FileUpload = () => {
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
|
aria-label="Select files to upload"
|
||||||
|
id="file-upload-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -860,11 +913,15 @@ const FileUpload = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
role="list"
|
||||||
|
aria-label="Selected files for upload"
|
||||||
className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`}
|
className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`}
|
||||||
>
|
>
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
|
role="listitem"
|
||||||
|
aria-label={`${file.name}, ${formatFileSize(file.size)}, status: ${file.status}`}
|
||||||
className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`}
|
className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
|
@ -919,16 +976,19 @@ const FileUpload = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{file.status === "pending" && (
|
{file.status === "pending" && (
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeFile(file.id);
|
removeFile(file.id);
|
||||||
}}
|
}}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className={`p-2 ${getThemeClasses("text-muted")} hover:opacity-80 ${getThemeClasses("alert-error-bg")} rounded-lg transition-all duration-200`}
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
className={`${getThemeClasses("hover:text-error")}`}
|
||||||
>
|
>
|
||||||
<XMarkIcon className="h-5 w-5" />
|
<XMarkIcon className="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{file.status === "uploading" && (
|
{file.status === "uploading" && (
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
|
|
@ -960,7 +1020,8 @@ const FileUpload = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar - only show as separate column when collection is pre-selected */}
|
||||||
|
{preSelectedCollectionId && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1077,12 +1138,11 @@ const FileUpload = () => {
|
||||||
<div
|
<div
|
||||||
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")}`}
|
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")}`}
|
||||||
>
|
>
|
||||||
<label className="flex items-start space-x-3 cursor-pointer">
|
<div className="flex items-start space-x-3">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={gdprConsent}
|
checked={gdprConsent}
|
||||||
onChange={(e) => setGdprConsent(e.target.checked)}
|
onChange={setGdprConsent}
|
||||||
className={`mt-1 h-4 w-4 rounded ${getThemeClasses("checkbox-focus")}`}
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p
|
<p
|
||||||
|
|
@ -1097,10 +1157,11 @@ const FileUpload = () => {
|
||||||
you share with can decrypt them.
|
you share with can decrypt them.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Upload button in sidebar */}
|
||||||
<Button
|
<Button
|
||||||
onClick={startUpload}
|
onClick={startUpload}
|
||||||
disabled={
|
disabled={
|
||||||
|
|
@ -1134,7 +1195,150 @@ const FileUpload = () => {
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Options section - show below content when no pre-selected collection */}
|
||||||
|
{!preSelectedCollectionId && files.length > 0 && (
|
||||||
|
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Upload Status */}
|
||||||
|
<div
|
||||||
|
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className={`font-semibold mb-4 ${getThemeClasses("text-primary")}`}
|
||||||
|
>
|
||||||
|
Upload Status
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${getThemeClasses("text-primary")}`}
|
||||||
|
>
|
||||||
|
{pendingFiles.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
||||||
|
>
|
||||||
|
Uploading
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${getThemeClasses("text-info")}`}
|
||||||
|
>
|
||||||
|
{uploadingFiles.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
||||||
|
>
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${getThemeClasses("text-success")}`}
|
||||||
|
>
|
||||||
|
{completedFiles.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{errorFiles.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
||||||
|
>
|
||||||
|
Errors
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${getThemeClasses("text-error")}`}
|
||||||
|
>
|
||||||
|
{errorFiles.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{completedFiles.length + errorFiles.length ===
|
||||||
|
files.length &&
|
||||||
|
files.length > 0 &&
|
||||||
|
!isUploading &&
|
||||||
|
pendingFiles.length === 0 && (
|
||||||
|
<div
|
||||||
|
className={`mt-4 p-3 ${getThemeClasses("alert-success-bg")} border ${getThemeClasses("alert-success-border")} rounded-lg`}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={`text-sm ${getThemeClasses("text-success")} flex items-center`}
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="h-4 w-4 mr-2" />
|
||||||
|
All uploads processed!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Selection */}
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<TagIcon
|
||||||
|
className={`h-5 w-5 ${getThemeClasses("text-secondary")}`}
|
||||||
|
/>
|
||||||
|
<h3
|
||||||
|
className={`font-semibold ${getThemeClasses("text-primary")}`}
|
||||||
|
>
|
||||||
|
Add Tags
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-xs ${getThemeClasses("text-secondary")} mb-3`}
|
||||||
|
>
|
||||||
|
Tags will be applied to all uploaded files
|
||||||
|
</p>
|
||||||
|
<TagSelector
|
||||||
|
value={selectedTagIds}
|
||||||
|
onChange={setSelectedTagIds}
|
||||||
|
disabled={isUploading}
|
||||||
|
label=""
|
||||||
|
placeholder="Select tags..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consent & Security Notice */}
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")} h-fit`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={gdprConsent}
|
||||||
|
onChange={setGdprConsent}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium ${getThemeClasses("text-primary")}`}
|
||||||
|
>
|
||||||
|
I consent to encrypted upload
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-xs ${getThemeClasses("text-secondary")} mt-1`}
|
||||||
|
>
|
||||||
|
Files are encrypted end-to-end and only you and those
|
||||||
|
you share with can decrypt them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -84,14 +84,28 @@ const SearchResults = memo(function SearchResults() {
|
||||||
// Refs for cleanup and debouncing
|
// Refs for cleanup and debouncing
|
||||||
const searchDebounceRef = useRef(null);
|
const searchDebounceRef = useRef(null);
|
||||||
|
|
||||||
|
// Ref to hold cache for cleanup (avoids stale closure)
|
||||||
|
const searchCacheRef = useRef(searchCache);
|
||||||
|
searchCacheRef.current = searchCache;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false;
|
isMountedRef.current = false;
|
||||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||||
|
// Clear search cache on unmount to prevent memory leaks
|
||||||
|
searchCacheRef.current.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Sanitize filename to prevent path traversal and special character issues
|
||||||
|
const sanitizeFilename = useCallback((filename) => {
|
||||||
|
return filename
|
||||||
|
.replace(/[^a-z0-9_-]/gi, '_') // Replace non-alphanumeric with underscore
|
||||||
|
.replace(/_+/g, '_') // Collapse multiple underscores
|
||||||
|
.substring(0, 50); // Limit length
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Export search results (GDPR Article 20 - Data Portability)
|
// Export search results (GDPR Article 20 - Data Portability)
|
||||||
const handleExportResults = useCallback(() => {
|
const handleExportResults = useCallback(() => {
|
||||||
const exportData = {
|
const exportData = {
|
||||||
|
|
@ -113,12 +127,14 @@ const SearchResults = memo(function SearchResults() {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `search_results_${query}_${Date.now()}.json`;
|
// Sanitize query for safe filename
|
||||||
|
const safeQuery = sanitizeFilename(query || 'export');
|
||||||
|
a.download = `search_results_${safeQuery}_${Date.now()}.json`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [query, results]);
|
}, [query, results, sanitizeFilename]);
|
||||||
|
|
||||||
// Search function with caching and validation
|
// Search function with caching and validation
|
||||||
const performSearch = useCallback(
|
const performSearch = useCallback(
|
||||||
|
|
@ -292,9 +308,9 @@ const SearchResults = memo(function SearchResults() {
|
||||||
const getFileIcon = useCallback((file) => {
|
const getFileIcon = useCallback((file) => {
|
||||||
const mimeType = file.mime_type || "";
|
const mimeType = file.mime_type || "";
|
||||||
if (mimeType.startsWith("image/"))
|
if (mimeType.startsWith("image/"))
|
||||||
return <PhotoIcon className="h-5 w-5 text-pink-600" />;
|
return <PhotoIcon className={`h-5 w-5 ${getThemeClasses("text-accent")}`} />;
|
||||||
return <DocumentIcon className="h-5 w-5 text-gray-600 dark:text-gray-400" />;
|
return <DocumentIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")}`} />;
|
||||||
}, []);
|
}, [getThemeClasses]);
|
||||||
|
|
||||||
const totalResults = results.collections.length + results.files.length;
|
const totalResults = results.collections.length + results.files.length;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,22 @@ const TrashView = () => {
|
||||||
let auditLogs = [];
|
let auditLogs = [];
|
||||||
const stored = localStorage.getItem("security_audit_logs");
|
const stored = localStorage.getItem("security_audit_logs");
|
||||||
if (stored) {
|
if (stored) {
|
||||||
auditLogs = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
// Validate it's an array
|
// Validate it's an array and each entry has expected structure
|
||||||
if (!Array.isArray(auditLogs)) {
|
if (Array.isArray(parsed)) {
|
||||||
auditLogs = [];
|
// Filter to only valid audit log entries to prevent injection
|
||||||
|
auditLogs = parsed.filter(log =>
|
||||||
|
log &&
|
||||||
|
typeof log === 'object' &&
|
||||||
|
typeof log.action === 'string' &&
|
||||||
|
typeof log.timestamp === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If structure was invalid, clear corrupted data
|
||||||
|
if (!Array.isArray(parsed) || auditLogs.length !== parsed.length) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn("[TrashView] Cleared invalid audit log entries");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auditLogs.push(auditLog);
|
auditLogs.push(auditLog);
|
||||||
|
|
@ -98,7 +110,8 @@ const TrashView = () => {
|
||||||
}
|
}
|
||||||
localStorage.setItem("security_audit_logs", JSON.stringify(auditLogs));
|
localStorage.setItem("security_audit_logs", JSON.stringify(auditLogs));
|
||||||
} catch (storageErr) {
|
} catch (storageErr) {
|
||||||
// If storage fails, just log to console in dev mode
|
// If storage fails, clear potentially corrupted data and log to console in dev mode
|
||||||
|
localStorage.removeItem("security_audit_logs");
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.warn("[TrashView] Could not store audit log:", storageErr.message);
|
console.warn("[TrashView] Could not store audit log:", storageErr.message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import React from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Layout from "../../../components/Layout/Layout";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
import { Button, Card, useUIXTheme } from "../../../components/UIX";
|
import { Button, Card, Breadcrumb, GDPRFooter, useUIXTheme } from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
QuestionMarkCircleIcon,
|
QuestionMarkCircleIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
|
|
@ -13,11 +13,9 @@ import {
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
MagnifyingGlassIcon,
|
|
||||||
KeyIcon,
|
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
LockClosedIcon,
|
LockClosedIcon,
|
||||||
ArrowLeftIcon,
|
HomeIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const Help = () => {
|
const Help = () => {
|
||||||
|
|
@ -28,8 +26,8 @@ const Help = () => {
|
||||||
{
|
{
|
||||||
title: "Getting Started",
|
title: "Getting Started",
|
||||||
icon: QuestionMarkCircleIcon,
|
icon: QuestionMarkCircleIcon,
|
||||||
color: "text-blue-600 dark:text-blue-400",
|
color: getThemeClasses("help-section-blue-text"),
|
||||||
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
bgColor: getThemeClasses("help-section-blue-bg"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "What is MapleFile?",
|
title: "What is MapleFile?",
|
||||||
|
|
@ -51,8 +49,8 @@ const Help = () => {
|
||||||
{
|
{
|
||||||
title: "Security & Encryption",
|
title: "Security & Encryption",
|
||||||
icon: ShieldCheckIcon,
|
icon: ShieldCheckIcon,
|
||||||
color: "text-green-600 dark:text-green-400",
|
color: getThemeClasses("help-section-green-text"),
|
||||||
bgColor: "bg-green-50 dark:bg-green-900/20",
|
bgColor: getThemeClasses("help-section-green-bg"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "End-to-End Encryption (E2EE)",
|
title: "End-to-End Encryption (E2EE)",
|
||||||
|
|
@ -74,8 +72,8 @@ const Help = () => {
|
||||||
{
|
{
|
||||||
title: "File Management",
|
title: "File Management",
|
||||||
icon: FolderIcon,
|
icon: FolderIcon,
|
||||||
color: "text-purple-600 dark:text-purple-400",
|
color: getThemeClasses("help-section-purple-text"),
|
||||||
bgColor: "bg-purple-50 dark:bg-purple-900/20",
|
bgColor: getThemeClasses("help-section-purple-bg"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Organizing with Folders",
|
title: "Organizing with Folders",
|
||||||
|
|
@ -97,8 +95,8 @@ const Help = () => {
|
||||||
{
|
{
|
||||||
title: "Sharing & Collaboration",
|
title: "Sharing & Collaboration",
|
||||||
icon: ShareIcon,
|
icon: ShareIcon,
|
||||||
color: "text-pink-600 dark:text-pink-400",
|
color: getThemeClasses("help-section-pink-text"),
|
||||||
bgColor: "bg-pink-50 dark:bg-pink-900/20",
|
bgColor: getThemeClasses("help-section-pink-bg"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Sharing Folders",
|
title: "Sharing Folders",
|
||||||
|
|
@ -120,8 +118,8 @@ const Help = () => {
|
||||||
{
|
{
|
||||||
title: "Data Management",
|
title: "Data Management",
|
||||||
icon: TrashIcon,
|
icon: TrashIcon,
|
||||||
color: "text-red-600 dark:text-red-400",
|
color: getThemeClasses("help-section-red-text"),
|
||||||
bgColor: "bg-red-50 dark:bg-red-900/20",
|
bgColor: getThemeClasses("help-section-red-bg"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Trash & Recovery",
|
title: "Trash & Recovery",
|
||||||
|
|
@ -173,94 +171,136 @@ const Help = () => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{
|
||||||
|
label: "Dashboard",
|
||||||
|
to: "/dashboard",
|
||||||
|
icon: HomeIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Help",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Header */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="mb-8">
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<Button
|
|
||||||
onClick={() => navigate("/dashboard")}
|
|
||||||
variant="secondary"
|
|
||||||
className="mb-4"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
|
||||||
Back to Dashboard
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<h1 className={`text-3xl font-bold flex items-center ${getThemeClasses("text-primary")}`}>
|
{/* Main Card */}
|
||||||
<QuestionMarkCircleIcon className={`h-8 w-8 mr-3 ${getThemeClasses("text-accent")}`} />
|
<Card>
|
||||||
Help & Documentation
|
{/* Header with icon, title, and action button */}
|
||||||
</h1>
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
<p className={`${getThemeClasses("text-secondary")} mt-2`}>
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
Learn how to use MapleFile's secure file storage features
|
<div className="flex-1 min-w-0">
|
||||||
</p>
|
<div className="flex items-start">
|
||||||
</div>
|
{/* Icon */}
|
||||||
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||||
{/* Quick Actions */}
|
<QuestionMarkCircleIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
<div className="mb-8">
|
</div>
|
||||||
<h2 className={`text-xl font-semibold mb-4 ${getThemeClasses("text-primary")}`}>
|
{/* Title and subtitle */}
|
||||||
Quick Actions
|
<div>
|
||||||
</h2>
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
Help & Documentation
|
||||||
{quickActions.map((action, index) => (
|
</h1>
|
||||||
<Card
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
key={index}
|
Learn how to use MapleFile's secure file storage features
|
||||||
className={`${getThemeClasses("hover:shadow")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-200`}
|
</p>
|
||||||
onClick={() => navigate(action.path)}
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-accent-light")} flex items-center justify-center mb-3`}>
|
|
||||||
<action.icon className={`h-6 w-6 ${action.color}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className={`font-medium mb-1 ${getThemeClasses("text-primary")}`}>
|
|
||||||
{action.title}
|
|
||||||
</h3>
|
|
||||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
|
||||||
{action.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Help Sections */}
|
|
||||||
<div className="space-y-8">
|
|
||||||
{helpSections.map((section, sectionIndex) => (
|
|
||||||
<div key={sectionIndex}>
|
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<div className={`h-10 w-10 rounded-lg ${section.bgColor} flex items-center justify-center mr-3`}>
|
|
||||||
<section.icon className={`h-6 w-6 ${section.color}`} />
|
|
||||||
</div>
|
|
||||||
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
|
||||||
{section.title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Action button */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/dashboard")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<HomeIcon className="h-4 w-4" />
|
||||||
|
<span>Back to Dashboard</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{/* Content */}
|
||||||
{section.items.map((item, itemIndex) => (
|
<div className="p-6">
|
||||||
<Card key={itemIndex} className="h-full">
|
{/* Quick Actions */}
|
||||||
<div className="p-6">
|
<div className="mb-8">
|
||||||
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
<h2 className={`text-xl font-semibold mb-4 ${getThemeClasses("text-primary")}`}>
|
||||||
{item.title}
|
Quick Actions
|
||||||
</h3>
|
</h2>
|
||||||
<p className={`text-sm ${getThemeClasses("text-secondary")} leading-relaxed`}>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" role="list" aria-label="Quick actions">
|
||||||
{item.description}
|
{quickActions.map((action, index) => (
|
||||||
</p>
|
<div
|
||||||
|
key={index}
|
||||||
|
role="listitem"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${action.title}: ${action.description}`}
|
||||||
|
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
|
onClick={() => navigate(action.path)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(action.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-accent-light")} flex items-center justify-center mb-3`} aria-hidden="true">
|
||||||
|
<action.icon className={`h-6 w-6 ${action.color}`} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<h3 className={`font-medium mb-1 ${getThemeClasses("text-primary")}`}>
|
||||||
|
{action.title}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
|
{action.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Resources */}
|
{/* Help Sections */}
|
||||||
<div className="mt-12">
|
<div className="space-y-8">
|
||||||
<Card className={getThemeClasses("bg-accent-light")}>
|
{helpSections.map((section, sectionIndex) => (
|
||||||
<div className="p-6">
|
<section key={sectionIndex} aria-labelledby={`section-${sectionIndex}`}>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<div className={`h-10 w-10 rounded-lg ${section.bgColor} flex items-center justify-center mr-3`} aria-hidden="true">
|
||||||
|
<section.icon className={`h-6 w-6 ${section.color}`} />
|
||||||
|
</div>
|
||||||
|
<h2 id={`section-${sectionIndex}`} className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" role="list" aria-label={`${section.title} topics`}>
|
||||||
|
{section.items.map((item, itemIndex) => (
|
||||||
|
<article
|
||||||
|
key={itemIndex}
|
||||||
|
role="listitem"
|
||||||
|
className={`p-6 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} h-full`}
|
||||||
|
>
|
||||||
|
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm ${getThemeClasses("text-secondary")} leading-relaxed`}>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Privacy & Security Notice */}
|
||||||
|
<div className={`mt-12 p-6 rounded-lg ${getThemeClasses("bg-accent-light")}`}>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<LockClosedIcon className={`h-6 w-6 ${getThemeClasses("text-accent")} mr-3 flex-shrink-0 mt-0.5`} />
|
<LockClosedIcon className={`h-6 w-6 ${getThemeClasses("text-accent")} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
<h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
Privacy & Security Notice
|
Privacy & Security Notice
|
||||||
|
|
@ -268,57 +308,56 @@ const Help = () => {
|
||||||
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-3`}>
|
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-3`}>
|
||||||
MapleFile uses end-to-end encryption to protect your data. This means:
|
MapleFile uses end-to-end encryption to protect your data. This means:
|
||||||
</p>
|
</p>
|
||||||
<ul className={`space-y-2 text-sm ${getThemeClasses("text-secondary")}`}>
|
<ul className={`space-y-2 text-sm ${getThemeClasses("text-secondary")}`} role="list">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true">•</span>
|
||||||
<span>Your files are encrypted before leaving your device</span>
|
<span>Your files are encrypted before leaving your device</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true">•</span>
|
||||||
<span>MapleFile cannot access, read, or decrypt your files</span>
|
<span>MapleFile cannot access, read, or decrypt your files</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true">•</span>
|
||||||
<span>If you forget your password, your data cannot be recovered</span>
|
<span>If you forget your password, your data cannot be recovered</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true">•</span>
|
||||||
<span>Always keep your recovery keys safe and secure</span>
|
<span>Always keep your recovery keys safe and secure</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Support Section */}
|
{/* Support Section */}
|
||||||
<div className="mt-8">
|
<div className={`mt-8 p-6 rounded-lg border ${getThemeClasses("border-secondary")}`}>
|
||||||
<Card>
|
|
||||||
<div className="p-6">
|
|
||||||
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
||||||
Need More Help?
|
Need More Help?
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}>
|
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}>
|
||||||
If you have questions not covered in this help documentation:
|
If you have questions not covered in this help documentation:
|
||||||
</p>
|
</p>
|
||||||
<ul className={`space-y-2 text-sm ${getThemeClasses("text-secondary")}`}>
|
<ul className={`space-y-2 text-sm ${getThemeClasses("text-secondary")}`} role="list">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true">•</span>
|
||||||
<span>Check the README.md file in the MapleFile repository</span>
|
<span>Check the README.md file in the MapleFile repository</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true">•</span>
|
||||||
<span>Open an issue on Codeberg for technical support</span>
|
<span>Open an issue on Codeberg for technical support</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true">•</span>
|
||||||
<span>Review the application's documentation and user guide</span>
|
<span>Review the application's documentation and user guide</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
|
{/* GDPR Footer */}
|
||||||
|
<GDPRFooter className="mt-8" />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,33 @@
|
||||||
// File: src/pages/User/Me/BlockedUsers.jsx
|
// File: src/pages/User/Me/BlockedUsers.jsx
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useAuth } from "../../../services/Services";
|
import { useAuth } from "../../../services/Services";
|
||||||
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Navigation from "../../../components/Navigation";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
import BlockedEmailManager from "../../../services/Manager/BlockedEmailManager";
|
import BlockedEmailManager from "../../../services/Manager/BlockedEmailManager";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
Spinner,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
NoSymbolIcon,
|
NoSymbolIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ExclamationTriangleIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const BlockedUsers = () => {
|
const BlockedUsers = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { authManager } = useAuth();
|
const { authManager } = useAuth();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// Page state
|
// Page state
|
||||||
const [blockedEmails, setBlockedEmails] = useState([]);
|
const [blockedEmails, setBlockedEmails] = useState([]);
|
||||||
|
|
@ -36,6 +46,14 @@ const BlockedUsers = () => {
|
||||||
// Manager instance
|
// Manager instance
|
||||||
const [blockedEmailManager, setBlockedEmailManager] = useState(null);
|
const [blockedEmailManager, setBlockedEmailManager] = useState(null);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Initialize manager
|
// Initialize manager
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authManager) {
|
if (authManager) {
|
||||||
|
|
@ -53,15 +71,25 @@ const BlockedUsers = () => {
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[BlockedUsers] Loading blocked emails...");
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[BlockedUsers] Loading blocked emails...");
|
||||||
|
}
|
||||||
const emails = await blockedEmailManager.getBlockedEmails(true);
|
const emails = await blockedEmailManager.getBlockedEmails(true);
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setBlockedEmails(emails);
|
setBlockedEmails(emails);
|
||||||
console.log("[BlockedUsers] Loaded", emails.length, "blocked emails");
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[BlockedUsers] Loaded", emails.length, "blocked emails");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[BlockedUsers] Failed to load blocked emails:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[BlockedUsers] Failed to load blocked emails:", err);
|
||||||
|
}
|
||||||
setError(err.message || "Failed to load blocked users.");
|
setError(err.message || "Failed to load blocked users.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [blockedEmailManager]);
|
}, [blockedEmailManager]);
|
||||||
|
|
||||||
|
|
@ -72,244 +100,251 @@ const BlockedUsers = () => {
|
||||||
}, [blockedEmailManager, authManager, loadBlockedEmails]);
|
}, [blockedEmailManager, authManager, loadBlockedEmails]);
|
||||||
|
|
||||||
// Handle add email
|
// Handle add email
|
||||||
const handleAddEmail = async (e) => {
|
const handleAddEmail = useCallback(async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newEmail.trim()) return;
|
if (!newEmail.trim() || !isMountedRef.current) return;
|
||||||
|
|
||||||
setAddLoading(true);
|
setAddLoading(true);
|
||||||
setAddError("");
|
setAddError("");
|
||||||
setSuccess("");
|
setSuccess("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[BlockedUsers] Adding email to blocked list:", newEmail);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[BlockedUsers] Adding email to blocked list:", newEmail);
|
||||||
|
}
|
||||||
await blockedEmailManager.addBlockedEmail(newEmail.trim());
|
await blockedEmailManager.addBlockedEmail(newEmail.trim());
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setSuccess(`${newEmail} has been blocked successfully.`);
|
setSuccess(`${newEmail} has been blocked successfully.`);
|
||||||
setNewEmail("");
|
setNewEmail("");
|
||||||
await loadBlockedEmails();
|
await loadBlockedEmails();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[BlockedUsers] Failed to add blocked email:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[BlockedUsers] Failed to add blocked email:", err);
|
||||||
|
}
|
||||||
setAddError(err.message || "Failed to block email.");
|
setAddError(err.message || "Failed to block email.");
|
||||||
} finally {
|
} finally {
|
||||||
setAddLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setAddLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [newEmail, blockedEmailManager, loadBlockedEmails]);
|
||||||
|
|
||||||
// Handle remove email
|
// Handle remove email
|
||||||
const handleRemoveEmail = async (email) => {
|
const handleRemoveEmail = useCallback(async (email) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
setDeleteLoading(email);
|
setDeleteLoading(email);
|
||||||
setError("");
|
setError("");
|
||||||
setSuccess("");
|
setSuccess("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[BlockedUsers] Removing email from blocked list:", email);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[BlockedUsers] Removing email from blocked list:", email);
|
||||||
|
}
|
||||||
await blockedEmailManager.removeBlockedEmail(email);
|
await blockedEmailManager.removeBlockedEmail(email);
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setSuccess(`${email} has been unblocked.`);
|
setSuccess(`${email} has been unblocked.`);
|
||||||
await loadBlockedEmails();
|
await loadBlockedEmails();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[BlockedUsers] Failed to remove blocked email:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[BlockedUsers] Failed to remove blocked email:", err);
|
||||||
|
}
|
||||||
setError(err.message || "Failed to unblock email.");
|
setError(err.message || "Failed to unblock email.");
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteLoading("");
|
if (isMountedRef.current) {
|
||||||
|
setDeleteLoading("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [blockedEmailManager, loadBlockedEmails]);
|
||||||
|
|
||||||
// Styles
|
// Breadcrumb items
|
||||||
const input_style =
|
const breadcrumbItems = [
|
||||||
"w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-colors";
|
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
|
||||||
const btn_primary =
|
{ label: "Blocked Users", isActive: true, icon: NoSymbolIcon },
|
||||||
"flex items-center justify-center px-4 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed";
|
];
|
||||||
const btn_secondary =
|
|
||||||
"flex items-center justify-center px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors font-medium";
|
|
||||||
const btn_danger =
|
|
||||||
"flex items-center justify-center px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors font-medium disabled:opacity-50";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<Layout>
|
||||||
<Navigation />
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Breadcrumb Navigation */}
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
{/* Main Card */}
|
||||||
{/* Back button */}
|
<Card>
|
||||||
<button
|
{/* Header */}
|
||||||
onClick={() => navigate("/me")}
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
className={`${btn_secondary} mb-6`}
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
>
|
<div className="flex-1 min-w-0">
|
||||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
<div className="flex items-start">
|
||||||
Back to Profile
|
<div className="p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 bg-red-500">
|
||||||
</button>
|
<NoSymbolIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
|
</div>
|
||||||
{/* Header */}
|
<div>
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6">
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
<div className="flex items-center">
|
Blocked Users
|
||||||
<div className="flex items-center justify-center h-12 w-12 bg-red-100 rounded-xl mr-4">
|
</h1>
|
||||||
<NoSymbolIcon className="h-6 w-6 text-red-600" />
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
</div>
|
Manage users who cannot share folders with you
|
||||||
<div>
|
</p>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Blocked Users</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Manage users who cannot share folders with you
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Success message */}
|
|
||||||
{success && (
|
|
||||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg flex items-center">
|
|
||||||
<CheckCircleIcon className="h-5 w-5 text-green-600 mr-3" />
|
|
||||||
<p className="text-green-700">{success}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center">
|
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 mr-3" />
|
|
||||||
<p className="text-red-700">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add Email Form */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
Block a User
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Enter the email address of a user you want to block. They will not
|
|
||||||
be able to share folders with you.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleAddEmail} className="flex gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={newEmail}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNewEmail(e.target.value);
|
|
||||||
if (addError) setAddError("");
|
|
||||||
}}
|
|
||||||
placeholder="Enter email address to block"
|
|
||||||
className={input_style}
|
|
||||||
disabled={addLoading}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{addError && (
|
|
||||||
<p className="mt-2 text-sm text-red-600">{addError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className={btn_primary}
|
|
||||||
disabled={addLoading || !newEmail.trim()}
|
|
||||||
>
|
|
||||||
{addLoading ? (
|
|
||||||
"Adding..."
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PlusIcon className="h-4 w-4 mr-2" />
|
|
||||||
Block
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Blocked Emails List */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
Blocked Users ({blockedEmails.length})
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-500">Loading blocked users...</p>
|
|
||||||
</div>
|
|
||||||
) : blockedEmails.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<NoSymbolIcon className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
|
||||||
<p className="text-gray-500">No blocked users</p>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
Users you block won't be able to share folders with you.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{blockedEmails.map((blocked) => (
|
|
||||||
<div
|
|
||||||
key={blocked.blocked_email}
|
|
||||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">
|
|
||||||
{blocked.blocked_email}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Blocked on{" "}
|
|
||||||
{new Date(blocked.created_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveEmail(blocked.blocked_email)}
|
|
||||||
className={btn_danger}
|
|
||||||
disabled={deleteLoading === blocked.blocked_email}
|
|
||||||
>
|
|
||||||
{deleteLoading === blocked.blocked_email ? (
|
|
||||||
"Removing..."
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TrashIcon className="h-4 w-4 mr-1" />
|
|
||||||
Unblock
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Back to Settings</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info section */}
|
{/* Messages */}
|
||||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
<div className="px-6 pt-6">
|
||||||
<h3 className="text-sm font-medium text-blue-900 mb-2">
|
{success && (
|
||||||
How blocking works
|
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
|
||||||
</h3>
|
{success}
|
||||||
<ul className="text-sm text-blue-700 space-y-1">
|
</Alert>
|
||||||
<li>
|
)}
|
||||||
- Blocked users cannot share folders or files with you
|
{error && (
|
||||||
</li>
|
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||||
<li>
|
{error}
|
||||||
- You can still share folders with blocked users
|
</Alert>
|
||||||
</li>
|
)}
|
||||||
<li>
|
</div>
|
||||||
- Blocking is private - users are not notified when blocked
|
|
||||||
</li>
|
{/* Content */}
|
||||||
<li>
|
<div className="px-6 pb-6 pt-2">
|
||||||
- Existing shares are not affected when you block someone
|
{/* Add Email Form */}
|
||||||
</li>
|
<Card className={`border ${getThemeClasses("border-muted")} p-6 mb-6`}>
|
||||||
</ul>
|
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
</div>
|
Block a User
|
||||||
|
</h2>
|
||||||
|
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}>
|
||||||
|
Enter the email address of a user you want to block. They will not
|
||||||
|
be able to share folders with you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddEmail} className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="block_email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(value) => {
|
||||||
|
setNewEmail(value);
|
||||||
|
if (addError) setAddError("");
|
||||||
|
}}
|
||||||
|
placeholder="Enter email address to block"
|
||||||
|
disabled={addLoading}
|
||||||
|
required
|
||||||
|
error={addError}
|
||||||
|
icon={EnvelopeIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={addLoading || !newEmail.trim()}
|
||||||
|
loading={addLoading}
|
||||||
|
>
|
||||||
|
{!addLoading && (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
<span>Block</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{addLoading && "Adding..."}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Blocked Emails List */}
|
||||||
|
<Card className={`border ${getThemeClasses("border-muted")} p-6`}>
|
||||||
|
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4`}>
|
||||||
|
Blocked Users ({blockedEmails.length})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
|
<p className={getThemeClasses("text-secondary")}>Loading blocked users...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : blockedEmails.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<NoSymbolIcon className={`h-12 w-12 ${getThemeClasses("text-secondary")} mx-auto mb-4`} />
|
||||||
|
<p className={getThemeClasses("text-secondary")}>No blocked users</p>
|
||||||
|
<p className={`text-sm ${getThemeClasses("text-secondary")} mt-1`}>
|
||||||
|
Users you block won't be able to share folders with you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{blockedEmails.map((blocked) => (
|
||||||
|
<div
|
||||||
|
key={blocked.blocked_email}
|
||||||
|
className={`flex items-center justify-between p-4 ${getThemeClasses("bg-muted")} rounded-lg border ${getThemeClasses("border-muted")}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<EnvelopeIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} mr-3`} />
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${getThemeClasses("text-primary")}`}>
|
||||||
|
{blocked.blocked_email}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Blocked on{" "}
|
||||||
|
{new Date(blocked.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRemoveEmail(blocked.blocked_email)}
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={deleteLoading === blocked.blocked_email}
|
||||||
|
loading={deleteLoading === blocked.blocked_email}
|
||||||
|
>
|
||||||
|
{deleteLoading !== blocked.blocked_email && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
<span>Unblock</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deleteLoading === blocked.blocked_email && "Removing..."}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Info section */}
|
||||||
|
<Alert type="info" className="mt-6">
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
|
How blocking works
|
||||||
|
</h3>
|
||||||
|
<ul className={`text-sm ${getThemeClasses("text-secondary")} space-y-1`}>
|
||||||
|
<li>• Blocked users cannot share folders or files with you</li>
|
||||||
|
<li>• You can still share folders with blocked users</li>
|
||||||
|
<li>• Blocking is private - users are not notified when blocked</li>
|
||||||
|
<li>• Existing shares are not affected when you block someone</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
{/* CSS Animations */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fade-in-up 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
// File: src/pages/User/Me/DeleteAccount.jsx
|
// File: src/pages/User/Me/DeleteAccount.jsx
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useAuth } from "../../../services/Services";
|
import { useAuth } from "../../../services/Services";
|
||||||
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Navigation from "../../../components/Navigation";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
Spinner,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ShieldExclamationIcon,
|
ShieldExclamationIcon,
|
||||||
|
|
@ -13,11 +22,14 @@ import {
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
LockClosedIcon,
|
LockClosedIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const DeleteAccount = () => {
|
const DeleteAccount = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { authManager, meManager } = useAuth();
|
const { authManager, meManager } = useAuth();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
@ -32,14 +44,26 @@ const DeleteAccount = () => {
|
||||||
gdprRights: false,
|
gdprRights: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load user info
|
// Load user info
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadUserInfo = async () => {
|
const loadUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const user = await meManager.getCurrentUser();
|
const user = await meManager.getCurrentUser();
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setUserEmail(user.email);
|
setUserEmail(user.email);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load user info:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("Failed to load user info:", err);
|
||||||
|
}
|
||||||
setError("Failed to load your account information.");
|
setError("Failed to load your account information.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -64,23 +88,25 @@ const DeleteAccount = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle back button
|
// Handle back button
|
||||||
const handleBack = () => {
|
const handleBack = useCallback(() => {
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
navigate("/me");
|
navigate("/me");
|
||||||
} else {
|
} else {
|
||||||
setCurrentStep(currentStep - 1);
|
setCurrentStep(currentStep - 1);
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
};
|
}, [currentStep, navigate]);
|
||||||
|
|
||||||
// Handle next step
|
// Handle next step
|
||||||
const handleNext = () => {
|
const handleNext = useCallback(() => {
|
||||||
setError("");
|
setError("");
|
||||||
setCurrentStep(currentStep + 1);
|
setCurrentStep(currentStep + 1);
|
||||||
};
|
}, [currentStep]);
|
||||||
|
|
||||||
// Handle account deletion
|
// Handle account deletion
|
||||||
const handleDeleteAccount = async () => {
|
const handleDeleteAccount = useCallback(async () => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
setError("Please enter your password to confirm deletion.");
|
setError("Please enter your password to confirm deletion.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -103,16 +129,23 @@ const DeleteAccount = () => {
|
||||||
// Call the delete API
|
// Call the delete API
|
||||||
await meManager.deleteCurrentUser(password);
|
await meManager.deleteCurrentUser(password);
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
// Show success message and logout
|
// Show success message and logout
|
||||||
setCurrentStep(4); // Success step
|
setCurrentStep(4); // Success step
|
||||||
|
|
||||||
// Wait 3 seconds then logout and redirect
|
// Wait 3 seconds then logout and redirect
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
authManager.logout();
|
authManager.logout();
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Account deletion failed:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("Account deletion failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle specific error cases
|
// Handle specific error cases
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
|
|
@ -125,118 +158,116 @@ const DeleteAccount = () => {
|
||||||
setError("Failed to delete account. Please try again or contact support.");
|
setError("Failed to delete account. Please try again or contact support.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [password, allAcknowledged, confirmationMatches, meManager, authManager, navigate]);
|
||||||
|
|
||||||
// Render Step 1: Warning and Information
|
// Render Step 1: Warning and Information
|
||||||
const renderStep1 = () => (
|
const renderStep1 = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Warning Banner */}
|
{/* Warning Banner */}
|
||||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
<Alert type="error">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-500 mr-3 flex-shrink-0 mt-0.5" />
|
<ExclamationTriangleIcon className={`h-6 w-6 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-red-800 mb-2">
|
<h3 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
This action is permanent and cannot be undone
|
This action is permanent and cannot be undone
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-red-700">
|
<p className={getThemeClasses("text-secondary")}>
|
||||||
Once you delete your account, all your data will be permanently
|
Once you delete your account, all your data will be permanently
|
||||||
removed from our servers. This includes all files, collections,
|
removed from our servers. This includes all files, collections,
|
||||||
and personal information.
|
and personal information.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* What will be deleted */}
|
{/* What will be deleted */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<Card className={`border ${getThemeClasses("border-muted")} p-6`}>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
<h3 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center`}>
|
||||||
<TrashIcon className="h-5 w-5 mr-2 text-gray-500" />
|
<TrashIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||||
What will be deleted
|
What will be deleted
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" />
|
<XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div>
|
<div>
|
||||||
<strong className="text-gray-900">All your files</strong>
|
<strong className={getThemeClasses("text-primary")}>All your files</strong>
|
||||||
<p className="text-sm text-gray-600">
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
Every file you've uploaded will be permanently deleted from our
|
Every file you've uploaded will be permanently deleted from our
|
||||||
servers
|
servers
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" />
|
<XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div>
|
<div>
|
||||||
<strong className="text-gray-900">All your collections</strong>
|
<strong className={getThemeClasses("text-primary")}>All your collections</strong>
|
||||||
<p className="text-sm text-gray-600">
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
All folders and collections you own will be permanently removed
|
All folders and collections you own will be permanently removed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" />
|
<XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div>
|
<div>
|
||||||
<strong className="text-gray-900">Personal information</strong>
|
<strong className={getThemeClasses("text-primary")}>Personal information</strong>
|
||||||
<p className="text-sm text-gray-600">
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
Your profile, email, and all associated data will be deleted
|
Your profile, email, and all associated data will be deleted
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" />
|
<XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div>
|
<div>
|
||||||
<strong className="text-gray-900">Shared access</strong>
|
<strong className={getThemeClasses("text-primary")}>Shared access</strong>
|
||||||
<p className="text-sm text-gray-600">
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
You'll be removed from any collections shared with you
|
You'll be removed from any collections shared with you
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" />
|
<XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div>
|
<div>
|
||||||
<strong className="text-gray-900">Storage usage history</strong>
|
<strong className={getThemeClasses("text-primary")}>Storage usage history</strong>
|
||||||
<p className="text-sm text-gray-600">
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
All your storage metrics and usage history will be deleted
|
All your storage metrics and usage history will be deleted
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* GDPR Information */}
|
{/* GDPR Information */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<Alert type="info">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<InformationCircleIcon className="h-5 w-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5" />
|
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-blue-900 mb-1">
|
<h4 className={`text-sm font-semibold ${getThemeClasses("text-primary")} mb-1`}>
|
||||||
Your Right to Erasure (GDPR Article 17)
|
Your Right to Erasure (GDPR Article 17)
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-blue-800">
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
This deletion process complies with GDPR regulations. All your
|
This deletion process complies with GDPR regulations. All your
|
||||||
personal data will be permanently erased from our systems within
|
personal data will be permanently erased from our systems within
|
||||||
moments of confirmation. This action cannot be reversed.
|
moments of confirmation. This action cannot be reversed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-between pt-4">
|
<div className="flex justify-between pt-4">
|
||||||
<button
|
<Button onClick={handleBack} variant="secondary">
|
||||||
onClick={handleBack}
|
<span className="inline-flex items-center gap-2">
|
||||||
className="px-6 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center"
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
>
|
<span>Cancel</span>
|
||||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
</span>
|
||||||
Cancel
|
</Button>
|
||||||
</button>
|
<Button onClick={handleNext} variant="danger">
|
||||||
<button
|
|
||||||
onClick={handleNext}
|
|
||||||
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
Continue to Confirmation
|
Continue to Confirmation
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -244,36 +275,36 @@ const DeleteAccount = () => {
|
||||||
// Render Step 2: Acknowledgments
|
// Render Step 2: Acknowledgments
|
||||||
const renderStep2 = () => (
|
const renderStep2 = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-yellow-50 border-l-4 border-yellow-500 p-4 rounded">
|
<Alert type="warning">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ShieldExclamationIcon className="h-6 w-6 text-yellow-600 mr-3 flex-shrink-0 mt-0.5" />
|
<ShieldExclamationIcon className={`h-6 w-6 ${getThemeClasses("text-warning")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
<h3 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Please confirm you understand
|
Please confirm you understand
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-yellow-700">
|
<p className={getThemeClasses("text-secondary")}>
|
||||||
Before proceeding, you must acknowledge the following statements
|
Before proceeding, you must acknowledge the following statements
|
||||||
about your account deletion.
|
about your account deletion.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* Acknowledgment Checkboxes */}
|
{/* Acknowledgment Checkboxes */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-4">
|
<Card className={`border ${getThemeClasses("border-muted")} p-6 space-y-4`}>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="ack-permanent"
|
id="ack-permanent"
|
||||||
checked={acknowledgments.permanentDeletion}
|
checked={acknowledgments.permanentDeletion}
|
||||||
onChange={() => handleAcknowledgmentChange("permanentDeletion")}
|
onChange={() => handleAcknowledgmentChange("permanentDeletion")}
|
||||||
className="mt-1 h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
|
className={`mt-1 h-4 w-4 ${getThemeClasses("text-accent")} focus:ring-2 ${getThemeClasses("border-muted")} rounded`}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="ack-permanent"
|
htmlFor="ack-permanent"
|
||||||
className="ml-3 text-sm text-gray-700 cursor-pointer"
|
className={`ml-3 text-sm ${getThemeClasses("text-secondary")} cursor-pointer`}
|
||||||
>
|
>
|
||||||
<strong className="text-gray-900">
|
<strong className={getThemeClasses("text-primary")}>
|
||||||
I understand that this deletion is permanent and irreversible.
|
I understand that this deletion is permanent and irreversible.
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
Once deleted, my account and all associated data cannot be
|
Once deleted, my account and all associated data cannot be
|
||||||
|
|
@ -287,13 +318,13 @@ const DeleteAccount = () => {
|
||||||
id="ack-data"
|
id="ack-data"
|
||||||
checked={acknowledgments.dataLoss}
|
checked={acknowledgments.dataLoss}
|
||||||
onChange={() => handleAcknowledgmentChange("dataLoss")}
|
onChange={() => handleAcknowledgmentChange("dataLoss")}
|
||||||
className="mt-1 h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
|
className={`mt-1 h-4 w-4 ${getThemeClasses("text-accent")} focus:ring-2 ${getThemeClasses("border-muted")} rounded`}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="ack-data"
|
htmlFor="ack-data"
|
||||||
className="ml-3 text-sm text-gray-700 cursor-pointer"
|
className={`ml-3 text-sm ${getThemeClasses("text-secondary")} cursor-pointer`}
|
||||||
>
|
>
|
||||||
<strong className="text-gray-900">
|
<strong className={getThemeClasses("text-primary")}>
|
||||||
I understand that all my files and collections will be permanently
|
I understand that all my files and collections will be permanently
|
||||||
deleted.
|
deleted.
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
|
|
@ -307,49 +338,44 @@ const DeleteAccount = () => {
|
||||||
id="ack-gdpr"
|
id="ack-gdpr"
|
||||||
checked={acknowledgments.gdprRights}
|
checked={acknowledgments.gdprRights}
|
||||||
onChange={() => handleAcknowledgmentChange("gdprRights")}
|
onChange={() => handleAcknowledgmentChange("gdprRights")}
|
||||||
className="mt-1 h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
|
className={`mt-1 h-4 w-4 ${getThemeClasses("text-accent")} focus:ring-2 ${getThemeClasses("border-muted")} rounded`}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="ack-gdpr"
|
htmlFor="ack-gdpr"
|
||||||
className="ml-3 text-sm text-gray-700 cursor-pointer"
|
className={`ml-3 text-sm ${getThemeClasses("text-secondary")} cursor-pointer`}
|
||||||
>
|
>
|
||||||
<strong className="text-gray-900">
|
<strong className={getThemeClasses("text-primary")}>
|
||||||
I am exercising my right to erasure under GDPR Article 17.
|
I am exercising my right to erasure under GDPR Article 17.
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
I understand that this will result in the immediate and complete
|
I understand that this will result in the immediate and complete
|
||||||
deletion of all my personal data from MapleFile servers.
|
deletion of all my personal data from MapleFile servers.
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Current Account */}
|
{/* Current Account */}
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
<div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border-muted")} rounded-lg p-4`}>
|
||||||
<p className="text-sm text-gray-600">
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
Account to be deleted:{" "}
|
Account to be deleted:{" "}
|
||||||
<strong className="text-gray-900">{userEmail}</strong>
|
<strong className={getThemeClasses("text-primary")}>{userEmail}</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-between pt-4">
|
<div className="flex justify-between pt-4">
|
||||||
<button
|
<Button onClick={handleBack} variant="secondary">
|
||||||
onClick={handleBack}
|
<span className="inline-flex items-center gap-2">
|
||||||
className="px-6 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center"
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
>
|
<span>Back</span>
|
||||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
</span>
|
||||||
Back
|
</Button>
|
||||||
</button>
|
<Button
|
||||||
<button
|
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={!allAcknowledged}
|
disabled={!allAcknowledged}
|
||||||
className={`px-6 py-2 rounded-lg transition-colors ${
|
variant="danger"
|
||||||
allAcknowledged
|
|
||||||
? "bg-red-600 text-white hover:bg-red-700"
|
|
||||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Continue to Final Step
|
Continue to Final Step
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -357,135 +383,89 @@ const DeleteAccount = () => {
|
||||||
// Render Step 3: Final Confirmation
|
// Render Step 3: Final Confirmation
|
||||||
const renderStep3 = () => (
|
const renderStep3 = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-red-100 border-2 border-red-500 p-4 rounded-lg">
|
<Alert type="error">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ExclamationTriangleIcon className="h-8 w-8 text-red-600 mr-3 flex-shrink-0" />
|
<ExclamationTriangleIcon className={`h-8 w-8 ${getThemeClasses("text-error")} mr-3 flex-shrink-0`} />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-red-900 mb-2">
|
<h3 className={`text-xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Final Confirmation Required
|
Final Confirmation Required
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-red-800">
|
<p className={getThemeClasses("text-secondary")}>
|
||||||
This is your last chance to cancel. After clicking "Delete My
|
This is your last chance to cancel. After clicking "Delete My
|
||||||
Account", your data will be permanently erased.
|
Account", your data will be permanently erased.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* Password Confirmation */}
|
{/* Password Confirmation */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-4">
|
<Card className={`border ${getThemeClasses("border-muted")} p-6 space-y-4`}>
|
||||||
<div>
|
<Input
|
||||||
<label
|
label="Enter your password to confirm"
|
||||||
htmlFor="password"
|
type="password"
|
||||||
className="block text-sm font-medium text-gray-700 mb-2 flex items-center"
|
name="password"
|
||||||
>
|
value={password}
|
||||||
<LockClosedIcon className="h-4 w-4 mr-2" />
|
onChange={(value) => {
|
||||||
Enter your password to confirm
|
setPassword(value);
|
||||||
</label>
|
setError("");
|
||||||
<input
|
}}
|
||||||
type="password"
|
placeholder="Your account password"
|
||||||
id="password"
|
disabled={loading}
|
||||||
value={password}
|
icon={LockClosedIcon}
|
||||||
onChange={(e) => {
|
/>
|
||||||
setPassword(e.target.value);
|
|
||||||
setError("");
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
|
||||||
placeholder="Your account password"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="confirm-text"
|
htmlFor="confirm-text"
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}
|
||||||
>
|
>
|
||||||
Type <strong>"DELETE MY ACCOUNT"</strong> to confirm (exactly as
|
Type <strong>"DELETE MY ACCOUNT"</strong> to confirm (exactly as
|
||||||
shown)
|
shown)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="confirm-text"
|
name="confirm-text"
|
||||||
value={confirmText}
|
value={confirmText}
|
||||||
onChange={(e) => {
|
onChange={(value) => {
|
||||||
setConfirmText(e.target.value);
|
setConfirmText(value);
|
||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 font-mono"
|
|
||||||
placeholder="DELETE MY ACCOUNT"
|
placeholder="DELETE MY ACCOUNT"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
error={confirmText && !confirmationMatches ? 'Text must match exactly: "DELETE MY ACCOUNT"' : ""}
|
||||||
/>
|
/>
|
||||||
{confirmText && !confirmationMatches && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
Text must match exactly: "DELETE MY ACCOUNT"
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
<Alert type="error" onClose={() => setError("")}>
|
||||||
<div className="flex items-start">
|
{error}
|
||||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" />
|
</Alert>
|
||||||
<p className="text-sm text-red-800">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-between pt-4">
|
<div className="flex justify-between pt-4">
|
||||||
<button
|
<Button onClick={handleBack} variant="secondary" disabled={loading}>
|
||||||
onClick={handleBack}
|
<span className="inline-flex items-center gap-2">
|
||||||
disabled={loading}
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
className="px-6 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
|
<span>Back</span>
|
||||||
>
|
</span>
|
||||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
</Button>
|
||||||
Back
|
<Button
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDeleteAccount}
|
onClick={handleDeleteAccount}
|
||||||
disabled={
|
disabled={loading || !password || !confirmationMatches || !allAcknowledged}
|
||||||
loading || !password || !confirmationMatches || !allAcknowledged
|
variant="danger"
|
||||||
}
|
loading={loading}
|
||||||
className={`px-6 py-2 rounded-lg transition-colors flex items-center ${
|
|
||||||
loading || !password || !confirmationMatches || !allAcknowledged
|
|
||||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
|
||||||
: "bg-red-600 text-white hover:bg-red-700"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{loading ? (
|
{!loading && (
|
||||||
<>
|
<span className="inline-flex items-center gap-2">
|
||||||
<svg
|
<TrashIcon className="h-4 w-4" />
|
||||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
<span>Delete My Account</span>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</span>
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Deleting Account...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TrashIcon className="h-4 w-4 mr-2" />
|
|
||||||
Delete My Account
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
{loading && "Deleting Account..."}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -493,33 +473,33 @@ const DeleteAccount = () => {
|
||||||
// Render Step 4: Success
|
// Render Step 4: Success
|
||||||
const renderStep4 = () => (
|
const renderStep4 = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-green-50 border-2 border-green-500 p-8 rounded-lg text-center">
|
<div className={`${getThemeClasses("bg-success-light")} border-2 ${getThemeClasses("border-success")} p-8 rounded-lg text-center`}>
|
||||||
<CheckCircleIcon className="h-16 w-16 text-green-600 mx-auto mb-4" />
|
<CheckCircleIcon className={`h-16 w-16 ${getThemeClasses("text-success")} mx-auto mb-4`} />
|
||||||
<h3 className="text-2xl font-bold text-green-900 mb-2">
|
<h3 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Account Deleted Successfully
|
Account Deleted Successfully
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-green-800 mb-4">
|
<p className={`${getThemeClasses("text-secondary")} mb-4`}>
|
||||||
Your account and all associated data have been permanently deleted
|
Your account and all associated data have been permanently deleted
|
||||||
from our servers.
|
from our servers.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-green-700">
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
You will be logged out and redirected to the home page in a few
|
You will be logged out and redirected to the home page in a few
|
||||||
seconds...
|
seconds...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<Alert type="info">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<InformationCircleIcon className="h-5 w-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5" />
|
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")} mr-3 flex-shrink-0 mt-0.5`} />
|
||||||
<div className="text-sm text-blue-800">
|
<div>
|
||||||
<strong className="text-blue-900">Thank you for using MapleFile.</strong>
|
<strong className={getThemeClasses("text-primary")}>Thank you for using MapleFile.</strong>
|
||||||
<p className="mt-1">
|
<p className={`mt-1 ${getThemeClasses("text-secondary")}`}>
|
||||||
If you have any feedback or concerns, please contact our support
|
If you have any feedback or concerns, please contact our support
|
||||||
team. You're always welcome to create a new account in the future.
|
team. You're always welcome to create a new account in the future.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -544,8 +524,8 @@ const DeleteAccount = () => {
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors ${
|
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors ${
|
||||||
currentStep >= step.number
|
currentStep >= step.number
|
||||||
? "border-red-600 bg-red-600 text-white"
|
? `${getThemeClasses("border-error")} ${getThemeClasses("bg-error")} text-white`
|
||||||
: "border-gray-300 bg-white text-gray-400"
|
: `${getThemeClasses("border-muted")} ${getThemeClasses("bg-card")} ${getThemeClasses("text-secondary")}`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{currentStep > step.number ? (
|
{currentStep > step.number ? (
|
||||||
|
|
@ -558,8 +538,8 @@ const DeleteAccount = () => {
|
||||||
<span
|
<span
|
||||||
className={`mt-2 text-xs whitespace-nowrap ${
|
className={`mt-2 text-xs whitespace-nowrap ${
|
||||||
currentStep >= step.number
|
currentStep >= step.number
|
||||||
? "font-semibold text-red-600"
|
? `font-semibold ${getThemeClasses("text-error")}`
|
||||||
: "text-gray-600"
|
: getThemeClasses("text-secondary")
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step.label}
|
{step.label}
|
||||||
|
|
@ -570,7 +550,7 @@ const DeleteAccount = () => {
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 h-1 mx-4 mb-6 transition-colors ${
|
className={`flex-1 h-1 mx-4 mb-6 transition-colors ${
|
||||||
currentStep > step.number ? "bg-red-600" : "bg-gray-300"
|
currentStep > step.number ? getThemeClasses("bg-error") : getThemeClasses("bg-muted")
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -581,40 +561,74 @@ const DeleteAccount = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Breadcrumb items
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
|
||||||
|
{ label: "Delete Account", isActive: true },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<Layout>
|
||||||
<Navigation />
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
{/* Breadcrumb Navigation */}
|
||||||
{/* Header */}
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2 flex items-center">
|
|
||||||
<TrashIcon className="h-8 w-8 mr-3 text-red-600" />
|
|
||||||
Delete Account
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Permanently delete your MapleFile account and all associated data
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Indicator */}
|
{/* Main Card */}
|
||||||
{renderProgressIndicator()}
|
<Card>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-error")}`}>
|
||||||
|
<TrashIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
|
Delete Account
|
||||||
|
</h1>
|
||||||
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Permanently delete your MapleFile account and all associated data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Back to Settings</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Content */}
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
<div className="p-6">
|
||||||
{currentStep === 1 && renderStep1()}
|
{/* Progress Indicator */}
|
||||||
{currentStep === 2 && renderStep2()}
|
{renderProgressIndicator()}
|
||||||
{currentStep === 3 && renderStep3()}
|
|
||||||
{currentStep === 4 && renderStep4()}
|
{/* Step Content */}
|
||||||
</div>
|
{currentStep === 1 && renderStep1()}
|
||||||
|
{currentStep === 2 && renderStep2()}
|
||||||
|
{currentStep === 3 && renderStep3()}
|
||||||
|
{currentStep === 4 && renderStep4()}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Footer Notice */}
|
{/* Footer Notice */}
|
||||||
{currentStep !== 4 && (
|
{currentStep !== 4 && (
|
||||||
<div className="mt-6 text-center text-sm text-gray-500">
|
<div className={`mt-6 text-center text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
<p>
|
<p>
|
||||||
Need help? Contact our support team at{" "}
|
Need help? Contact our support team at{" "}
|
||||||
<a
|
<a
|
||||||
href="mailto:support@maplefile.com"
|
href="mailto:support@maplefile.com"
|
||||||
className="text-blue-600 hover:text-blue-700"
|
className={getThemeClasses("link-primary")}
|
||||||
>
|
>
|
||||||
support@maplefile.com
|
support@maplefile.com
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -622,7 +636,7 @@ const DeleteAccount = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,34 @@ const EMPTY_ENTITY_DATA = {};
|
||||||
const ExportData = () => {
|
const ExportData = () => {
|
||||||
const { getThemeClasses } = useUIXTheme();
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
|
||||||
|
// Theme-aware section styles
|
||||||
|
const sectionStyles = useMemo(() => ({
|
||||||
|
success: {
|
||||||
|
container: getThemeClasses("export-section-success-bg"),
|
||||||
|
border: getThemeClasses("export-section-success-border"),
|
||||||
|
icon: getThemeClasses("export-section-success-icon"),
|
||||||
|
title: getThemeClasses("export-section-success-title"),
|
||||||
|
text: getThemeClasses("export-section-success-text"),
|
||||||
|
muted: getThemeClasses("export-section-success-muted"),
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
container: getThemeClasses("export-section-info-bg"),
|
||||||
|
border: getThemeClasses("export-section-info-border"),
|
||||||
|
icon: getThemeClasses("export-section-info-icon"),
|
||||||
|
title: getThemeClasses("export-section-info-title"),
|
||||||
|
text: getThemeClasses("export-section-info-text"),
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
container: getThemeClasses("export-section-warning-bg"),
|
||||||
|
border: getThemeClasses("export-section-warning-border"),
|
||||||
|
icon: getThemeClasses("export-section-warning-icon"),
|
||||||
|
title: getThemeClasses("export-section-warning-title"),
|
||||||
|
text: getThemeClasses("export-section-warning-text"),
|
||||||
|
muted: getThemeClasses("export-section-warning-muted"),
|
||||||
|
code: getThemeClasses("export-section-warning-code"),
|
||||||
|
},
|
||||||
|
}), [getThemeClasses]);
|
||||||
|
|
||||||
// Breadcrumb configuration
|
// Breadcrumb configuration
|
||||||
const breadcrumbItems = useMemo(
|
const breadcrumbItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|
@ -55,105 +83,116 @@ const ExportData = () => {
|
||||||
// Content sections
|
// Content sections
|
||||||
const contentSections = useMemo(() => {
|
const contentSections = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6" role="main" aria-label="Export data options">
|
||||||
{/* Quick Export Option - GDPR Compliance */}
|
{/* Quick Export Option - GDPR Compliance */}
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-xl p-6">
|
<section
|
||||||
|
aria-labelledby="quick-export-heading"
|
||||||
|
className={`${sectionStyles.success.container} border-2 ${sectionStyles.success.border} rounded-xl p-6`}
|
||||||
|
>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ArrowDownTrayIcon className="h-6 w-6 text-green-600 dark:text-green-400 mr-3 flex-shrink-0 mt-0.5" />
|
<ArrowDownTrayIcon className={`h-6 w-6 ${sectionStyles.success.icon} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-green-900 dark:text-green-100 mb-2">
|
<h3 id="quick-export-heading" className={`text-lg font-semibold ${sectionStyles.success.title} mb-2`}>
|
||||||
Quick Export (Web Browser)
|
Quick Export (Web Browser)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-green-800 dark:text-green-200 mb-3">
|
<p className={`${sectionStyles.success.text} mb-3`}>
|
||||||
In compliance with GDPR Article 20, you can export your data directly from your web browser. This option is suitable for accounts with moderate amounts of data.
|
In compliance with GDPR Article 20, you can export your data directly from your web browser. This option is suitable for accounts with moderate amounts of data.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-green-800 dark:text-green-200 mb-4">
|
<p className={`${sectionStyles.success.text} mb-4`}>
|
||||||
<strong>What gets exported:</strong> Profile information, collection metadata, and file lists (metadata only - actual file downloads require desktop app due to E2EE).
|
<strong>What gets exported:</strong> Profile information, collection metadata, and file lists (metadata only - actual file downloads require desktop app due to E2EE).
|
||||||
</p>
|
</p>
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="flex items-center"
|
icon={ArrowDownTrayIcon}
|
||||||
onClick={() => alert('This feature will be implemented by the backend team. It should generate a JSON export of user profile, collections, and file metadata.')}
|
onClick={() => alert('This feature will be implemented by the backend team. It should generate a JSON export of user profile, collections, and file metadata.')}
|
||||||
>
|
>
|
||||||
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
|
|
||||||
Export Metadata (JSON)
|
Export Metadata (JSON)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-green-700 dark:text-green-300 mt-3">
|
<p className={`text-xs ${sectionStyles.success.muted} mt-3`}>
|
||||||
<strong>Timeline:</strong> Your export will be available for download within 24 hours. Large exports may take up to 72 hours. You'll receive an email when ready.
|
<strong>Timeline:</strong> Your export will be available for download within 24 hours. Large exports may take up to 72 hours. You'll receive an email when ready.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Information Notice */}
|
{/* Information Notice */}
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-800 rounded-xl p-6">
|
<section
|
||||||
|
aria-labelledby="desktop-info-heading"
|
||||||
|
className={`${sectionStyles.info.container} border-2 ${sectionStyles.info.border} rounded-xl p-6`}
|
||||||
|
>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<InformationCircleIcon className="h-6 w-6 text-blue-600 dark:text-blue-400 mr-3 flex-shrink-0 mt-0.5" />
|
<InformationCircleIcon className={`h-6 w-6 ${sectionStyles.info.icon} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
<h3 id="desktop-info-heading" className={`text-lg font-semibold ${sectionStyles.info.title} mb-2`}>
|
||||||
Desktop Application for Complete Export
|
Desktop Application for Complete Export
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-blue-800 dark:text-blue-200 mb-3">
|
<p className={`${sectionStyles.info.text} mb-3`}>
|
||||||
For exporting <strong>complete data including decrypted files</strong>, we recommend using the <strong>MapleFile Desktop Application</strong>. Due to end-to-end encryption, only the desktop app can decrypt and export your actual file contents.
|
For exporting <strong>complete data including decrypted files</strong>, we recommend using the <strong>MapleFile Desktop Application</strong>. Due to end-to-end encryption, only the desktop app can decrypt and export your actual file contents.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-blue-800 dark:text-blue-200">
|
<p className={sectionStyles.info.text}>
|
||||||
The desktop application efficiently manages the decryption and download of your complete data archive with all files in their original format.
|
The desktop application efficiently manages the decryption and download of your complete data archive with all files in their original format.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* E2EE Explanation */}
|
{/* E2EE Explanation */}
|
||||||
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}>
|
<section
|
||||||
|
aria-labelledby="e2ee-heading"
|
||||||
|
className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border-secondary")} p-6`}
|
||||||
|
>
|
||||||
<div className="flex items-start mb-4">
|
<div className="flex items-start mb-4">
|
||||||
<ShieldCheckIcon className="h-6 w-6 text-green-600 dark:text-green-400 mr-3 flex-shrink-0 mt-0.5" />
|
<ShieldCheckIcon className={`h-6 w-6 ${getThemeClasses("icon-success")} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
<h3 id="e2ee-heading" className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
Why End-to-End Encryption Matters
|
Why End-to-End Encryption Matters
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-3">
|
<p className={`${getThemeClasses("text-secondary")} mb-3`}>
|
||||||
Your files are encrypted on your device before they're uploaded to our servers. This means:
|
Your files are encrypted on your device before they're uploaded to our servers. This means:
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2 text-gray-700 dark:text-gray-300">
|
<ul className={`space-y-2 ${getThemeClasses("text-secondary")}`} role="list" aria-label="Encryption benefits">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 dark:text-green-400 mr-2">✓</span>
|
<span className={`${getThemeClasses("icon-success")} mr-2`} aria-hidden="true">✓</span>
|
||||||
<span>Only you can decrypt and read your files</span>
|
<span>Only you can decrypt and read your files</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 dark:text-green-400 mr-2">✓</span>
|
<span className={`${getThemeClasses("icon-success")} mr-2`} aria-hidden="true">✓</span>
|
||||||
<span>MapleFile servers cannot access your file contents</span>
|
<span>MapleFile servers cannot access your file contents</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 dark:text-green-400 mr-2">✓</span>
|
<span className={`${getThemeClasses("icon-success")} mr-2`} aria-hidden="true">✓</span>
|
||||||
<span>Your privacy is protected even if servers are compromised</span>
|
<span>Your privacy is protected even if servers are compromised</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 dark:text-green-400 mr-2">✓</span>
|
<span className={`${getThemeClasses("icon-success")} mr-2`} aria-hidden="true">✓</span>
|
||||||
<span>Decryption requires your local encryption keys</span>
|
<span>Decryption requires your local encryption keys</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Desktop Application Installation */}
|
{/* Desktop Application Installation */}
|
||||||
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}>
|
<section
|
||||||
|
aria-labelledby="install-heading"
|
||||||
|
className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border-secondary")} p-6`}
|
||||||
|
>
|
||||||
<div className="flex items-start mb-4">
|
<div className="flex items-start mb-4">
|
||||||
<ComputerDesktopIcon className={`h-6 w-6 mr-3 flex-shrink-0 mt-0.5 ${getThemeClasses("text-accent")}`} />
|
<ComputerDesktopIcon className={`h-6 w-6 mr-3 flex-shrink-0 mt-0.5 ${getThemeClasses("link-primary")}`} aria-hidden="true" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
<h3 id="install-heading" className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
Install MapleFile Desktop Application
|
Install MapleFile Desktop Application
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
<p className={`${getThemeClasses("text-secondary")} mb-4`}>
|
||||||
The MapleFile Desktop Application is a native application that can export your data with full decryption support and an intuitive user interface.
|
The MapleFile Desktop Application is a native application that can export your data with full decryption support and an intuitive user interface.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Repository Link */}
|
{/* Repository Link */}
|
||||||
<div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border")} rounded-lg p-4 mb-4`}>
|
<div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border-secondary")} rounded-lg p-4 mb-4`}>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<CodeBracketIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-3 mt-0.5" />
|
<CodeBracketIcon className={`h-5 w-5 ${getThemeClasses("text-muted")} mr-3 mt-0.5`} aria-hidden="true" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className={`text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}>
|
<p className={`text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
Source Code & Installation Instructions
|
Source Code & Installation Instructions
|
||||||
|
|
@ -162,7 +201,7 @@ const ExportData = () => {
|
||||||
href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile"
|
href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`${getThemeClasses("text-accent")} hover:underline text-sm font-mono break-all`}
|
className={`${getThemeClasses("link-primary")} hover:underline text-sm font-mono break-all`}
|
||||||
>
|
>
|
||||||
https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile
|
https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -173,145 +212,157 @@ const ExportData = () => {
|
||||||
{/* Installation Steps */}
|
{/* Installation Steps */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className={`font-medium ${getThemeClasses("text-primary")}`}>Installation Steps:</h4>
|
<h4 className={`font-medium ${getThemeClasses("text-primary")}`}>Installation Steps:</h4>
|
||||||
<ol className="space-y-3 text-gray-700 dark:text-gray-300">
|
<ol className={`space-y-3 ${getThemeClasses("text-secondary")}`} role="list" aria-label="Installation steps">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>1.</span>
|
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">1.</span>
|
||||||
<span>Visit the repository link above</span>
|
<span>Visit the repository link above</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>2.</span>
|
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">2.</span>
|
||||||
<span>Follow the installation instructions in the README.md file</span>
|
<span>Follow the installation instructions in the README.md file</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>3.</span>
|
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">3.</span>
|
||||||
<span>Install the desktop application for your operating system (Windows, macOS, or Linux)</span>
|
<span>Install the desktop application for your operating system (Windows, macOS, or Linux)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>4.</span>
|
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">4.</span>
|
||||||
<span>Log in to your MapleFile account using the desktop application</span>
|
<span>Log in to your MapleFile account using the desktop application</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>5.</span>
|
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">5.</span>
|
||||||
<span>Use the export feature to download and decrypt your data</span>
|
<span>Use the export feature to download and decrypt your data</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* What Gets Exported */}
|
{/* What Gets Exported */}
|
||||||
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}>
|
<section
|
||||||
|
aria-labelledby="exported-data-heading"
|
||||||
|
className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border-secondary")} p-6`}
|
||||||
|
>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<DocumentTextIcon className="h-6 w-6 text-gray-600 dark:text-gray-400 mr-3 flex-shrink-0 mt-0.5" />
|
<DocumentTextIcon className={`h-6 w-6 ${getThemeClasses("text-muted")} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
<h3 id="exported-data-heading" className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
||||||
What Data Will Be Exported
|
What Data Will Be Exported
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-3">
|
<p className={`${getThemeClasses("text-secondary")} mb-3`}>
|
||||||
The desktop application will export a complete archive of your MapleFile account, including:
|
The desktop application will export a complete archive of your MapleFile account, including:
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2 text-gray-700 dark:text-gray-300">
|
<ul className={`space-y-2 ${getThemeClasses("text-secondary")}`} role="list" aria-label="Exported data types">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true">•</span>
|
||||||
<span><strong>Profile Information:</strong> Your account details, settings, and preferences</span>
|
<span><strong>Profile Information:</strong> Your account details, settings, and preferences</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true">•</span>
|
||||||
<span><strong>Collections (Folders):</strong> All your collection metadata and structure</span>
|
<span><strong>Collections (Folders):</strong> All your collection metadata and structure</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true">•</span>
|
||||||
<span><strong>Files:</strong> All uploaded files, fully decrypted and in their original format</span>
|
<span><strong>Files:</strong> All uploaded files, fully decrypted and in their original format</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true">•</span>
|
||||||
<span><strong>File Metadata:</strong> Creation dates, modification dates, file sizes, and descriptions</span>
|
<span><strong>File Metadata:</strong> Creation dates, modification dates, file sizes, and descriptions</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true">•</span>
|
||||||
<span><strong>Sharing Information:</strong> Details about collections shared with you or by you</span>
|
<span><strong>Sharing Information:</strong> Details about collections shared with you or by you</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-4">
|
<p className={`${getThemeClasses("text-muted")} text-sm mt-4`}>
|
||||||
The export will be provided as a ZIP archive with a structured folder hierarchy matching your collections.
|
The export will be provided as a ZIP archive with a structured folder hierarchy matching your collections.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* GDPR Information */}
|
{/* GDPR Information */}
|
||||||
<div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border")} rounded-xl p-6`}>
|
<section
|
||||||
<h3 className={`text-sm font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
aria-labelledby="gdpr-heading"
|
||||||
|
className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border-secondary")} rounded-xl p-6`}
|
||||||
|
>
|
||||||
|
<h3 id="gdpr-heading" className={`text-sm font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
GDPR Article 20 - Right to Data Portability
|
GDPR Article 20 - Right to Data Portability
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2">
|
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-2`}>
|
||||||
You have the right to receive the personal data concerning you, which you have provided to us, in a structured, commonly used and machine-readable format. You also have the right to transmit those data to another controller.
|
You have the right to receive the personal data concerning you, which you have provided to us, in a structured, commonly used and machine-readable format. You also have the right to transmit those data to another controller.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className={`text-sm ${getThemeClasses("text-muted")}`}>
|
||||||
The MapleFile Desktop Application ensures compliance with this right by providing your complete data archive in standard formats (JSON metadata, original file formats).
|
The MapleFile Desktop Application ensures compliance with this right by providing your complete data archive in standard formats (JSON metadata, original file formats).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Security Notice */}
|
{/* Security Notice */}
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-200 dark:border-yellow-800 rounded-xl p-6">
|
<section
|
||||||
|
aria-labelledby="security-heading"
|
||||||
|
className={`${sectionStyles.warning.container} border-2 ${sectionStyles.warning.border} rounded-xl p-6`}
|
||||||
|
>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ShieldCheckIcon className="h-6 w-6 text-yellow-600 dark:text-yellow-400 mr-3 flex-shrink-0 mt-0.5" />
|
<ShieldCheckIcon className={`h-6 w-6 ${sectionStyles.warning.icon} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
|
<h3 id="security-heading" className={`text-lg font-semibold ${sectionStyles.warning.title} mb-2`}>
|
||||||
Security: Verify Your Download
|
Security: Verify Your Download
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-yellow-800 dark:text-yellow-200 mb-3">
|
<p className={`${sectionStyles.warning.text} mb-3`}>
|
||||||
For your security, always verify the authenticity of downloaded software:
|
For your security, always verify the authenticity of downloaded software:
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2 text-yellow-800 dark:text-yellow-200">
|
<ul className={`space-y-2 ${sectionStyles.warning.text}`} role="list" aria-label="Security verification steps">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-yellow-600 dark:text-yellow-400 mr-2">✓</span>
|
<span className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true">✓</span>
|
||||||
<span>Only download from the official Codeberg repository linked above</span>
|
<span>Only download from the official Codeberg repository linked above</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-yellow-600 dark:text-yellow-400 mr-2">✓</span>
|
<span className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true">✓</span>
|
||||||
<span>Verify the repository URL matches: <code className="bg-yellow-100 dark:bg-yellow-900 px-1 rounded">codeberg.org/mapleopentech</code></span>
|
<span>Verify the repository URL matches: <code className={`${sectionStyles.warning.code} px-1 rounded`}>codeberg.org/mapleopentech</code></span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-yellow-600 dark:text-yellow-400 mr-2">✓</span>
|
<span className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true">✓</span>
|
||||||
<span>Check that your browser shows a secure HTTPS connection (padlock icon)</span>
|
<span>Check that your browser shows a secure HTTPS connection (padlock icon)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-yellow-600 dark:text-yellow-400 mr-2">✓</span>
|
<span className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true">✓</span>
|
||||||
<span>Review the release notes and commit history before installation</span>
|
<span>Review the release notes and commit history before installation</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-3">
|
<p className={`text-sm ${sectionStyles.warning.muted} mt-3`}>
|
||||||
<strong>Warning:</strong> Never download MapleFile software from unofficial sources or third-party websites.
|
<strong>Warning:</strong> Never download MapleFile software from unofficial sources or third-party websites.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Support Section */}
|
{/* Support Section */}
|
||||||
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}>
|
<section
|
||||||
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
aria-labelledby="support-heading"
|
||||||
|
className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border-secondary")} p-6`}
|
||||||
|
>
|
||||||
|
<h3 id="support-heading" className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
||||||
Need Help?
|
Need Help?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-3">
|
<p className={`${getThemeClasses("text-secondary")} mb-3`}>
|
||||||
If you encounter any issues installing or using the MapleFile Desktop Application, please:
|
If you encounter any issues installing or using the MapleFile Desktop Application, please:
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2 text-gray-700 dark:text-gray-300">
|
<ul className={`space-y-2 ${getThemeClasses("text-secondary")}`} role="list" aria-label="Support options">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true">•</span>
|
||||||
<span>Check the README.md file in the repository for detailed documentation</span>
|
<span>Check the README.md file in the repository for detailed documentation</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true">•</span>
|
||||||
<span>Open an issue on the Codeberg repository for technical support</span>
|
<span>Open an issue on the Codeberg repository for technical support</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className={`mr-2 ${getThemeClasses("text-accent")}`}>•</span>
|
<span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true">•</span>
|
||||||
<span>Review the application's help documentation and user guide</span>
|
<span>Review the application's help documentation and user guide</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
<div className="flex justify-center py-6">
|
<div className="flex justify-center py-6">
|
||||||
|
|
@ -319,19 +370,19 @@ const ExportData = () => {
|
||||||
href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile"
|
href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Go to MapleFile Desktop Application Repository (opens in new tab)"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="flex items-center"
|
icon={CodeBracketIcon}
|
||||||
>
|
>
|
||||||
<CodeBracketIcon className="h-5 w-5 mr-2" />
|
|
||||||
Go to MapleFile Desktop Application Repository
|
Go to MapleFile Desktop Application Repository
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [getThemeClasses]);
|
}, [getThemeClasses, sectionStyles]);
|
||||||
|
|
||||||
// Field sections for DetailLiteView
|
// Field sections for DetailLiteView
|
||||||
const fieldSections = useMemo(() => {
|
const fieldSections = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,36 @@
|
||||||
// File: src/pages/User/Me/Tags/TagsManagement.jsx
|
// File: src/pages/User/Me/Tags/TagsManagement.jsx
|
||||||
// Tags Management Page - Complete CRUD for tags with E2EE
|
// Tags Management Page - Complete CRUD for tags with E2EE
|
||||||
|
// Layout pattern matching FileUpload.jsx
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
import { useTags } from "../../../../services/Services.jsx";
|
import { useTags } from "../../../../services/Services.jsx";
|
||||||
import Button from "../../../../components/UIX/Button/Button.jsx";
|
import withPasswordProtection from "../../../../hocs/withPasswordProtection";
|
||||||
import Input from "../../../../components/UIX/Input/Input.jsx";
|
import Layout from "../../../../components/Layout/Layout";
|
||||||
import Alert from "../../../../components/UIX/Alert/Alert.jsx";
|
import {
|
||||||
import Card from "../../../../components/UIX/Card/Card.jsx";
|
Button,
|
||||||
import { TrashIcon, PencilIcon, PlusIcon, TagIcon } from "@heroicons/react/24/outline";
|
Input,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
Spinner,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../../components/UIX";
|
||||||
|
import {
|
||||||
|
TrashIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TagIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
HomeIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const TagsManagement = () => {
|
const TagsManagement = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
const { tagManager } = useTags();
|
const { tagManager } = useTags();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
|
|
@ -24,10 +44,41 @@ const TagsManagement = () => {
|
||||||
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
|
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
|
||||||
const [formErrors, setFormErrors] = useState({});
|
const [formErrors, setFormErrors] = useState({});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load tags
|
||||||
|
const loadTags = useCallback(async () => {
|
||||||
|
if (!tagManager) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
const fetchedTags = await tagManager.listTags();
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setTags(fetchedTags || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
console.error("Failed to load tags:", err);
|
||||||
|
setError(err.message || "Failed to load tags");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [tagManager]);
|
||||||
|
|
||||||
// Load tags on mount
|
// Load tags on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTags();
|
loadTags();
|
||||||
}, []);
|
}, [loadTags]);
|
||||||
|
|
||||||
// Listen for tag events
|
// Listen for tag events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -44,25 +95,10 @@ const TagsManagement = () => {
|
||||||
window.removeEventListener("tagUpdated", handleTagUpdated);
|
window.removeEventListener("tagUpdated", handleTagUpdated);
|
||||||
window.removeEventListener("tagDeleted", handleTagDeleted);
|
window.removeEventListener("tagDeleted", handleTagDeleted);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [loadTags]);
|
||||||
|
|
||||||
// Load tags
|
|
||||||
const loadTags = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError("");
|
|
||||||
const fetchedTags = await tagManager.listTags();
|
|
||||||
setTags(fetchedTags || []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load tags:", err);
|
|
||||||
setError(err.message || "Failed to load tags");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle create
|
// Handle create
|
||||||
const handleCreate = async () => {
|
const handleCreate = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setFormErrors({});
|
setFormErrors({});
|
||||||
setError("");
|
setError("");
|
||||||
|
|
@ -84,20 +120,26 @@ const TagsManagement = () => {
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await tagManager.createTag(formData);
|
await tagManager.createTag(formData);
|
||||||
setSuccess("Tag created successfully!");
|
if (isMountedRef.current) {
|
||||||
setFormData({ name: "", color: "#3B82F6" });
|
setSuccess("Tag created successfully!");
|
||||||
setIsCreating(false);
|
setFormData({ name: "", color: "#3B82F6" });
|
||||||
await loadTags();
|
setIsCreating(false);
|
||||||
|
await loadTags();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create tag:", err);
|
if (isMountedRef.current) {
|
||||||
setError(err.message || "Failed to create tag");
|
console.error("Failed to create tag:", err);
|
||||||
|
setError(err.message || "Failed to create tag");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [formData, tagManager, loadTags]);
|
||||||
|
|
||||||
// Handle edit
|
// Handle edit
|
||||||
const handleEdit = async () => {
|
const handleEdit = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setFormErrors({});
|
setFormErrors({});
|
||||||
setError("");
|
setError("");
|
||||||
|
|
@ -119,20 +161,26 @@ const TagsManagement = () => {
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await tagManager.updateTag(editingTagId, formData);
|
await tagManager.updateTag(editingTagId, formData);
|
||||||
setSuccess("Tag updated successfully!");
|
if (isMountedRef.current) {
|
||||||
setFormData({ name: "", color: "#3B82F6" });
|
setSuccess("Tag updated successfully!");
|
||||||
setEditingTagId(null);
|
setFormData({ name: "", color: "#3B82F6" });
|
||||||
await loadTags();
|
setEditingTagId(null);
|
||||||
|
await loadTags();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update tag:", err);
|
if (isMountedRef.current) {
|
||||||
setError(err.message || "Failed to update tag");
|
console.error("Failed to update tag:", err);
|
||||||
|
setError(err.message || "Failed to update tag");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [formData, editingTagId, tagManager, loadTags]);
|
||||||
|
|
||||||
// Handle delete
|
// Handle delete
|
||||||
const handleDelete = async (tagId) => {
|
const handleDelete = useCallback(async (tagId) => {
|
||||||
if (!window.confirm("Are you sure you want to delete this tag?")) {
|
if (!window.confirm("Are you sure you want to delete this tag?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -142,200 +190,261 @@ const TagsManagement = () => {
|
||||||
setSuccess("");
|
setSuccess("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await tagManager.deleteTag(tagId);
|
await tagManager.deleteTag(tagId);
|
||||||
setSuccess("Tag deleted successfully!");
|
if (isMountedRef.current) {
|
||||||
await loadTags();
|
setSuccess("Tag deleted successfully!");
|
||||||
|
await loadTags();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete tag:", err);
|
if (isMountedRef.current) {
|
||||||
setError(err.message || "Failed to delete tag");
|
console.error("Failed to delete tag:", err);
|
||||||
|
setError(err.message || "Failed to delete tag");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [tagManager, loadTags]);
|
||||||
|
|
||||||
// Start editing
|
// Start editing
|
||||||
const startEdit = (tag) => {
|
const startEdit = useCallback((tag) => {
|
||||||
setFormData({ name: tag.name, color: tag.color });
|
setFormData({ name: tag.name, color: tag.color });
|
||||||
setEditingTagId(tag.id);
|
setEditingTagId(tag.id);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Cancel form
|
// Cancel form
|
||||||
const cancelForm = () => {
|
const cancelForm = useCallback(() => {
|
||||||
setFormData({ name: "", color: "#3B82F6" });
|
setFormData({ name: "", color: "#3B82F6" });
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setEditingTagId(null);
|
setEditingTagId(null);
|
||||||
setFormErrors({});
|
setFormErrors({});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// Memoize breadcrumb items
|
||||||
|
const breadcrumbItems = useMemo(() => [
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
to: "/me",
|
||||||
|
icon: Cog6ToothIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tags",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Layout>
|
||||||
{/* Header */}
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="flex justify-between items-center">
|
{/* Breadcrumb Navigation */}
|
||||||
<div>
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
Manage Tags
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Create and organize tags for your files and collections
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!isCreating && !editingTagId && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsCreating(true)}
|
|
||||||
icon={PlusIcon}
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
New Tag
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts */}
|
{/* Main Card */}
|
||||||
{error && (
|
|
||||||
<Alert type="error" onClose={() => setError("")}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<Alert type="success" onClose={() => setSuccess("")}>
|
|
||||||
{success}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create/Edit Form */}
|
|
||||||
{(isCreating || editingTagId) && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-6 space-y-4">
|
{/* Header with icon, title, and action button */}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
{editingTagId ? "Edit Tag" : "Create New Tag"}
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
</h3>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start">
|
||||||
<div className="space-y-4">
|
{/* Icon */}
|
||||||
<Input
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||||
label="Tag Name"
|
<TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
value={formData.name}
|
</div>
|
||||||
onChange={(value) => {
|
{/* Title and subtitle */}
|
||||||
setFormData({ ...formData, name: value });
|
<div>
|
||||||
setFormErrors({ ...formErrors, name: "" });
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
}}
|
Manage Tags
|
||||||
error={formErrors.name}
|
</h1>
|
||||||
placeholder="e.g., Important, Work, Personal"
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
maxLength={50}
|
Create and organize tags for your files and collections
|
||||||
/>
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Tag Color
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormData({ ...formData, color: e.target.value });
|
|
||||||
setFormErrors({ ...formErrors, color: "" });
|
|
||||||
}}
|
|
||||||
className="h-10 w-20 rounded cursor-pointer border border-gray-300 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={formData.color}
|
|
||||||
onChange={(value) => {
|
|
||||||
setFormData({ ...formData, color: value });
|
|
||||||
setFormErrors({ ...formErrors, color: "" });
|
|
||||||
}}
|
|
||||||
error={formErrors.color}
|
|
||||||
placeholder="#3B82F6"
|
|
||||||
maxLength={7}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
</div>
|
||||||
Choose a color to identify this tag
|
{/* Action buttons */}
|
||||||
</p>
|
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||||
|
{!isCreating && !editingTagId && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
icon={PlusIcon}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
New Tag
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Back to Settings</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-3 pt-4">
|
{/* Messages */}
|
||||||
<Button
|
<div className="px-6 pt-6">
|
||||||
onClick={editingTagId ? handleEdit : handleCreate}
|
{error && (
|
||||||
variant="primary"
|
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||||
disabled={isLoading}
|
{error}
|
||||||
>
|
</Alert>
|
||||||
{editingTagId ? "Update Tag" : "Create Tag"}
|
)}
|
||||||
</Button>
|
{success && (
|
||||||
<Button onClick={cancelForm} variant="secondary" disabled={isLoading}>
|
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
|
||||||
Cancel
|
{success}
|
||||||
</Button>
|
</Alert>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags List */}
|
{/* Content */}
|
||||||
{isLoading && tags.length === 0 ? (
|
<div className="px-6 pb-6 pt-2">
|
||||||
<Card>
|
{/* Create/Edit Form */}
|
||||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
{(isCreating || editingTagId) && (
|
||||||
Loading tags...
|
<div className={`mb-6 p-6 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
|
||||||
</div>
|
<h3 className={`text-lg font-semibold mb-4 ${getThemeClasses("text-primary")}`}>
|
||||||
</Card>
|
{editingTagId ? "Edit Tag" : "Create New Tag"}
|
||||||
) : tags.length === 0 && !isCreating ? (
|
</h3>
|
||||||
<Card>
|
|
||||||
<div className="p-8 text-center">
|
<div className="space-y-4">
|
||||||
<TagIcon className="h-12 w-12 mx-auto text-gray-400 dark:text-gray-600 mb-3" />
|
<Input
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
label="Tag Name"
|
||||||
No tags yet
|
value={formData.name}
|
||||||
</h3>
|
onChange={(value) => {
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
setFormData({ ...formData, name: value });
|
||||||
Create your first tag to organize your files and collections
|
setFormErrors({ ...formErrors, name: "" });
|
||||||
</p>
|
}}
|
||||||
<Button onClick={() => setIsCreating(true)} icon={PlusIcon} variant="primary">
|
error={formErrors.name}
|
||||||
Create Your First Tag
|
placeholder="e.g., Important, Work, Personal"
|
||||||
</Button>
|
maxLength={50}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
) : (
|
<div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<label className={`block text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
{tags.map((tag) => (
|
Tag Color
|
||||||
<Card key={tag.id}>
|
</label>
|
||||||
<div className="p-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex items-start justify-between">
|
<input
|
||||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
type="color"
|
||||||
<div
|
value={formData.color}
|
||||||
className="w-4 h-4 rounded-full flex-shrink-0"
|
onChange={(e) => {
|
||||||
style={{ backgroundColor: tag.color }}
|
setFormData({ ...formData, color: e.target.value });
|
||||||
/>
|
setFormErrors({ ...formErrors, color: "" });
|
||||||
<div className="flex-1 min-w-0">
|
}}
|
||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
className={`h-10 w-20 rounded cursor-pointer border ${getThemeClasses("border-secondary")}`}
|
||||||
{tag.name}
|
/>
|
||||||
</h4>
|
<Input
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
value={formData.color}
|
||||||
{tag.color}
|
onChange={(value) => {
|
||||||
</p>
|
setFormData({ ...formData, color: value });
|
||||||
|
setFormErrors({ ...formErrors, color: "" });
|
||||||
|
}}
|
||||||
|
error={formErrors.color}
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs mt-1 ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Choose a color to identify this tag
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={editingTagId ? handleEdit : handleCreate}
|
||||||
|
variant="primary"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
loadingText={editingTagId ? "Updating..." : "Creating..."}
|
||||||
|
>
|
||||||
|
{editingTagId ? "Update Tag" : "Create Tag"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={cancelForm} variant="secondary" disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags List */}
|
||||||
|
{isLoading && tags.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
|
<p className={getThemeClasses("text-secondary")}>Loading tags...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : tags.length === 0 && !isCreating ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
|
||||||
|
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
|
No tags yet
|
||||||
|
</h3>
|
||||||
|
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Create your first tag to organize your files
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setIsCreating(true)} icon={PlusIcon} variant="primary">
|
||||||
|
Create Your First Tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : tags.length > 0 && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} transition-shadow duration-200`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className={`text-sm font-medium truncate ${getThemeClasses("text-primary")}`}>
|
||||||
|
{tag.name}
|
||||||
|
</h4>
|
||||||
|
<p className={`text-xs mt-0.5 ${getThemeClasses("text-secondary")}`}>
|
||||||
|
{tag.color}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-1 ml-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => startEdit(tag)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Edit tag ${tag.name}`}
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDelete(tag.id)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Delete tag ${tag.name}`}
|
||||||
|
className="hover:text-red-600 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2 ml-2">
|
))}
|
||||||
<button
|
|
||||||
onClick={() => startEdit(tag)}
|
|
||||||
className="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
|
||||||
title="Edit tag"
|
|
||||||
>
|
|
||||||
<PencilIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(tag.id)}
|
|
||||||
className="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
|
||||||
title="Delete tag"
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagsManagement;
|
export default withPasswordProtection(TagsManagement);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,31 @@
|
||||||
// File: src/pages/User/Tags/TagCreate.jsx
|
// File: src/pages/User/Tags/TagCreate.jsx
|
||||||
// Tag Create Page - Create a new tag
|
// Tag Create Page - Create a new tag
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useTags } from "../../../services/Services.jsx";
|
import { useTags } from "../../../services/Services.jsx";
|
||||||
import Navigation from "../../../components/Navigation";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Alert from "../../../components/UIX/Alert/Alert.jsx";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
TagIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const TagCreate = () => {
|
const TagCreate = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { tagManager } = useTags();
|
const { tagManager } = useTags();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -18,10 +33,20 @@ const TagCreate = () => {
|
||||||
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
|
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
|
||||||
const [formErrors, setFormErrors] = useState({});
|
const [formErrors, setFormErrors] = useState({});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle create
|
// Handle create
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = useCallback(async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setFormErrors({});
|
setFormErrors({});
|
||||||
setError("");
|
setError("");
|
||||||
|
|
@ -42,165 +67,168 @@ const TagCreate = () => {
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await tagManager.createTag(formData);
|
await tagManager.createTag(formData);
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
// Navigate back to list
|
// Navigate back to list
|
||||||
navigate("/me/tags");
|
navigate("/me/tags");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create tag:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("Failed to create tag:", err);
|
||||||
|
}
|
||||||
setError(err.message || "Failed to create tag");
|
setError(err.message || "Failed to create tag");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [formData, tagManager, navigate]);
|
||||||
|
|
||||||
const btn_primary =
|
// Breadcrumb items
|
||||||
"inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200";
|
const breadcrumbItems = [
|
||||||
const btn_secondary =
|
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
|
||||||
"inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200";
|
{ label: "Tags", to: "/me/tags", icon: TagIcon },
|
||||||
const input_style =
|
{ label: "Create Tag", isActive: true },
|
||||||
"block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-red-500 focus:border-red-500 sm:text-sm disabled:opacity-50";
|
];
|
||||||
const label_style = "block text-sm font-medium text-gray-700 mb-1";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<Layout>
|
||||||
<Navigation />
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
{/* Breadcrumb Navigation */}
|
||||||
{/* Header */}
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<div className="mb-8 animate-fade-in-down">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/me/tags")}
|
|
||||||
className="inline-flex items-center text-sm text-gray-600 hover:text-red-700 mb-4 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
|
||||||
Back to Tags
|
|
||||||
</button>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Create Tag</h1>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
Create a new tag to organize your files and collections
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert */}
|
{/* Main Card */}
|
||||||
{error && (
|
<Card>
|
||||||
<div className="mb-6 animate-fade-in-up">
|
{/* Header */}
|
||||||
<Alert type="error" onClose={() => setError("")}>
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
{error}
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
</Alert>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||||
|
<PlusIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
|
Create Tag
|
||||||
|
</h1>
|
||||||
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Create a new tag to organize your files and collections
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me/tags")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Back to Tags</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form */}
|
{/* Messages */}
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 animate-fade-in-up">
|
<div className="px-6 pt-6">
|
||||||
<form onSubmit={handleCreate} className="space-y-6">
|
{error && (
|
||||||
<div>
|
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||||
<label htmlFor="tag_name" className={label_style}>
|
{error}
|
||||||
Tag Name <span className="text-red-500">*</span>
|
</Alert>
|
||||||
</label>
|
)}
|
||||||
<input
|
</div>
|
||||||
id="tag_name"
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 pb-6 pt-2">
|
||||||
|
<form onSubmit={handleCreate} className="space-y-6 max-w-xl">
|
||||||
|
<Input
|
||||||
|
label="Tag Name"
|
||||||
type="text"
|
type="text"
|
||||||
|
name="tag_name"
|
||||||
|
placeholder="e.g., Important, Work, Personal"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => {
|
onChange={(value) => {
|
||||||
setFormData({ ...formData, name: e.target.value });
|
setFormData({ ...formData, name: value });
|
||||||
setFormErrors({ ...formErrors, name: "" });
|
setFormErrors({ ...formErrors, name: "" });
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Important, Work, Personal"
|
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
required
|
required
|
||||||
className={input_style}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
error={formErrors.name}
|
||||||
|
icon={TagIcon}
|
||||||
/>
|
/>
|
||||||
{formErrors.name && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{formErrors.name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={label_style}>
|
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Tag Color <span className="text-red-500">*</span>
|
Tag Color <span className={getThemeClasses("text-error")}>*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormData({ ...formData, color: e.target.value });
|
|
||||||
setFormErrors({ ...formErrors, color: "" });
|
|
||||||
}}
|
|
||||||
className="h-12 w-24 rounded-lg cursor-pointer border-2 border-gray-300 shadow-sm hover:border-red-500 transition-colors duration-200"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="color"
|
||||||
value={formData.color}
|
value={formData.color}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFormData({ ...formData, color: e.target.value });
|
setFormData({ ...formData, color: e.target.value });
|
||||||
setFormErrors({ ...formErrors, color: "" });
|
setFormErrors({ ...formErrors, color: "" });
|
||||||
}}
|
}}
|
||||||
placeholder="#3B82F6"
|
className={`h-12 w-24 rounded-lg cursor-pointer border-2 ${getThemeClasses("border-muted")} shadow-sm ${getThemeClasses("hover:border-accent")} transition-colors duration-200`}
|
||||||
maxLength={7}
|
|
||||||
className={input_style}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="tag_color"
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFormData({ ...formData, color: value });
|
||||||
|
setFormErrors({ ...formErrors, color: "" });
|
||||||
|
}}
|
||||||
|
maxLength={7}
|
||||||
|
disabled={isLoading}
|
||||||
|
error={formErrors.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className={`text-xs ${getThemeClasses("text-secondary")} mt-2`}>
|
||||||
|
Choose a color to identify this tag visually
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{formErrors.color && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{formErrors.color}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
Choose a color to identify this tag visually
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-3 pt-4 border-t border-gray-200">
|
<div className={`flex space-x-3 pt-4 border-t ${getThemeClasses("border-muted")}`}>
|
||||||
<button type="submit" className={btn_primary} disabled={isLoading}>
|
<Button
|
||||||
{isLoading ? "Creating..." : "Create Tag"}
|
type="submit"
|
||||||
</button>
|
variant="primary"
|
||||||
<button
|
disabled={isLoading}
|
||||||
type="button"
|
loading={isLoading}
|
||||||
onClick={() => navigate("/me/tags")}
|
>
|
||||||
className={btn_secondary}
|
{!isLoading && (
|
||||||
disabled={isLoading}
|
<span className="inline-flex items-center gap-2">
|
||||||
>
|
<PlusIcon className="h-4 w-4" />
|
||||||
Cancel
|
<span>Create Tag</span>
|
||||||
</button>
|
</span>
|
||||||
</div>
|
)}
|
||||||
</form>
|
{isLoading && "Creating..."}
|
||||||
</div>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate("/me/tags")}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
{/* CSS Animations */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes fade-in-down {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-down {
|
|
||||||
animation: fade-in-down 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fade-in-up 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagCreate;
|
export default withPasswordProtection(TagCreate);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,25 @@
|
||||||
// File: src/pages/User/Tags/TagDelete.jsx
|
// File: src/pages/User/Tags/TagDelete.jsx
|
||||||
// Tag Delete Page - Delete confirmation for a tag
|
// Tag Delete Page - Delete confirmation for a tag
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useNavigate, useParams, useLocation } from "react-router";
|
import { useNavigate, useParams, useLocation } from "react-router";
|
||||||
import { useTags } from "../../../services/Services.jsx";
|
import { useTags } from "../../../services/Services.jsx";
|
||||||
import Navigation from "../../../components/Navigation";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Alert from "../../../components/UIX/Alert/Alert.jsx";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
Spinner,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
|
TagIcon,
|
||||||
|
TrashIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const TagDelete = () => {
|
const TagDelete = () => {
|
||||||
|
|
@ -16,27 +27,34 @@ const TagDelete = () => {
|
||||||
const { tagId } = useParams();
|
const { tagId } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { tagManager } = useTags();
|
const { tagManager } = useTags();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [tag, setTag] = useState(null);
|
const [tag, setTag] = useState(null);
|
||||||
|
|
||||||
// Get tag name from location state or load tag
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.state?.tagName) {
|
isMountedRef.current = true;
|
||||||
setTag({ id: tagId, name: location.state.tagName });
|
return () => {
|
||||||
} else {
|
isMountedRef.current = false;
|
||||||
loadTag();
|
};
|
||||||
}
|
}, []);
|
||||||
}, [tagId]);
|
|
||||||
|
|
||||||
// Load tag
|
// Load tag
|
||||||
const loadTag = async () => {
|
const loadTag = useCallback(async () => {
|
||||||
|
if (!tagManager) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
const tags = await tagManager.listTags();
|
const tags = await tagManager.listTags();
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
const foundTag = tags.find((t) => t.id === tagId);
|
const foundTag = tags.find((t) => t.id === tagId);
|
||||||
if (!foundTag) {
|
if (!foundTag) {
|
||||||
setError("Tag not found");
|
setError("Tag not found");
|
||||||
|
|
@ -44,152 +62,188 @@ const TagDelete = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTag(foundTag);
|
setTag(foundTag);
|
||||||
|
setIsInitialized(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load tag:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("Failed to load tag:", err);
|
||||||
|
}
|
||||||
setError(err.message || "Failed to load tag");
|
setError(err.message || "Failed to load tag");
|
||||||
|
setIsInitialized(true);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [tagManager, tagId, navigate]);
|
||||||
|
|
||||||
|
// Get tag name from location state or load tag
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.state?.tagName) {
|
||||||
|
setTag({ id: tagId, name: location.state.tagName });
|
||||||
|
setIsInitialized(true);
|
||||||
|
} else if (tagManager && !isInitialized) {
|
||||||
|
loadTag();
|
||||||
|
}
|
||||||
|
}, [tagId, location.state, tagManager, isInitialized, loadTag]);
|
||||||
|
|
||||||
// Handle delete
|
// Handle delete
|
||||||
const handleDelete = async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError("");
|
setError("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
console.log("[TagDelete] Attempting to delete tag:", tagId);
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[TagDelete] Attempting to delete tag:", tagId);
|
||||||
|
}
|
||||||
|
|
||||||
await tagManager.deleteTag(tagId);
|
await tagManager.deleteTag(tagId);
|
||||||
console.log("[TagDelete] Tag deleted successfully");
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[TagDelete] Tag deleted successfully");
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate back to list
|
// Navigate back to list
|
||||||
navigate("/me/tags");
|
navigate("/me/tags");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TagDelete] Failed to delete tag:", err);
|
if (!isMountedRef.current) return;
|
||||||
console.error("[TagDelete] Error details:", {
|
|
||||||
message: err.message,
|
if (import.meta.env.DEV) {
|
||||||
status: err.status,
|
console.error("[TagDelete] Failed to delete tag:", err);
|
||||||
response: err.response,
|
}
|
||||||
});
|
|
||||||
setError(err.message || "Failed to delete tag");
|
setError(err.message || "Failed to delete tag");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [tagManager, tagId, navigate]);
|
||||||
|
|
||||||
const btn_danger =
|
// Breadcrumb items
|
||||||
"inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200";
|
const breadcrumbItems = [
|
||||||
const btn_secondary =
|
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
|
||||||
"inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200";
|
{ label: "Tags", to: "/me/tags", icon: TagIcon },
|
||||||
|
{ label: "Delete Tag", isActive: true },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<Layout>
|
||||||
<Navigation />
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
{/* Breadcrumb Navigation */}
|
||||||
{/* Header */}
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<div className="mb-8 animate-fade-in-down">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/me/tags")}
|
|
||||||
className="inline-flex items-center text-sm text-gray-600 hover:text-red-700 mb-4 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
|
||||||
Back to Tags
|
|
||||||
</button>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Delete Tag</h1>
|
|
||||||
<p className="text-gray-600 mt-1">Confirm tag deletion</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert */}
|
{/* Main Card */}
|
||||||
{error && (
|
<Card>
|
||||||
<div className="mb-6 animate-fade-in-up">
|
{/* Header */}
|
||||||
<Alert type="error" onClose={() => setError("")}>
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
{error}
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
</Alert>
|
<div className="flex-1 min-w-0">
|
||||||
</div>
|
<div className="flex items-start">
|
||||||
)}
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-error")}`}>
|
||||||
|
<TrashIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
{/* Confirmation */}
|
</div>
|
||||||
{isLoading && !tag ? (
|
<div>
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center animate-fade-in-up">
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div>
|
Delete Tag
|
||||||
<p className="text-gray-600">Loading tag...</p>
|
</h1>
|
||||||
</div>
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
) : tag ? (
|
Confirm tag deletion
|
||||||
<div className="bg-white rounded-xl shadow-lg border-2 border-red-100 p-8 animate-fade-in-up">
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-red-100">
|
|
||||||
<ExclamationTriangleIcon className="h-8 w-8 text-red-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
Delete Tag "{tag.name}"?
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Are you sure you want to delete this tag? This action cannot
|
|
||||||
be undone.
|
|
||||||
</p>
|
|
||||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
<strong>Note:</strong> This will only delete the tag itself. Files and
|
|
||||||
collections with this tag will not be affected.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex-shrink-0">
|
||||||
|
<Button
|
||||||
<div className="flex space-x-3 mt-6 pt-6 border-t border-gray-200">
|
onClick={() => navigate("/me/tags")}
|
||||||
<button
|
variant="secondary"
|
||||||
onClick={handleDelete}
|
size="sm"
|
||||||
className={btn_danger}
|
>
|
||||||
disabled={isLoading}
|
<span className="inline-flex items-center gap-2">
|
||||||
>
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
{isLoading ? "Deleting..." : "Delete Tag"}
|
<span>Back to Tags</span>
|
||||||
</button>
|
</span>
|
||||||
<button
|
</Button>
|
||||||
onClick={() => navigate("/me/tags")}
|
</div>
|
||||||
className={btn_secondary}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="px-6 pt-6">
|
||||||
|
{error && (
|
||||||
|
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 pb-6 pt-2">
|
||||||
|
{isLoading && !tag ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
|
<p className={getThemeClasses("text-secondary")}>Loading tag...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : tag ? (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<div className={`${getThemeClasses("bg-error-light")} border ${getThemeClasses("border-error")} rounded-xl p-6`}>
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-2xl ${getThemeClasses("bg-error-light")}`}>
|
||||||
|
<ExclamationTriangleIcon className={`h-8 w-8 ${getThemeClasses("text-error")}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className={`text-xl font-semibold ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
|
Delete Tag "{tag.name}"?
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Are you sure you want to delete this tag? This action cannot
|
||||||
|
be undone.
|
||||||
|
</p>
|
||||||
|
<Alert type="info" className="mt-4">
|
||||||
|
<strong>Note:</strong> This will only delete the tag itself. Files and
|
||||||
|
collections with this tag will not be affected.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex space-x-3 mt-6 pt-6 border-t ${getThemeClasses("border-muted")}`}>
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
variant="danger"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{!isLoading && (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
<span>Delete Tag</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLoading && "Deleting..."}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me/tags")}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
{/* CSS Animations */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes fade-in-down {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-down {
|
|
||||||
animation: fade-in-down 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fade-in-up 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagDelete;
|
export default withPasswordProtection(TagDelete);
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,59 @@
|
||||||
// File: src/pages/User/Tags/TagEdit.jsx
|
// File: src/pages/User/Tags/TagEdit.jsx
|
||||||
// Tag Edit Page - Edit an existing tag
|
// Tag Edit Page - Edit an existing tag
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { useTags } from "../../../services/Services.jsx";
|
import { useTags } from "../../../services/Services.jsx";
|
||||||
import Navigation from "../../../components/Navigation";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Alert from "../../../components/UIX/Alert/Alert.jsx";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
Spinner,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
TagIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const TagEdit = () => {
|
const TagEdit = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { tagId } = useParams();
|
const { tagId } = useParams();
|
||||||
const { tagManager } = useTags();
|
const { tagManager } = useTags();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
|
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
|
||||||
const [formErrors, setFormErrors] = useState({});
|
const [formErrors, setFormErrors] = useState({});
|
||||||
|
|
||||||
// Load tag on mount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTag();
|
isMountedRef.current = true;
|
||||||
}, [tagId]);
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load tag on mount
|
||||||
|
const loadTag = useCallback(async () => {
|
||||||
|
if (!tagManager) return;
|
||||||
|
|
||||||
// Load tag
|
|
||||||
const loadTag = async () => {
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
const tags = await tagManager.listTags();
|
const tags = await tagManager.listTags();
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
const tag = tags.find((t) => t.id === tagId);
|
const tag = tags.find((t) => t.id === tagId);
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
setError("Tag not found");
|
setError("Tag not found");
|
||||||
|
|
@ -37,18 +61,34 @@ const TagEdit = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFormData({ name: tag.name, color: tag.color });
|
setFormData({ name: tag.name, color: tag.color });
|
||||||
|
setIsInitialized(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load tag:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("Failed to load tag:", err);
|
||||||
|
}
|
||||||
setError(err.message || "Failed to load tag");
|
setError(err.message || "Failed to load tag");
|
||||||
|
setIsInitialized(true);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [tagManager, tagId, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagManager && !isInitialized) {
|
||||||
|
loadTag();
|
||||||
|
}
|
||||||
|
}, [tagManager, isInitialized, loadTag]);
|
||||||
|
|
||||||
// Handle update
|
// Handle update
|
||||||
const handleUpdate = async (e) => {
|
const handleUpdate = useCallback(async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setFormErrors({});
|
setFormErrors({});
|
||||||
setError("");
|
setError("");
|
||||||
|
|
@ -69,170 +109,172 @@ const TagEdit = () => {
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await tagManager.updateTag(tagId, formData);
|
await tagManager.updateTag(tagId, formData);
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
// Navigate back to list
|
// Navigate back to list
|
||||||
navigate("/me/tags");
|
navigate("/me/tags");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update tag:", err);
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("Failed to update tag:", err);
|
||||||
|
}
|
||||||
setError(err.message || "Failed to update tag");
|
setError(err.message || "Failed to update tag");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [formData, tagManager, tagId, navigate]);
|
||||||
|
|
||||||
const btn_primary =
|
// Breadcrumb items
|
||||||
"inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200";
|
const breadcrumbItems = [
|
||||||
const btn_secondary =
|
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
|
||||||
"inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200";
|
{ label: "Tags", to: "/me/tags", icon: TagIcon },
|
||||||
const input_style =
|
{ label: "Edit Tag", isActive: true },
|
||||||
"block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-red-500 focus:border-red-500 sm:text-sm disabled:opacity-50";
|
];
|
||||||
const label_style = "block text-sm font-medium text-gray-700 mb-1";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<Layout>
|
||||||
<Navigation />
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
{/* Breadcrumb Navigation */}
|
||||||
{/* Header */}
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<div className="mb-8 animate-fade-in-down">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/me/tags")}
|
|
||||||
className="inline-flex items-center text-sm text-gray-600 hover:text-red-700 mb-4 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
|
||||||
Back to Tags
|
|
||||||
</button>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Edit Tag</h1>
|
|
||||||
<p className="text-gray-600 mt-1">Update tag information</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert */}
|
{/* Main Card */}
|
||||||
{error && (
|
<Card>
|
||||||
<div className="mb-6 animate-fade-in-up">
|
{/* Header */}
|
||||||
<Alert type="error" onClose={() => setError("")}>
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
{error}
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
</Alert>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||||
|
<TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
|
Edit Tag
|
||||||
|
</h1>
|
||||||
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Update tag information
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me/tags")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Back to Tags</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form */}
|
{/* Messages */}
|
||||||
{isLoading && !formData.name ? (
|
<div className="px-6 pt-6">
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center animate-fade-in-up">
|
{error && (
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div>
|
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||||
<p className="text-gray-600">Loading tag...</p>
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 animate-fade-in-up">
|
{/* Content */}
|
||||||
<form onSubmit={handleUpdate} className="space-y-6">
|
<div className="px-6 pb-6 pt-2">
|
||||||
<div>
|
{isLoading && !formData.name ? (
|
||||||
<label htmlFor="tag_name" className={label_style}>
|
<div className="flex items-center justify-center py-12">
|
||||||
Tag Name <span className="text-red-500">*</span>
|
<div className="text-center">
|
||||||
</label>
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
<input
|
<p className={getThemeClasses("text-secondary")}>Loading tag...</p>
|
||||||
id="tag_name"
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleUpdate} className="space-y-6 max-w-xl">
|
||||||
|
<Input
|
||||||
|
label="Tag Name"
|
||||||
type="text"
|
type="text"
|
||||||
|
name="tag_name"
|
||||||
|
placeholder="e.g., Important, Work, Personal"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => {
|
onChange={(value) => {
|
||||||
setFormData({ ...formData, name: e.target.value });
|
setFormData({ ...formData, name: value });
|
||||||
setFormErrors({ ...formErrors, name: "" });
|
setFormErrors({ ...formErrors, name: "" });
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Important, Work, Personal"
|
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
required
|
required
|
||||||
className={input_style}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
error={formErrors.name}
|
||||||
|
icon={TagIcon}
|
||||||
/>
|
/>
|
||||||
{formErrors.name && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{formErrors.name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={label_style}>
|
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
|
||||||
Tag Color <span className="text-red-500">*</span>
|
Tag Color <span className={getThemeClasses("text-error")}>*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormData({ ...formData, color: e.target.value });
|
|
||||||
setFormErrors({ ...formErrors, color: "" });
|
|
||||||
}}
|
|
||||||
className="h-12 w-24 rounded-lg cursor-pointer border-2 border-gray-300 shadow-sm hover:border-red-500 transition-colors duration-200"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="color"
|
||||||
value={formData.color}
|
value={formData.color}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFormData({ ...formData, color: e.target.value });
|
setFormData({ ...formData, color: e.target.value });
|
||||||
setFormErrors({ ...formErrors, color: "" });
|
setFormErrors({ ...formErrors, color: "" });
|
||||||
}}
|
}}
|
||||||
placeholder="#3B82F6"
|
className={`h-12 w-24 rounded-lg cursor-pointer border-2 ${getThemeClasses("border-muted")} shadow-sm ${getThemeClasses("hover:border-accent")} transition-colors duration-200`}
|
||||||
maxLength={7}
|
|
||||||
className={input_style}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="tag_color"
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFormData({ ...formData, color: value });
|
||||||
|
setFormErrors({ ...formErrors, color: "" });
|
||||||
|
}}
|
||||||
|
maxLength={7}
|
||||||
|
disabled={isLoading}
|
||||||
|
error={formErrors.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className={`text-xs ${getThemeClasses("text-secondary")} mt-2`}>
|
||||||
|
Choose a color to identify this tag visually
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{formErrors.color && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{formErrors.color}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
Choose a color to identify this tag visually
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-3 pt-4 border-t border-gray-200">
|
<div className={`flex space-x-3 pt-4 border-t ${getThemeClasses("border-muted")}`}>
|
||||||
<button type="submit" className={btn_primary} disabled={isLoading}>
|
<Button
|
||||||
{isLoading ? "Updating..." : "Update Tag"}
|
type="submit"
|
||||||
</button>
|
variant="primary"
|
||||||
<button
|
disabled={isLoading}
|
||||||
type="button"
|
loading={isLoading}
|
||||||
onClick={() => navigate("/me/tags")}
|
>
|
||||||
className={btn_secondary}
|
{!isLoading && "Update Tag"}
|
||||||
disabled={isLoading}
|
{isLoading && "Updating..."}
|
||||||
>
|
</Button>
|
||||||
Cancel
|
<Button
|
||||||
</button>
|
type="button"
|
||||||
</div>
|
variant="secondary"
|
||||||
</form>
|
onClick={() => navigate("/me/tags")}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
{/* CSS Animations */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes fade-in-down {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-down {
|
|
||||||
animation: fade-in-down 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fade-in-up 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagEdit;
|
export default withPasswordProtection(TagEdit);
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,83 @@
|
||||||
// File: src/pages/User/Tags/TagList.jsx
|
// File: src/pages/User/Tags/TagList.jsx
|
||||||
// Tags List Page - Display all user tags
|
// Tags List Page - Display all user tags with Layout (sidebar + topbar)
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useTags, useAuth } from "../../../services/Services.jsx";
|
import { useTags } from "../../../services/Services.jsx";
|
||||||
import Navigation from "../../../components/Navigation";
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Alert from "../../../components/UIX/Alert/Alert.jsx";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
Spinner,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UserCircleIcon,
|
ArrowLeftIcon,
|
||||||
ShieldCheckIcon,
|
Cog6ToothIcon,
|
||||||
CheckIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const TagList = () => {
|
const TagList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
const { tagManager } = useTags();
|
const { tagManager } = useTags();
|
||||||
const { meManager } = useAuth();
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(true); // Start with loading true
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
const [userProfile, setUserProfile] = useState(null);
|
|
||||||
const [activeTab, setActiveTab] = useState("tags");
|
|
||||||
|
|
||||||
const tabs = [
|
// Cleanup on unmount
|
||||||
{ id: "profile", label: "Profile", icon: UserCircleIcon },
|
|
||||||
{ id: "security", label: "Security", icon: ShieldCheckIcon },
|
|
||||||
{ id: "tags", label: "Tags", icon: TagIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
const badge_success =
|
|
||||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800";
|
|
||||||
|
|
||||||
// Load user profile
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadUserProfile = async () => {
|
isMountedRef.current = true;
|
||||||
if (meManager) {
|
return () => {
|
||||||
try {
|
isMountedRef.current = false;
|
||||||
const profile = await meManager.getCurrentUser();
|
|
||||||
setUserProfile(profile);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load user profile:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
loadUserProfile();
|
|
||||||
}, [meManager]);
|
|
||||||
|
|
||||||
// Load tags on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadTags();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load tags
|
||||||
|
const loadTags = useCallback(async () => {
|
||||||
|
if (!tagManager) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
const fetchedTags = await tagManager.listTags();
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setTags(fetchedTags || []);
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("Failed to load tags:", err);
|
||||||
|
}
|
||||||
|
setError(err.message || "Failed to load tags");
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [tagManager]);
|
||||||
|
|
||||||
|
// Load tags when tagManager becomes available
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagManager && !isInitialized) {
|
||||||
|
loadTags();
|
||||||
|
}
|
||||||
|
}, [tagManager, isInitialized, loadTags]);
|
||||||
|
|
||||||
// Listen for tag events
|
// Listen for tag events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTagCreated = () => loadTags();
|
const handleTagCreated = () => loadTags();
|
||||||
|
|
@ -73,232 +93,170 @@ const TagList = () => {
|
||||||
window.removeEventListener("tagUpdated", handleTagUpdated);
|
window.removeEventListener("tagUpdated", handleTagUpdated);
|
||||||
window.removeEventListener("tagDeleted", handleTagDeleted);
|
window.removeEventListener("tagDeleted", handleTagDeleted);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [loadTags]);
|
||||||
|
|
||||||
// Load tags
|
|
||||||
const loadTags = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError("");
|
|
||||||
const fetchedTags = await tagManager.listTags();
|
|
||||||
setTags(fetchedTags || []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load tags:", err);
|
|
||||||
setError(err.message || "Failed to load tags");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle delete
|
// Handle delete
|
||||||
const handleDelete = async (tagId, tagName) => {
|
const handleDelete = useCallback((tagId, tagName) => {
|
||||||
navigate(`/me/tags/${tagId}/delete`, { state: { tagName } });
|
navigate(`/me/tags/${tagId}/delete`, { state: { tagName } });
|
||||||
};
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Memoize breadcrumb items
|
||||||
|
const breadcrumbItems = useMemo(() => [
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
to: "/me",
|
||||||
|
icon: Cog6ToothIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tags",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<Layout>
|
||||||
<Navigation />
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
{/* Breadcrumb Navigation */}
|
||||||
{/* Header */}
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<div className="mb-8 animate-fade-in-down">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
|
|
||||||
My Account
|
|
||||||
<TagIcon className="h-8 w-8 text-red-700 ml-2" />
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
Manage your profile and security settings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
{/* Main Card */}
|
||||||
{/* Sidebar */}
|
<Card>
|
||||||
<div className="lg:col-span-1">
|
{/* Header with icon, title, and action button */}
|
||||||
{userProfile && (
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6 text-center animate-fade-in-up">
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
<div className="relative inline-block mb-4">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="h-24 w-24 bg-gradient-to-br from-red-600 to-red-800 rounded-2xl flex items-center justify-center text-white text-3xl font-bold mx-auto">
|
<div className="flex items-start">
|
||||||
{userProfile.first_name?.charAt(0)}
|
{/* Icon */}
|
||||||
{userProfile.last_name?.charAt(0)}
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||||
|
<TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
|
</div>
|
||||||
|
{/* Title and subtitle */}
|
||||||
|
<div>
|
||||||
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
|
Manage Tags
|
||||||
|
</h1>
|
||||||
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Create and organize tags for your files and collections
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
|
||||||
{userProfile.first_name} {userProfile.last_name}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600">{userProfile.email}</p>
|
|
||||||
<div className={`mt-4 ${badge_success}`}>
|
|
||||||
<CheckIcon className="h-3 w-3 mr-1" />
|
|
||||||
Verified User
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Action buttons */}
|
||||||
|
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||||
<nav
|
<Button
|
||||||
className="space-y-1 animate-fade-in-up"
|
|
||||||
style={{ animationDelay: "100ms" }}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (tab.id === "profile") {
|
|
||||||
navigate("/me");
|
|
||||||
} else if (tab.id === "security") {
|
|
||||||
navigate("/me");
|
|
||||||
} else {
|
|
||||||
setActiveTab(tab.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? "bg-gradient-to-r from-red-700 to-red-800 text-white shadow-md"
|
|
||||||
: "text-gray-700 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<tab.icon className="h-5 w-5 mr-3" />
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="lg:col-span-3">
|
|
||||||
<div className="space-y-6 animate-fade-in-up">
|
|
||||||
{/* Header with New Tag Button */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900">My Tags</h2>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Create and organize tags for your files and collections
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/me/tags/create")}
|
onClick={() => navigate("/me/tags/create")}
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
icon={PlusIcon}
|
||||||
|
variant="primary"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4 mr-2" />
|
|
||||||
New Tag
|
New Tag
|
||||||
</button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Back to Settings</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alerts */}
|
|
||||||
{error && (
|
|
||||||
<Alert type="error" onClose={() => setError("")}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<Alert type="success" onClose={() => setSuccess("")}>
|
|
||||||
{success}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags List */}
|
|
||||||
{isLoading && tags.length === 0 ? (
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Loading tags...</p>
|
|
||||||
</div>
|
|
||||||
) : tags.length === 0 ? (
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center">
|
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-red-600 to-red-800 mb-4">
|
|
||||||
<TagIcon className="h-8 w-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
No tags yet
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
|
||||||
Create your first tag to organize your files and collections
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/me/tags/create")}
|
|
||||||
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-5 w-5 mr-2" />
|
|
||||||
Create Your First Tag
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
|
|
||||||
{tags.map((tag, index) => (
|
|
||||||
<div
|
|
||||||
key={tag.id}
|
|
||||||
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-5 hover:shadow-xl transition-all duration-200 animate-fade-in-up"
|
|
||||||
style={{ animationDelay: `${index * 50}ms` }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="text-base font-semibold text-gray-900 truncate">
|
|
||||||
{tag.name}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-gray-500 mt-0.5 font-mono">
|
|
||||||
{tag.color}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-1 ml-2">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/me/tags/${tag.id}/edit`)}
|
|
||||||
className="p-2 text-gray-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors duration-200"
|
|
||||||
title="Edit tag"
|
|
||||||
>
|
|
||||||
<PencilIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(tag.id, tag.name)}
|
|
||||||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors duration-200"
|
|
||||||
title="Delete tag"
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="px-6 pt-6">
|
||||||
|
{error && (
|
||||||
|
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 pb-6 pt-2">
|
||||||
|
{/* Tags List */}
|
||||||
|
{isLoading && tags.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
|
<p className={getThemeClasses("text-secondary")}>Loading tags...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : tags.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
|
||||||
|
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
|
No tags yet
|
||||||
|
</h3>
|
||||||
|
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Create your first tag to organize your files
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate("/me/tags/create")} icon={PlusIcon} variant="primary">
|
||||||
|
Create Your First Tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
role="article"
|
||||||
|
aria-label={`Tag: ${tag.name}`}
|
||||||
|
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} transition-shadow duration-200`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className={`text-sm font-medium truncate ${getThemeClasses("text-primary")}`}>
|
||||||
|
{tag.name}
|
||||||
|
</h4>
|
||||||
|
<p className={`text-xs mt-0.5 ${getThemeClasses("text-secondary")}`}>
|
||||||
|
{tag.color}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-1 ml-2" role="group" aria-label={`Actions for tag ${tag.name}`}>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/me/tags/${tag.id}/edit`)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Edit tag ${tag.name}`}
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDelete(tag.id, tag.name)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Delete tag ${tag.name}`}
|
||||||
|
className="hover:text-red-600 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
{/* CSS Animations */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes fade-in-down {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-down {
|
|
||||||
animation: fade-in-down 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fade-in-up 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagList;
|
export default withPasswordProtection(TagList);
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,97 @@
|
||||||
// File: src/pages/User/Tags/TagSearch.jsx
|
// File: src/pages/User/Tags/TagSearch.jsx
|
||||||
// Tag Search Page - Select tags and search for collections and files
|
// Tag Search Page - Select tags and search for collections and files
|
||||||
|
// Layout pattern matching FileManagerIndex.jsx
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useTags } from "../../../services/Services.jsx";
|
import { useTags } from "../../../services/Services.jsx";
|
||||||
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Layout from "../../../components/Layout/Layout";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
import Alert from "../../../components/UIX/Alert/Alert.jsx";
|
import {
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
Spinner,
|
||||||
|
Checkbox,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
XMarkIcon,
|
PlusIcon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
CheckIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const TagSearch = () => {
|
const TagSearch = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
const { tagManager } = useTags();
|
const { tagManager } = useTags();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState([]);
|
const [selectedTagIds, setSelectedTagIds] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(true); // Start with loading true
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
// Load tags on mount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTags();
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load tags
|
// Load tags
|
||||||
const loadTags = async () => {
|
const loadTags = useCallback(async () => {
|
||||||
|
if (!tagManager) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
const fetchedTags = await tagManager.listTags();
|
const fetchedTags = await tagManager.listTags();
|
||||||
setTags(fetchedTags || []);
|
if (isMountedRef.current) {
|
||||||
|
setTags(fetchedTags || []);
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load tags:", err);
|
if (isMountedRef.current) {
|
||||||
setError(err.message || "Failed to load tags");
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("Failed to load tags:", err);
|
||||||
|
}
|
||||||
|
setError(err.message || "Failed to load tags");
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [tagManager]);
|
||||||
|
|
||||||
|
// Load tags when tagManager becomes available
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagManager && !isInitialized) {
|
||||||
|
loadTags();
|
||||||
|
}
|
||||||
|
}, [tagManager, isInitialized, loadTags]);
|
||||||
|
|
||||||
// Toggle tag selection
|
// Toggle tag selection
|
||||||
const handleTagToggle = (tagId) => {
|
const handleTagToggle = useCallback((tagId) => {
|
||||||
if (selectedTagIds.includes(tagId)) {
|
setSelectedTagIds((prev) =>
|
||||||
setSelectedTagIds(selectedTagIds.filter((id) => id !== tagId));
|
prev.includes(tagId)
|
||||||
} else {
|
? prev.filter((id) => id !== tagId)
|
||||||
setSelectedTagIds([...selectedTagIds, tagId]);
|
: [...prev, tagId]
|
||||||
}
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Handle search
|
// Handle search
|
||||||
const handleSearch = () => {
|
const handleSearch = useCallback(() => {
|
||||||
if (selectedTagIds.length === 0) {
|
if (selectedTagIds.length === 0) {
|
||||||
setError("Please select at least one tag to search");
|
setError("Please select at least one tag to search");
|
||||||
return;
|
return;
|
||||||
|
|
@ -63,233 +101,218 @@ const TagSearch = () => {
|
||||||
navigate("/me/tags/search/results", {
|
navigate("/me/tags/search/results", {
|
||||||
state: { selectedTagIds },
|
state: { selectedTagIds },
|
||||||
});
|
});
|
||||||
};
|
}, [selectedTagIds, navigate]);
|
||||||
|
|
||||||
// Clear selection
|
// Clear selection
|
||||||
const handleClearSelection = () => {
|
const handleClearSelection = useCallback(() => {
|
||||||
setSelectedTagIds([]);
|
setSelectedTagIds([]);
|
||||||
setError("");
|
setError("");
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// Memoize breadcrumb items
|
||||||
|
const breadcrumbItems = useMemo(() => [
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
to: "/me",
|
||||||
|
icon: Cog6ToothIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tags",
|
||||||
|
to: "/me/tags",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Search",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Header */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="mb-8 animate-fade-in-down">
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
{/* Main Card */}
|
||||||
<button
|
<Card>
|
||||||
onClick={() => navigate("/me/tags")}
|
{/* Header with icon, title, and action buttons */}
|
||||||
className="mr-4 p-2 text-gray-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors duration-200"
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
>
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
<ArrowLeftIcon className="h-6 w-6" />
|
<div className="flex-1 min-w-0">
|
||||||
</button>
|
<div className="flex items-start">
|
||||||
<div>
|
{/* Icon */}
|
||||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||||
Search by Tags
|
<MagnifyingGlassIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
<MagnifyingGlassIcon className="h-8 w-8 text-red-700 ml-2" />
|
</div>
|
||||||
</h1>
|
{/* Title and subtitle */}
|
||||||
<p className="text-gray-600 mt-1">
|
<div>
|
||||||
Select tags to find matching files and collections
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
</p>
|
Search by Tags
|
||||||
|
</h1>
|
||||||
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Select tags to find matching files and collections
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={selectedTagIds.length === 0}
|
||||||
|
variant="primary"
|
||||||
|
icon={MagnifyingGlassIcon}
|
||||||
|
>
|
||||||
|
Search {selectedTagIds.length > 0 && `(${selectedTagIds.length})`}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me/tags")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Back to Tags</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts */}
|
{/* Messages */}
|
||||||
{error && (
|
<div className="px-6 pt-6">
|
||||||
<Alert type="error" onClose={() => setError("")}>
|
{error && (
|
||||||
{error}
|
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||||
</Alert>
|
{error}
|
||||||
)}
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Content */}
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 animate-fade-in-up">
|
<div className="px-6 pb-6 pt-2">
|
||||||
{/* Selection Counter */}
|
{/* Selection Counter */}
|
||||||
<div className="mb-6 pb-6 border-b border-gray-200">
|
<div className={`mb-6 pb-4 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<TagIcon className="h-6 w-6 text-red-700 mr-3" />
|
<TagIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<span className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
|
||||||
Select Tags
|
{selectedTagIds.length} tag{selectedTagIds.length !== 1 ? "s" : ""} selected
|
||||||
</h2>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
{selectedTagIds.length} selected
|
|
||||||
</span>
|
|
||||||
{selectedTagIds.length > 0 && (
|
{selectedTagIds.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
onClick={handleClearSelection}
|
onClick={handleClearSelection}
|
||||||
className="text-sm text-red-700 hover:text-red-800 font-medium"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
Clear All
|
Clear All
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags Grid */}
|
{/* Tags Grid */}
|
||||||
{isLoading && tags.length === 0 ? (
|
{isLoading && tags.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div>
|
<div className="text-center">
|
||||||
<p className="text-gray-600">Loading tags...</p>
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
</div>
|
<p className={getThemeClasses("text-secondary")}>Loading tags...</p>
|
||||||
) : tags.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-red-600 to-red-800 mb-4">
|
|
||||||
<TagIcon className="h-8 w-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
No tags available
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
|
||||||
Create tags first before searching
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/me/tags/create")}
|
|
||||||
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Create Your First Tag
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3 mb-8">
|
|
||||||
{tags.map((tag, index) => {
|
|
||||||
const isSelected = selectedTagIds.includes(tag.id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
onClick={() => handleTagToggle(tag.id)}
|
|
||||||
className={`p-4 rounded-lg border-2 transition-all duration-200 text-left animate-fade-in-up ${
|
|
||||||
isSelected
|
|
||||||
? "border-red-700 bg-red-50 shadow-md"
|
|
||||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
|
||||||
}`}
|
|
||||||
style={{ animationDelay: `${index * 30}ms` }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div
|
|
||||||
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4
|
|
||||||
className={`text-base font-semibold truncate ${
|
|
||||||
isSelected ? "text-red-900" : "text-gray-900"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="flex-shrink-0 w-6 h-6 bg-red-700 rounded-full flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-white"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Button */}
|
|
||||||
<div className="flex items-center justify-center pt-6 border-t border-gray-200">
|
|
||||||
<button
|
|
||||||
onClick={handleSearch}
|
|
||||||
disabled={selectedTagIds.length === 0}
|
|
||||||
className={`inline-flex items-center justify-center px-8 py-4 border border-transparent text-base font-medium rounded-lg text-white transition-all duration-200 ${
|
|
||||||
selectedTagIds.length === 0
|
|
||||||
? "bg-gray-400 cursor-not-allowed"
|
|
||||||
: "bg-gradient-to-r from-red-700 to-red-800 hover:from-red-800 hover:to-red-900 shadow-lg hover:shadow-xl"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MagnifyingGlassIcon className="h-5 w-5 mr-2" />
|
|
||||||
Search {selectedTagIds.length > 0 && `(${selectedTagIds.length} tags)`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
{tags.length > 0 && (
|
|
||||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4 animate-fade-in-up" style={{ animationDelay: "200ms" }}>
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5 text-blue-600"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-blue-800">
|
|
||||||
How tag search works
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2 text-sm text-blue-700">
|
|
||||||
<p>
|
|
||||||
Search returns items that have <strong>ALL</strong> selected tags (AND logic).
|
|
||||||
The more tags you select, the more specific your search becomes.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : tags.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
|
||||||
|
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
|
No tags available
|
||||||
|
</h3>
|
||||||
|
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Create tags first before searching
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me/tags/create")}
|
||||||
|
icon={PlusIcon}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Create Your First Tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const isSelected = selectedTagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${isSelected ? "Deselect" : "Select"} tag ${tag.name}`}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onClick={() => handleTagToggle(tag.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTagToggle(tag.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} shadow-md`
|
||||||
|
: `${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:border-primary")} hover:shadow-sm`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleTagToggle(tag.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
|
||||||
|
{tag.name}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className={`flex-shrink-0 w-6 h-6 ${getThemeClasses("bg-gradient-secondary")} rounded-full flex items-center justify-center`}>
|
||||||
|
<CheckIcon className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className={`mt-6 p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")}`} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
|
||||||
|
How tag search works
|
||||||
|
</h3>
|
||||||
|
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Search returns items that have <strong>ALL</strong> selected tags (AND logic).
|
||||||
|
The more tags you select, the more specific your search becomes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
|
|
||||||
{/* CSS Animations */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes fade-in-down {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-down {
|
|
||||||
animation: fade-in-down 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fade-in-up 0.5s ease-out both;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagSearch;
|
export default withPasswordProtection(TagSearch);
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,75 @@
|
||||||
// File: src/pages/User/Tags/TagSearchResults.jsx
|
// File: src/pages/User/Tags/TagSearchResults.jsx
|
||||||
// Tag Search Results Page - Display collections and files matching selected tags
|
// Tag Search Results Page - Display collections and files matching selected tags
|
||||||
|
// Layout pattern matching TagSearch.jsx
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router";
|
import { useNavigate, useLocation } from "react-router";
|
||||||
import { useTags, useCrypto } from "../../../services/Services.jsx";
|
import { useTags, useCrypto } from "../../../services/Services.jsx";
|
||||||
|
import withPasswordProtection from "../../../hocs/withPasswordProtection";
|
||||||
import Layout from "../../../components/Layout/Layout";
|
import Layout from "../../../components/Layout/Layout";
|
||||||
import Alert from "../../../components/UIX/Alert/Alert.jsx";
|
import {
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Breadcrumb,
|
||||||
|
Spinner,
|
||||||
|
useUIXTheme,
|
||||||
|
} from "../../../components/UIX";
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
DocumentIcon,
|
DocumentIcon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// Module-level cache for FileCryptoService to avoid repeated dynamic imports
|
||||||
|
let FileCryptoServiceCache = null;
|
||||||
|
const getFileCryptoService = async () => {
|
||||||
|
if (!FileCryptoServiceCache) {
|
||||||
|
const module = await import("../../../services/Crypto/FileCryptoService.js");
|
||||||
|
FileCryptoServiceCache = module.default;
|
||||||
|
}
|
||||||
|
return FileCryptoServiceCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
// UUID validation regex for tag IDs
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
const TagSearchResults = () => {
|
const TagSearchResults = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { getThemeClasses } = useUIXTheme();
|
||||||
const { tagManager } = useTags();
|
const { tagManager } = useTags();
|
||||||
const { CollectionCryptoService } = useCrypto();
|
const { CollectionCryptoService } = useCrypto();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
// Get selected tag IDs from navigation state
|
// Get selected tag IDs from navigation state and validate
|
||||||
const selectedTagIds = location.state?.selectedTagIds || [];
|
const selectedTagIds = useMemo(() => {
|
||||||
|
const rawIds = location.state?.selectedTagIds;
|
||||||
|
if (!Array.isArray(rawIds)) return [];
|
||||||
|
// Filter to only valid UUID strings
|
||||||
|
return rawIds.filter(id => typeof id === 'string' && UUID_REGEX.test(id));
|
||||||
|
}, [location.state?.selectedTagIds]);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [results, setResults] = useState(null);
|
const [results, setResults] = useState(null);
|
||||||
const [decryptedCollections, setDecryptedCollections] = useState([]);
|
const [decryptedCollections, setDecryptedCollections] = useState([]);
|
||||||
const [decryptedFiles, setDecryptedFiles] = useState([]);
|
const [decryptedFiles, setDecryptedFiles] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Redirect if no tags selected
|
// Redirect if no tags selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedTagIds || selectedTagIds.length === 0) {
|
if (!selectedTagIds || selectedTagIds.length === 0) {
|
||||||
|
|
@ -38,37 +77,10 @@ const TagSearchResults = () => {
|
||||||
}
|
}
|
||||||
}, [selectedTagIds, navigate]);
|
}, [selectedTagIds, navigate]);
|
||||||
|
|
||||||
// Perform search on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedTagIds && selectedTagIds.length > 0) {
|
|
||||||
performSearch();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Perform search
|
|
||||||
const performSearch = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError("");
|
|
||||||
console.log("[TagSearchResults] Searching with tags:", selectedTagIds);
|
|
||||||
|
|
||||||
const searchResults = await tagManager.searchByTags(selectedTagIds, 50);
|
|
||||||
setResults(searchResults);
|
|
||||||
|
|
||||||
console.log("[TagSearchResults] Search completed:", searchResults);
|
|
||||||
|
|
||||||
// Decrypt results
|
|
||||||
await decryptResults(searchResults);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[TagSearchResults] Search failed:", err);
|
|
||||||
setError(err.message || "Failed to search by tags");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Decrypt search results
|
// Decrypt search results
|
||||||
const decryptResults = async (searchResults) => {
|
const decryptResults = useCallback(async (searchResults) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsDecrypting(true);
|
setIsDecrypting(true);
|
||||||
|
|
||||||
|
|
@ -79,273 +91,378 @@ const TagSearchResults = () => {
|
||||||
const decrypted = await CollectionCryptoService.decryptCollectionFromAPI(collection);
|
const decrypted = await CollectionCryptoService.decryptCollectionFromAPI(collection);
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[TagSearchResults] Failed to decrypt collection:", collection.id, error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[TagSearchResults] Failed to decrypt collection:", collection.id, error);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const decryptedColls = (await Promise.all(collectionPromises)).filter(Boolean);
|
const decryptedColls = (await Promise.all(collectionPromises)).filter(Boolean);
|
||||||
setDecryptedCollections(decryptedColls);
|
if (isMountedRef.current) {
|
||||||
|
setDecryptedCollections(decryptedColls);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt files
|
// Decrypt files
|
||||||
if (searchResults.files && searchResults.files.length > 0) {
|
if (searchResults.files && searchResults.files.length > 0) {
|
||||||
// Dynamically import FileCryptoService
|
// Use cached FileCryptoService
|
||||||
const { default: FileCryptoService } = await import(
|
const FileCryptoService = await getFileCryptoService();
|
||||||
"../../../services/Crypto/FileCryptoService.js"
|
|
||||||
);
|
|
||||||
|
|
||||||
const filePromises = searchResults.files.map(async (file) => {
|
const filePromises = searchResults.files.map(async (file) => {
|
||||||
try {
|
try {
|
||||||
const decrypted = await FileCryptoService.decryptFileFromAPI(file);
|
const decrypted = await FileCryptoService.decryptFileFromAPI(file);
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[TagSearchResults] Failed to decrypt file:", file.id, error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[TagSearchResults] Failed to decrypt file:", file.id, error);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const decryptedFileList = (await Promise.all(filePromises)).filter(Boolean);
|
const decryptedFileList = (await Promise.all(filePromises)).filter(Boolean);
|
||||||
setDecryptedFiles(decryptedFileList);
|
if (isMountedRef.current) {
|
||||||
|
setDecryptedFiles(decryptedFileList);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[TagSearchResults] Decryption failed:", error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[TagSearchResults] Decryption failed:", error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsDecrypting(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsDecrypting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [CollectionCryptoService]);
|
||||||
|
|
||||||
|
// Perform search
|
||||||
|
const performSearch = useCallback(async () => {
|
||||||
|
if (!tagManager || !isMountedRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[TagSearchResults] Searching with tags:", selectedTagIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = await tagManager.searchByTags(selectedTagIds, 50);
|
||||||
|
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setResults(searchResults);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("[TagSearchResults] Search completed:", searchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt results
|
||||||
|
await decryptResults(searchResults);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error("[TagSearchResults] Search failed:", err);
|
||||||
|
}
|
||||||
|
setError(err.message || "Failed to search by tags");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [tagManager, selectedTagIds, decryptResults]);
|
||||||
|
|
||||||
|
// Perform search on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTagIds && selectedTagIds.length > 0 && tagManager) {
|
||||||
|
performSearch();
|
||||||
|
}
|
||||||
|
}, [tagManager]); // Only run when tagManager becomes available
|
||||||
|
|
||||||
// Format file size
|
// Format file size
|
||||||
const formatFileSize = (bytes) => {
|
const formatFileSize = useCallback((bytes) => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return "0 Bytes";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Format date
|
// Format date
|
||||||
const formatDate = (dateString) => {
|
const formatDate = useCallback((dateString) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString("en-US", {
|
return date.toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// Memoize breadcrumb items
|
||||||
|
const breadcrumbItems = useMemo(() => [
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
to: "/me",
|
||||||
|
icon: Cog6ToothIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tags",
|
||||||
|
to: "/me/tags",
|
||||||
|
icon: TagIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Search",
|
||||||
|
to: "/me/tags/search",
|
||||||
|
icon: MagnifyingGlassIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Results",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalCollections = results?.collectionCount || 0;
|
||||||
|
const totalFiles = results?.fileCount || 0;
|
||||||
|
const hasResults = totalCollections > 0 || totalFiles > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Header */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="mb-8 animate-fade-in-down">
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/me/tags/search")}
|
|
||||||
className="mr-4 p-2 text-gray-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
|
|
||||||
Search Results
|
|
||||||
<MagnifyingGlassIcon className="h-8 w-8 text-red-700 ml-2" />
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
Showing results for {selectedTagIds.length} selected tag{selectedTagIds.length !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts */}
|
{/* Main Card */}
|
||||||
{error && (
|
<Card>
|
||||||
<Alert type="error" onClose={() => setError("")}>
|
{/* Header with icon, title, and action buttons */}
|
||||||
{error}
|
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
</Alert>
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||||
)}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start">
|
||||||
{/* Loading State */}
|
{/* Icon */}
|
||||||
{isLoading ? (
|
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center animate-fade-in-up">
|
<MagnifyingGlassIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Searching...</p>
|
|
||||||
</div>
|
|
||||||
) : results ? (
|
|
||||||
<>
|
|
||||||
{/* Summary */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6 animate-fade-in-up">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-6">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FolderIcon className="h-6 w-6 text-red-700 mr-2" />
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">{results.collectionCount}</p>
|
|
||||||
<p className="text-sm text-gray-600">Collections</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
{/* Title and subtitle */}
|
||||||
<DocumentIcon className="h-6 w-6 text-red-700 mr-2" />
|
<div>
|
||||||
<div>
|
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||||
<p className="text-2xl font-bold text-gray-900">{results.fileCount}</p>
|
Search Results
|
||||||
<p className="text-sm text-gray-600">Files</p>
|
</h1>
|
||||||
</div>
|
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||||
|
Showing results for {selectedTagIds.length} selected tag{selectedTagIds.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||||
|
<Button
|
||||||
onClick={() => navigate("/me/tags/search")}
|
onClick={() => navigate("/me/tags/search")}
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 transition-colors duration-200"
|
variant="primary"
|
||||||
|
icon={MagnifyingGlassIcon}
|
||||||
>
|
>
|
||||||
<MagnifyingGlassIcon className="h-4 w-4 mr-2" />
|
|
||||||
New Search
|
New Search
|
||||||
</button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/me/tags/search")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
<span>Back</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* No Results */}
|
{/* Messages */}
|
||||||
{results.collectionCount === 0 && results.fileCount === 0 ? (
|
<div className="px-6 pt-6">
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center animate-fade-in-up">
|
{error && (
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-gray-400 to-gray-600 mb-4">
|
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||||
<MagnifyingGlassIcon className="h-8 w-8 text-white" />
|
{error}
|
||||||
</div>
|
</Alert>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
No results found
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
|
||||||
No collections or files match all the selected tags. Try selecting fewer tags.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/me/tags/search")}
|
|
||||||
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Try Different Tags
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Collections Section */}
|
|
||||||
{decryptedCollections.length > 0 && (
|
|
||||||
<div className="animate-fade-in-up">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
|
||||||
<FolderIcon className="h-6 w-6 text-red-700 mr-2" />
|
|
||||||
Collections ({decryptedCollections.length})
|
|
||||||
</h2>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{decryptedCollections.map((collection, index) => (
|
|
||||||
<div
|
|
||||||
key={collection.id}
|
|
||||||
onClick={() => navigate(`/file-manager/collections/${collection.id}`)}
|
|
||||||
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 hover:shadow-xl transition-all duration-200 cursor-pointer animate-fade-in-up"
|
|
||||||
style={{ animationDelay: `${index * 50}ms` }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-12 h-12 bg-gradient-to-br from-red-600 to-red-800 rounded-lg flex items-center justify-center">
|
|
||||||
<FolderIcon className="h-6 w-6 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-1 min-w-0">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
|
||||||
{collection.name}
|
|
||||||
</h3>
|
|
||||||
{collection.description && (
|
|
||||||
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
|
|
||||||
{collection.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-2 flex items-center text-xs text-gray-500">
|
|
||||||
<span>Created {formatDate(collection.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Files Section */}
|
|
||||||
{decryptedFiles.length > 0 && (
|
|
||||||
<div className="animate-fade-in-up" style={{ animationDelay: "100ms" }}>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
|
||||||
<DocumentIcon className="h-6 w-6 text-red-700 mr-2" />
|
|
||||||
Files ({decryptedFiles.length})
|
|
||||||
</h2>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{decryptedFiles.map((file, index) => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
onClick={() => navigate(`/file-manager/files/${file.id}`)}
|
|
||||||
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 hover:shadow-xl transition-all duration-200 cursor-pointer animate-fade-in-up"
|
|
||||||
style={{ animationDelay: `${(decryptedCollections.length + index) * 50}ms` }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-blue-800 rounded-lg flex items-center justify-center">
|
|
||||||
<DocumentIcon className="h-6 w-6 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-1 min-w-0">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
|
||||||
{file.filename}
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2 flex items-center space-x-4 text-xs text-gray-500">
|
|
||||||
<span>{formatFileSize(file.size_in_bytes)}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Uploaded {formatDate(file.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Decrypting State */}
|
|
||||||
{isDecrypting && (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<p className="text-sm text-gray-600">Decrypting results...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* CSS Animations */}
|
{/* Content */}
|
||||||
<style>{`
|
<div className="px-6 pb-3 pt-2">
|
||||||
@keyframes fade-in-down {
|
{/* Loading State */}
|
||||||
from {
|
{isLoading ? (
|
||||||
opacity: 0;
|
<div className="flex items-center justify-center py-12">
|
||||||
transform: translateY(-20px);
|
<div className="text-center">
|
||||||
}
|
<Spinner size="lg" className="mx-auto mb-4" />
|
||||||
to {
|
<p className={getThemeClasses("text-secondary")}>Searching...</p>
|
||||||
opacity: 1;
|
</div>
|
||||||
transform: translateY(0);
|
</div>
|
||||||
}
|
) : results ? (
|
||||||
}
|
<>
|
||||||
.animate-fade-in-down {
|
{/* Summary Stats */}
|
||||||
animation: fade-in-down 0.5s ease-out both;
|
<div className={`mb-6 pb-4 border-b ${getThemeClasses("border-secondary")}`}>
|
||||||
}
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FolderIcon className={`h-6 w-6 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||||
|
<div>
|
||||||
|
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>{totalCollections}</p>
|
||||||
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Collections</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DocumentIcon className={`h-6 w-6 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||||
|
<div>
|
||||||
|
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>{totalFiles}</p>
|
||||||
|
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Files</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isDecrypting && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Spinner size="sm" className="mr-2" />
|
||||||
|
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>Decrypting...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
{/* No Results */}
|
||||||
from {
|
{!hasResults ? (
|
||||||
opacity: 0;
|
<div className="text-center py-3">
|
||||||
transform: translateY(20px);
|
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
|
||||||
}
|
<MagnifyingGlassIcon className="h-10 w-10 text-gray-400" />
|
||||||
to {
|
</div>
|
||||||
opacity: 1;
|
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||||
transform: translateY(0);
|
No results found
|
||||||
}
|
</h3>
|
||||||
}
|
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
|
||||||
.animate-fade-in-up {
|
No collections or files match all the selected tags. Try selecting fewer tags.
|
||||||
animation: fade-in-up 0.5s ease-out both;
|
</p>
|
||||||
}
|
<Button
|
||||||
`}</style>
|
onClick={() => navigate("/me/tags/search")}
|
||||||
|
icon={MagnifyingGlassIcon}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Try Different Tags
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Collections Section */}
|
||||||
|
{decryptedCollections.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className={`text-lg font-semibold mb-4 flex items-center ${getThemeClasses("text-primary")}`}>
|
||||||
|
<FolderIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||||
|
Collections ({decryptedCollections.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{decryptedCollections.map((collection) => (
|
||||||
|
<div
|
||||||
|
key={collection.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Open collection ${collection.name}`}
|
||||||
|
onClick={() => navigate(`/file-manager/collections/${collection.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/file-manager/collections/${collection.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} hover:shadow-md transition-all duration-200 cursor-pointer`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className={`flex-shrink-0 w-12 h-12 ${getThemeClasses("bg-gradient-secondary")} rounded-lg flex items-center justify-center`}>
|
||||||
|
<FolderIcon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1 min-w-0">
|
||||||
|
<h3 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
|
||||||
|
{collection.name}
|
||||||
|
</h3>
|
||||||
|
{collection.description && (
|
||||||
|
<p className={`mt-1 text-sm line-clamp-2 ${getThemeClasses("text-secondary")}`}>
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
|
||||||
|
Created {formatDate(collection.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Files Section */}
|
||||||
|
{decryptedFiles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className={`text-lg font-semibold mb-4 flex items-center ${getThemeClasses("text-primary")}`}>
|
||||||
|
<DocumentIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||||
|
Files ({decryptedFiles.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{decryptedFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Open file ${file.filename}`}
|
||||||
|
onClick={() => navigate(`/file-manager/files/${file.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/file-manager/files/${file.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} hover:shadow-md transition-all duration-200 cursor-pointer`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||||
|
<DocumentIcon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1 min-w-0">
|
||||||
|
<h3 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
|
||||||
|
{file.filename}
|
||||||
|
</h3>
|
||||||
|
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
|
||||||
|
{formatFileSize(file.size_in_bytes)} • Uploaded {formatDate(file.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className={`mt-6 p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")}`} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
|
||||||
|
About these results
|
||||||
|
</h3>
|
||||||
|
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||||
|
These results show items that have <strong>ALL</strong> of your selected tags.
|
||||||
|
Click on any item to view its details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagSearchResults;
|
export default withPasswordProtection(TagSearchResults);
|
||||||
|
|
|
||||||
|
|
@ -361,10 +361,27 @@ class LocalStorageService {
|
||||||
localStorage.setItem(`login_session_${key}`, JSON.stringify(data));
|
localStorage.setItem(`login_session_${key}`, JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get login session data
|
// Get login session data with validation
|
||||||
getLoginSessionData(key) {
|
getLoginSessionData(key) {
|
||||||
const data = localStorage.getItem(`login_session_${key}`);
|
const data = localStorage.getItem(`login_session_${key}`);
|
||||||
return data ? JSON.parse(data) : null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
// Validate it's an object (not null, array, or primitive)
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
// Invalid structure - clear corrupted data
|
||||||
|
localStorage.removeItem(`login_session_${key}`);
|
||||||
|
console.warn(`[LocalStorageService] Invalid login session data for key: ${key}, cleared`);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
// Parse error - clear corrupted data
|
||||||
|
localStorage.removeItem(`login_session_${key}`);
|
||||||
|
console.warn(`[LocalStorageService] Failed to parse login session data for key: ${key}, cleared`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear login session data
|
// Clear login session data
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue