diff --git a/app/account/layout.tsx b/app/account/layout.tsx index 17d8dee..300f896 100644 --- a/app/account/layout.tsx +++ b/app/account/layout.tsx @@ -1,4 +1,5 @@ -import React from "react"; +import React from "react" +import { Toaster } from "@/components/ui/sonner" export default function AccountLayout({ children, @@ -6,7 +7,12 @@ export default function AccountLayout({ children: React.ReactNode }) { return ( -
{children}
+ <> +
+ {children} +
+ + ) } diff --git a/app/globals.css b/app/globals.css index 3bcb11f..659882f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -20,6 +20,10 @@ --color-accent-foreground: oklch(var(--accent-foreground)); --color-destructive: oklch(var(--destructive)); --color-destructive-foreground: oklch(var(--destructive-foreground)); + --color-error: oklch(var(--error)); + --color-error-foreground: oklch(var(--error-foreground)); + --color-success: oklch(var(--success)); + --color-success-foreground: oklch(var(--success-foreground)); --color-border: oklch(var(--border)); --color-input: oklch(var(--input)); --color-ring: oklch(var(--ring)); @@ -105,6 +109,10 @@ --accent-foreground: 0.208 0.042 265.755; --destructive: 0.577 0.245 27.325; --destructive-foreground: 0.984 0.003 247.858; + --error: 0.577 0.245 27.325; + --error-foreground: 0.984 0.003 247.858; + --success: 0.6 0.118 184.704; + --success-foreground: 0.984 0.003 247.858; --border: 0.929 0.013 255.508; --input: 0.929 0.013 255.508; --ring: 0.704 0.04 256.788; @@ -145,6 +153,10 @@ --accent-foreground: 0.984 0.003 247.858; --destructive: 0.704 0.191 22.216; --destructive-foreground: 0.984 0.003 247.858; + --error: 0.704 0.191 22.216; + --error-foreground: 0.984 0.003 247.858; + --success: 0.696 0.17 162.48; + --success-foreground: 0.984 0.003 247.858; --border: 1 0 0 / 10%; --input: 1 0 0 / 15%; --ring: 0.551 0.027 264.364; diff --git a/components/cards/dashboard/Settings/ChangeAuthentikPassword.tsx b/components/cards/dashboard/Settings/ChangeAuthentikPassword.tsx index a5da567..59c2fe7 100644 --- a/components/cards/dashboard/Settings/ChangeAuthentikPassword.tsx +++ b/components/cards/dashboard/Settings/ChangeAuthentikPassword.tsx @@ -1,10 +1,10 @@ "use client" -import React, { useState } from "react" +import React, { useState, useRef, useEffect } from "react" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" -import { Key, Loader2 } from "lucide-react" +import { Key, Loader2, CheckCircleIcon, XCircleIcon } from "lucide-react" import Link from "next/link" import { Dialog, @@ -15,17 +15,22 @@ import { DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { toast } from "sonner" +import { motion, useAnimationControls } from "framer-motion" export function ChangeAuthentikPassword() { const [newPassword, setNewPassword] = useState("") const [loading, setLoading] = useState(false) - const [message, setMessage] = useState(null) const [open, setOpen] = useState(false) + const holdTimeoutRef = useRef(null) + const intervalRef = useRef(null) + const controls = useAnimationControls() + const [isHolding, setIsHolding] = useState(false) + const holdDuration = 10 + const [remainingTime, setRemainingTime] = useState(holdDuration) - const handlePasswordChange = async (e: React.FormEvent) => { - e.preventDefault() + const submitPasswordChange = async () => { setLoading(true) - setMessage(null) try { const response = await fetch("/api/auth/password", { method: "POST", @@ -37,32 +42,143 @@ export function ChangeAuthentikPassword() { const resData = await response.json() if (response.ok && resData.success) { - setMessage("Password Updated") - setLoading(false) - // Close dialog after change + toast("Password updated successfully!", { + icon: , + style: { + backgroundColor: "oklch(var(--success))", + color: "oklch(var(--success-foreground))", + }, + }) setTimeout(() => { setOpen(false) setNewPassword("") + controls.set({ "--progress": "0%" }) }, 1500) - // TODO: Show a toast that password was changed } else if (resData.error) { - setMessage(resData.error) - setLoading(false) + toast("An error occurred", { + description: resData.error, + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) } else { - setMessage("[1] Failed to Update") - setLoading(false) + toast("Failed to Update", { + description: "An unknown error occurred [1]", + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) } } catch (error) { console.log(error) - setMessage("[2] Failed to Update") + toast("Failed to Update", { + description: "An unknown error occurred [2]", + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) + } finally { setLoading(false) + setIsHolding(false) + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + holdTimeoutRef.current = null + } + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } } - }; + } + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault() + } + + const holdDurationMs = holdDuration * 1000 + + const handleHoldStart = () => { + if (loading || newPassword.length < 8) return + + setIsHolding(true) + controls.set({ "--progress": "0%" }) + controls.start( + { "--progress": "100%" }, + { duration: holdDuration, ease: "linear" } + ) + + holdTimeoutRef.current = setTimeout(() => { + console.log("[i] Hold complete, submitting...") + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + submitPasswordChange() + holdTimeoutRef.current = null + }, holdDurationMs) + + setRemainingTime(holdDuration) + if (intervalRef.current) clearInterval(intervalRef.current) + intervalRef.current = setInterval(() => { + setRemainingTime((prevTime) => { + if (prevTime <= 1) { + if (intervalRef.current) clearInterval(intervalRef.current) + return 0 + } + return prevTime - 1 + }) + }, 1000) + } + + const handleHoldEnd = () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + holdTimeoutRef.current = null + } + if (isHolding) { + console.log("[i] Hold interrupted") + controls.stop() + controls.set({ "--progress": "0%" }) + setIsHolding(false) + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + } + + useEffect(() => { + return () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + } + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + }, []) + + useEffect(() => { + if (!open) { + handleHoldEnd() + setLoading(false) + setNewPassword("") + } + }, [open]) return ( - @@ -89,7 +205,7 @@ export function ChangeAuthentikPassword() { to keep it safe! -
+
- {message && ( -

- {message} -

- )} - + + +
diff --git a/components/cards/dashboard/Settings/ChangeEmailPassword.tsx b/components/cards/dashboard/Settings/ChangeEmailPassword.tsx index dc19471..ebe7dac 100644 --- a/components/cards/dashboard/Settings/ChangeEmailPassword.tsx +++ b/components/cards/dashboard/Settings/ChangeEmailPassword.tsx @@ -1,31 +1,36 @@ "use client" -import React, { useState } from "react" +import React, { useState, useRef, useEffect } from "react" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" -import { Key, Loader2 } from "lucide-react" +import { CheckCircleIcon, Key, Loader2, XCircleIcon } from "lucide-react" import Link from "next/link" import { Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger } from "@/components/ui/dialog" +import { toast } from "sonner" +import { motion, useAnimationControls } from "framer-motion" export function ChangeEmailPassword() { const [newPassword, setNewPassword] = useState("") const [loading, setLoading] = useState(false) - const [message, setMessage] = useState(null) const [open, setOpen] = useState(false) + const holdTimeoutRef = useRef(null) + const intervalRef = useRef(null) + const controls = useAnimationControls() + const [isHolding, setIsHolding] = useState(false) + const holdDuration = 10 + const [remainingTime, setRemainingTime] = useState(holdDuration) - const handlePasswordChange = async (e: React.FormEvent) => { - e.preventDefault() + const submitPasswordChange = async () => { setLoading(true) - setMessage(null) try { const response = await fetch("/api/mail/password", { method: "POST", @@ -37,31 +42,143 @@ export function ChangeEmailPassword() { const resData = await response.json() if (response.ok && resData.success) { - setMessage("Password Updated") - setLoading(false) - // Close dialog after change + toast("Password updated successfully!", { + icon: , + style: { + backgroundColor: "oklch(var(--success))", + color: "oklch(var(--success-foreground))", + }, + }) setTimeout(() => { setOpen(false) setNewPassword("") + controls.set({ "--progress": "0%" }) }, 1500) } else if (resData.error) { - setMessage(resData.error) - setLoading(false) + toast("An error occurred", { + description: resData.error, + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) } else { - setMessage("[1] Failed to Update") - setLoading(false) + toast("Failed to Update", { + description: "An unknown error occurred [1]", + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) } } catch (error) { console.log(error) - setMessage("[2] Failed to Update") + toast("Failed to Update", { + description: "An unknown error occurred [2]", + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) + } finally { setLoading(false) + setIsHolding(false) + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + holdTimeoutRef.current = null + } + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } } } + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault() + } + + const holdDurationMs = holdDuration * 1000 + + const handleHoldStart = () => { + if (loading || newPassword.length < 8) return + + setIsHolding(true) + controls.set({ "--progress": "0%" }) + controls.start( + { "--progress": "100%" }, + { duration: holdDuration, ease: "linear" } + ) + + holdTimeoutRef.current = setTimeout(() => { + console.log("[i] Hold complete, submitting...") + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + submitPasswordChange() + holdTimeoutRef.current = null + }, holdDurationMs) + + setRemainingTime(holdDuration) + if (intervalRef.current) clearInterval(intervalRef.current) + intervalRef.current = setInterval(() => { + setRemainingTime((prevTime) => { + if (prevTime <= 1) { + if (intervalRef.current) clearInterval(intervalRef.current) + return 0 + } + return prevTime - 1 + }) + }, 1000) + } + + const handleHoldEnd = () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + holdTimeoutRef.current = null + } + if (isHolding) { + console.log("[i] Hold interrupted") + controls.stop() + controls.set({ "--progress": "0%" }) + setIsHolding(false) + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + } + + useEffect(() => { + return () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + } + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + }, []) + + useEffect(() => { + if (!open) { + handleHoldEnd() + setLoading(false) + setNewPassword("") + } + }, [open]) + return ( - @@ -81,7 +198,7 @@ export function ChangeEmailPassword() { to keep it safe! -
+
- {message && ( -

- {message} -

- )} - + + +
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000..92e7cf9 --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/package.json b/package.json index d126579..f09acff 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@prisma/client": "^6.6.0", "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-label": "^2.1.3", "@radix-ui/react-popover": "^1.1.7", @@ -45,6 +46,7 @@ "react-hook-form": "^7.55.0", "react-icons": "^5.5.0", "react-typed": "^2.0.12", + "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tw-animate-css": "^1.2.5", "validator": "^13.15.0",