diff --git a/.gitignore b/.gitignore index aa24275..c29c3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ tasks # Temp gitignore -#*_migration_source +*_migration_source # System Files .DS_Store diff --git a/bun.lockb b/bun.lockb index 22c05e5..47337a0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index d8dd2d6..0324c6e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/s3-request-presigner": "^3.953.0", "@libsql/client": "^0.15.15", + "@motionone/solid": "^10.16.4", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.0", "@solidjs/start": "^1.1.0", @@ -21,6 +22,7 @@ "bcrypt": "^6.0.0", "google-auth-library": "^10.5.0", "jose": "^6.1.3", + "motion": "^12.23.26", "solid-js": "^1.9.5", "uuid": "^13.0.0", "valibot": "^0.29.0", diff --git a/src/app.tsx b/src/app.tsx index 5a246fe..2dfa7d7 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -5,26 +5,29 @@ import "./app.css"; import { LeftBar, RightBar } from "./components/Bars"; import { TerminalSplash } from "./components/TerminalSplash"; import { SplashProvider } from "./context/splash"; +import { MetaProvider } from "@solidjs/meta"; export default function App() { return ( - -
- - ( -
- -
- {props.children} + + +
+ + ( +
+ +
+ {props.children} +
+
- -
- )} - > - - -
- + )} + > + + +
+ + ); } diff --git a/src/components/SimpleParallax.tsx b/src/components/SimpleParallax.tsx new file mode 100644 index 0000000..531b75c --- /dev/null +++ b/src/components/SimpleParallax.tsx @@ -0,0 +1,243 @@ +import { createSignal, createEffect, onMount, onCleanup, children as resolveChildren, type ParentComponent, createMemo, For } from "solid-js"; +import { animate } from "motion"; + +type ParallaxBackground = { + imageSet: { [key: number]: string }; + size: { width: number; height: number }; + verticalOffset: number; +}; + +type ParallaxLayerProps = { + layer: number; + caveParallax: ParallaxBackground; + dimensions: { width: number; height: number }; + scale: number; + scaledWidth: number; + scaledHeight: number; + verticalOffsetPixels: number; + imagesNeeded: number; + direction: number; +}; + +function ParallaxLayer(props: ParallaxLayerProps) { + let containerRef: HTMLDivElement | undefined; + + const layerDepthFactor = createMemo(() => props.layer / (Object.keys(props.caveParallax.imageSet).length - 1)); + const layerVerticalOffset = createMemo(() => props.verticalOffsetPixels * layerDepthFactor()); + const speed = createMemo(() => (120 - props.layer * 10) * 1000); + const targetX = createMemo(() => props.direction * -props.caveParallax.size.width * props.imagesNeeded); + + const containerStyle = createMemo(() => ({ + width: `${props.caveParallax.size.width * props.imagesNeeded * 3}px`, + height: `${props.caveParallax.size.height}px`, + left: `${(props.dimensions.width - props.scaledWidth) / 2}px`, + top: `${(props.dimensions.height - props.scaledHeight) / 2 + layerVerticalOffset()}px`, + "transform-origin": "center center", + "will-change": "transform", + })); + + // Set up animation when component mounts or when direction/speed changes + createEffect(() => { + if (!containerRef) return; + + const target = targetX(); + const duration = speed() / 1000; + + const controls = animate( + containerRef, + { + transform: [ + `translateX(0px) scale(${props.scale})`, + `translateX(${target}px) scale(${props.scale})` + ] + }, + { + duration, + easing: "linear", + repeat: Infinity, + } + ); + + onCleanup(() => controls.stop()); + }); + + const imageGroups = createMemo(() => { + return [-1, 0, 1].map((groupOffset) => ( +
+ {Array.from({ length: props.imagesNeeded }).map((_, index) => ( +
+ {`Parallax Object.keys(props.caveParallax.imageSet).length - 3 ? "eager" : "lazy"} + /> +
+ ))} +
+ )); + }); + + return ( +
+ {imageGroups()} +
+ ); +} + +const SimpleParallax: ParentComponent = (props) => { + let containerRef: HTMLDivElement | undefined; + const [dimensions, setDimensions] = createSignal({ width: 0, height: 0 }); + const [direction, setDirection] = createSignal(1); + + const caveParallax = createMemo(() => ({ + imageSet: { + 0: "/Cave/0.png", + 1: "/Cave/1.png", + 2: "/Cave/2.png", + 3: "/Cave/3.png", + 4: "/Cave/4.png", + 5: "/Cave/5.png", + 6: "/Cave/6.png", + 7: "/Cave/7.png", + }, + size: { width: 384, height: 216 }, + verticalOffset: 0.4, + })); + + const layerCount = createMemo(() => Object.keys(caveParallax().imageSet).length - 1); + const imagesNeeded = 3; + + const updateDimensions = () => { + if (containerRef) { + setDimensions({ + width: window.innerWidth, + height: window.innerHeight, + }); + } + }; + + onMount(() => { + let timeoutId: ReturnType; + + const handleResize = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(updateDimensions, 100); + }; + + updateDimensions(); + window.addEventListener("resize", handleResize); + + const intervalId = setInterval(() => { + setDirection((prev) => prev * -1); + }, 30000); + + onCleanup(() => { + clearTimeout(timeoutId); + clearInterval(intervalId); + window.removeEventListener("resize", handleResize); + }); + }); + + const calculations = createMemo(() => { + const dims = dimensions(); + if (dims.width === 0) { + return { + scale: 0, + scaledWidth: 0, + scaledHeight: 0, + verticalOffsetPixels: 0, + }; + } + + const cave = caveParallax(); + const scaleHeight = dims.height / cave.size.height; + const scaleWidth = dims.width / cave.size.width; + const scale = Math.max(scaleHeight, scaleWidth) * 1.21; + + return { + scale, + scaledWidth: cave.size.width * scale, + scaledHeight: cave.size.height * scale, + verticalOffsetPixels: cave.verticalOffset * dims.height, + }; + }); + + const parallaxLayers = createMemo(() => { + const dims = dimensions(); + if (dims.width === 0) return null; + + const calc = calculations(); + const cave = caveParallax(); + const dir = direction(); + + return Array.from({ length: layerCount() }).map((_, i) => { + const layerIndex = layerCount() - i; + return ( + + ); + }); + }); + + const resolved = resolveChildren(() => props.children); + + return ( +
+
+
+ {parallaxLayers()} +
+
{resolved()}
+ +
+ ); +}; + +export default SimpleParallax; diff --git a/src/components/icons/DownloadOnAppStore.tsx b/src/components/icons/DownloadOnAppStore.tsx new file mode 100644 index 0000000..f1c2f68 --- /dev/null +++ b/src/components/icons/DownloadOnAppStore.tsx @@ -0,0 +1,71 @@ +import { Component } from "solid-js"; + +const DownloadOnAppStore: Component<{ size: number }> = (props) => { + return ( + + Download_on_the_App_Store_Badge + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default DownloadOnAppStore; diff --git a/src/components/icons/DownloadOnAppStoreDark.tsx b/src/components/icons/DownloadOnAppStoreDark.tsx new file mode 100644 index 0000000..651ef40 --- /dev/null +++ b/src/components/icons/DownloadOnAppStoreDark.tsx @@ -0,0 +1,129 @@ +export default function DownloadOnAppStoreDark(props: { size: number }) { + return ( + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/icons/LinkedIn.tsx b/src/components/icons/LinkedIn.tsx new file mode 100644 index 0000000..4f76ea1 --- /dev/null +++ b/src/components/icons/LinkedIn.tsx @@ -0,0 +1,26 @@ +import { Component } from "solid-js"; + +const LinkedIn: Component<{ + height: string | number; + width: string | number; + fill?: string; + stroke?: string; +}> = (props) => { + return ( + + + + ); +}; + +export default LinkedIn; diff --git a/src/lib/s3upload.ts b/src/lib/s3upload.ts new file mode 100644 index 0000000..99afb54 --- /dev/null +++ b/src/lib/s3upload.ts @@ -0,0 +1,52 @@ +/** + * S3 Upload Utility for SolidStart + * Uploads files to S3 using pre-signed URLs from tRPC + */ + +export default async function AddImageToS3( + file: Blob | File, + title: string, + type: string, +): Promise { + try { + // Get pre-signed URL from tRPC endpoint + const getPreSignedResponse = await fetch("/api/trpc/misc.getPreSignedURL", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: type, + title: title, + filename: (file as File).name, + }), + }); + + if (!getPreSignedResponse.ok) { + throw new Error("Failed to get pre-signed URL"); + } + + const responseData = await getPreSignedResponse.json(); + const { uploadURL, key } = responseData.result.data as { + uploadURL: string; + key: string; + }; + + console.log("url: " + uploadURL, "key: " + key); + + // Upload file to S3 using pre-signed URL + const uploadResponse = await fetch(uploadURL, { + method: "PUT", + body: file as File, + }); + + if (!uploadResponse.ok) { + throw new Error("Failed to upload file to S3"); + } + + return key; + } catch (e) { + console.error("S3 upload error:", e); + throw e; + } +} diff --git a/src/routes/account.tsx b/src/routes/account.tsx new file mode 100644 index 0000000..b4d97b9 --- /dev/null +++ b/src/routes/account.tsx @@ -0,0 +1,624 @@ +import { createSignal, createEffect, Show, onMount } from "solid-js"; +import { useNavigate } from "@solidjs/router"; +import Eye from "~/components/icons/Eye"; +import EyeSlash from "~/components/icons/EyeSlash"; +import { validatePassword, isValidEmail } from "~/lib/validation"; + +type UserProfile = { + id: string; + email: string | null; + emailVerified: boolean; + displayName: string | null; + image: string | null; + provider: string; + hasPassword: boolean; +}; + +export default function AccountPage() { + const navigate = useNavigate(); + + // User data + const [user, setUser] = createSignal(null); + const [loading, setLoading] = createSignal(true); + + // Form loading states + const [emailButtonLoading, setEmailButtonLoading] = createSignal(false); + const [displayNameButtonLoading, setDisplayNameButtonLoading] = createSignal(false); + const [passwordChangeLoading, setPasswordChangeLoading] = createSignal(false); + const [deleteAccountButtonLoading, setDeleteAccountButtonLoading] = createSignal(false); + const [profileImageSetLoading, setProfileImageSetLoading] = createSignal(false); + + // Password state + const [passwordsMatch, setPasswordsMatch] = createSignal(false); + const [showPasswordLengthWarning, setShowPasswordLengthWarning] = createSignal(false); + const [passwordLengthSufficient, setPasswordLengthSufficient] = createSignal(false); + const [passwordBlurred, setPasswordBlurred] = createSignal(false); + const [passwordError, setPasswordError] = createSignal(false); + const [passwordDeletionError, setPasswordDeletionError] = createSignal(false); + + // Show/hide password toggles + const [showOldPasswordInput, setShowOldPasswordInput] = createSignal(false); + const [showPasswordInput, setShowPasswordInput] = createSignal(false); + const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false); + + // Success messages + const [showImageSuccess, setShowImageSuccess] = createSignal(false); + const [showEmailSuccess, setShowEmailSuccess] = createSignal(false); + const [showDisplayNameSuccess, setShowDisplayNameSuccess] = createSignal(false); + const [showPasswordSuccess, setShowPasswordSuccess] = createSignal(false); + + // Form refs + let oldPasswordRef: HTMLInputElement | undefined; + let newPasswordRef: HTMLInputElement | undefined; + let newPasswordConfRef: HTMLInputElement | undefined; + let emailRef: HTMLInputElement | undefined; + let displayNameRef: HTMLInputElement | undefined; + let deleteAccountPasswordRef: HTMLInputElement | undefined; + + // Fetch user profile on mount + onMount(async () => { + try { + const response = await fetch("/api/trpc/user.getProfile", { + method: "GET", + }); + + if (response.ok) { + const result = await response.json(); + if (result.result?.data) { + setUser(result.result.data); + } else { + // Not logged in, redirect to login + navigate("/login"); + } + } else { + navigate("/login"); + } + } catch (err) { + console.error("Failed to fetch user profile:", err); + navigate("/login"); + } finally { + setLoading(false); + } + }); + + // Email update handler + const setEmailTrigger = async (e: Event) => { + e.preventDefault(); + if (!emailRef) return; + + const email = emailRef.value; + if (!isValidEmail(email)) { + alert("Invalid email address"); + return; + } + + setEmailButtonLoading(true); + setShowEmailSuccess(false); + + try { + const response = await fetch("/api/trpc/user.updateEmail", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const result = await response.json(); + if (response.ok && result.result?.data) { + setUser(result.result.data); + setShowEmailSuccess(true); + setTimeout(() => setShowEmailSuccess(false), 3000); + } + } catch (err) { + console.error("Email update error:", err); + } finally { + setEmailButtonLoading(false); + } + }; + + // Display name update handler + const setDisplayNameTrigger = async (e: Event) => { + e.preventDefault(); + if (!displayNameRef) return; + + const displayName = displayNameRef.value; + setDisplayNameButtonLoading(true); + setShowDisplayNameSuccess(false); + + try { + const response = await fetch("/api/trpc/user.updateDisplayName", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName }), + }); + + const result = await response.json(); + if (response.ok && result.result?.data) { + setUser(result.result.data); + setShowDisplayNameSuccess(true); + setTimeout(() => setShowDisplayNameSuccess(false), 3000); + } + } catch (err) { + console.error("Display name update error:", err); + } finally { + setDisplayNameButtonLoading(false); + } + }; + + // Password change/set handler + const handlePasswordSubmit = async (e: Event) => { + e.preventDefault(); + const currentUser = user(); + if (!currentUser) return; + + if (currentUser.hasPassword) { + // Change password (requires old password) + if (!oldPasswordRef || !newPasswordRef || !newPasswordConfRef) return; + + const oldPassword = oldPasswordRef.value; + const newPassword = newPasswordRef.value; + const newPasswordConf = newPasswordConfRef.value; + + const passwordValidation = validatePassword(newPassword); + if (!passwordValidation.isValid) { + setPasswordError(true); + return; + } + + if (newPassword !== newPasswordConf) { + setPasswordError(true); + return; + } + + setPasswordChangeLoading(true); + setPasswordError(false); + + try { + const response = await fetch("/api/trpc/user.changePassword", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ oldPassword, newPassword }), + }); + + const result = await response.json(); + if (response.ok && result.result?.data?.success) { + setShowPasswordSuccess(true); + setTimeout(() => setShowPasswordSuccess(false), 3000); + // Clear form + if (oldPasswordRef) oldPasswordRef.value = ""; + if (newPasswordRef) newPasswordRef.value = ""; + if (newPasswordConfRef) newPasswordConfRef.value = ""; + } else { + setPasswordError(true); + } + } catch (err) { + console.error("Password change error:", err); + setPasswordError(true); + } finally { + setPasswordChangeLoading(false); + } + } else { + // Set password (first time for OAuth users) + if (!newPasswordRef || !newPasswordConfRef) return; + + const newPassword = newPasswordRef.value; + const newPasswordConf = newPasswordConfRef.value; + + const passwordValidation = validatePassword(newPassword); + if (!passwordValidation.isValid) { + setPasswordError(true); + return; + } + + if (newPassword !== newPasswordConf) { + setPasswordError(true); + return; + } + + setPasswordChangeLoading(true); + setPasswordError(false); + + try { + const response = await fetch("/api/trpc/user.setPassword", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: newPassword }), + }); + + const result = await response.json(); + if (response.ok && result.result?.data?.success) { + // Refresh user data to show hasPassword = true + const profileResponse = await fetch("/api/trpc/user.getProfile"); + const profileResult = await profileResponse.json(); + if (profileResult.result?.data) { + setUser(profileResult.result.data); + } + setShowPasswordSuccess(true); + setTimeout(() => setShowPasswordSuccess(false), 3000); + // Clear form + if (newPasswordRef) newPasswordRef.value = ""; + if (newPasswordConfRef) newPasswordConfRef.value = ""; + } else { + setPasswordError(true); + } + } catch (err) { + console.error("Password set error:", err); + setPasswordError(true); + } finally { + setPasswordChangeLoading(false); + } + } + }; + + // Delete account handler + const deleteAccountTrigger = async (e: Event) => { + e.preventDefault(); + if (!deleteAccountPasswordRef) return; + + const password = deleteAccountPasswordRef.value; + setDeleteAccountButtonLoading(true); + setPasswordDeletionError(false); + + try { + const response = await fetch("/api/trpc/user.deleteAccount", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + + const result = await response.json(); + if (response.ok && result.result?.data?.success) { + // Redirect to login + navigate("/login"); + } else { + setPasswordDeletionError(true); + } + } catch (err) { + console.error("Delete account error:", err); + setPasswordDeletionError(true); + } finally { + setDeleteAccountButtonLoading(false); + } + }; + + // Resend email verification + const sendEmailVerification = async () => { + const currentUser = user(); + if (!currentUser?.email) return; + + try { + await fetch("/api/trpc/auth.resendEmailVerification", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: currentUser.email }), + }); + alert("Verification email sent!"); + } catch (err) { + console.error("Email verification error:", err); + } + }; + + // Password validation helpers + const checkPasswordLength = (password: string) => { + if (password.length >= 8) { + setPasswordLengthSufficient(true); + setShowPasswordLengthWarning(false); + } else { + setPasswordLengthSufficient(false); + if (passwordBlurred()) { + setShowPasswordLengthWarning(true); + } + } + }; + + const checkForMatch = (newPassword: string, newPasswordConf: string) => { + setPasswordsMatch(newPassword === newPasswordConf); + }; + + const handleNewPasswordChange = (e: Event) => { + const target = e.target as HTMLInputElement; + checkPasswordLength(target.value); + if (newPasswordConfRef) { + checkForMatch(target.value, newPasswordConfRef.value); + } + }; + + const handlePasswordConfChange = (e: Event) => { + const target = e.target as HTMLInputElement; + if (newPasswordRef) { + checkForMatch(newPasswordRef.value, target.value); + } + }; + + const handlePasswordBlur = () => { + if (!passwordLengthSufficient() && newPasswordRef && newPasswordRef.value !== "") { + setShowPasswordLengthWarning(true); + } + setPasswordBlurred(true); + }; + + return ( +
+
+ +
Loading...
+
+ } + > + {(currentUser) => ( + <> +
+ Account Settings +
+ + {/* Email Section */} +
+
+
+
Current email:
+ {currentUser().email ? ( + {currentUser().email} + ) : ( + None Set + )} +
+ + + +
+ +
+
+ + + +
+
+ +
+ +
Email updated!
+
+
+ + {/* Display Name Section */} +
+
+
Display Name:
+ {currentUser().displayName ? ( + {currentUser().displayName} + ) : ( + None Set + )} +
+
+ +
+
+ + + +
+
+ +
+ +
Display name updated!
+
+
+
+ + {/* Password Change/Set Section */} +
+
+
+ {currentUser().hasPassword ? "Change Password" : "Set Password"} +
+ + +
+ + + + +
+
+ +
+ + + + +
+ + +
+ Password too short! Min Length: 8 +
+
+ +
+ + + + +
+ + = 6}> +
+ Passwords do not match! +
+
+ + + + +
+ {currentUser().hasPassword + ? "Password did not match record" + : "Error setting password"} +
+
+ + +
+ Password {currentUser().hasPassword ? "changed" : "set"} successfully! +
+
+
+
+ +
+ + {/* Delete Account Section */} +
+
+
Delete Account
+
+ Warning: This will delete all account information and is irreversible +
+ +
+
+
+ + + +
+
+ + + + +
+ Password did not match record +
+
+
+
+
+ + )} + +
+
+ ); +} diff --git a/src/routes/downloads.tsx b/src/routes/downloads.tsx new file mode 100644 index 0000000..a22f535 --- /dev/null +++ b/src/routes/downloads.tsx @@ -0,0 +1,167 @@ +import { A } from "@solidjs/router"; +import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore"; +import GitHub from "~/components/icons/GitHub"; +import LinkedIn from "~/components/icons/LinkedIn"; + +export default function DownloadsPage() { + const download = (assetName: string) => { + fetch(`/api/downloads/public/${assetName}`) + .then((response) => response.json()) + .then((data) => { + const url = data.downloadURL; + window.location.href = url; + }) + .catch((error) => console.error(error)); + }; + + const joinBetaPrompt = () => { + window.alert( + "This isn't released yet, if you would like to help test, please go the contact page and include the game and platform you would like to help test in the message. Otherwise the apk is available for direct install. Thanks!" + ); + }; + + return ( +
+
+ Downloads +
+ +
+
+ Life and Lineage +
+
+ +
+
+
Android (apk only)
+ +
+ Note the android version is not well tested, and has performance + issues. +
+
Or
+ +
(Coming soon)
+ +
+ +
+
iOS
+ + + +
+
+
+ +
+
+ Shapes with Abigail! +
+ (apk and iOS) +
+ +
+
+
Android
+ +
Or
+
(Coming soon)
+ +
+ +
+
iOS
+ + + +
+
+ +
+
+ Cork +
+ (macOS 13 Ventura or later) +
+ +
+ +
+
+ Just unzip and drag into 'Applications' folder +
+
+ + +
+
+ ); +} diff --git a/src/routes/login.tsx b/src/routes/login/index.tsx similarity index 100% rename from src/routes/login.tsx rename to src/routes/login/index.tsx diff --git a/src/routes/login/password-reset.tsx b/src/routes/login/password-reset.tsx new file mode 100644 index 0000000..91b373a --- /dev/null +++ b/src/routes/login/password-reset.tsx @@ -0,0 +1,321 @@ +import { createSignal, createEffect, Show } from "solid-js"; +import { A, useNavigate, useSearchParams } from "@solidjs/router"; +import CountdownCircleTimer from "~/components/CountdownCircleTimer"; +import Eye from "~/components/icons/Eye"; +import EyeSlash from "~/components/icons/EyeSlash"; +import { validatePassword } from "~/lib/validation"; + +export default function PasswordResetPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // State management + const [passwordBlurred, setPasswordBlurred] = createSignal(false); + const [passwordChangeLoading, setPasswordChangeLoading] = createSignal(false); + const [passwordsMatch, setPasswordsMatch] = createSignal(false); + const [showPasswordLengthWarning, setShowPasswordLengthWarning] = createSignal(false); + const [passwordLengthSufficient, setPasswordLengthSufficient] = createSignal(false); + const [showRequestNewEmail, setShowRequestNewEmail] = createSignal(false); + const [countDown, setCountDown] = createSignal(false); + const [error, setError] = createSignal(""); + const [showPasswordInput, setShowPasswordInput] = createSignal(false); + const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false); + + // Form refs + let newPasswordRef: HTMLInputElement | undefined; + let newPasswordConfRef: HTMLInputElement | undefined; + + // Get token from URL + const token = searchParams.token; + + // Redirect to request page if no token + createEffect(() => { + if (!token) { + navigate("/login/request-password-reset"); + } + }); + + // Form submission handler + const setNewPasswordTrigger = async (e: Event) => { + e.preventDefault(); + setShowRequestNewEmail(false); + setError(""); + + if (!newPasswordRef || !newPasswordConfRef) { + setError("Please fill in all fields"); + return; + } + + const newPassword = newPasswordRef.value; + const newPasswordConf = newPasswordConfRef.value; + + // Validate password + const passwordValidation = validatePassword(newPassword); + if (!passwordValidation.isValid) { + setError(passwordValidation.errors[0] || "Invalid password"); + return; + } + + if (newPassword !== newPasswordConf) { + setError("Passwords do not match"); + return; + } + + setPasswordChangeLoading(true); + + try { + const response = await fetch("/api/trpc/auth.resetPassword", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token: token, + newPassword, + newPasswordConfirmation: newPasswordConf, + }), + }); + + const result = await response.json(); + + if (response.ok && result.result?.data) { + setCountDown(true); + } else { + const errorMsg = result.error?.message || "Failed to reset password"; + if (errorMsg.includes("expired") || errorMsg.includes("token")) { + setShowRequestNewEmail(true); + setError("Token has expired"); + } else { + setError(errorMsg); + } + } + } catch (err) { + console.error("Password reset error:", err); + setError("An error occurred. Please try again."); + } finally { + setPasswordChangeLoading(false); + } + }; + + // Check if passwords match + const checkForMatch = (newPassword: string, newPasswordConf: string) => { + if (newPassword === newPasswordConf) { + setPasswordsMatch(true); + } else { + setPasswordsMatch(false); + } + }; + + // Check password length + const checkPasswordLength = (password: string) => { + if (password.length >= 8) { + setPasswordLengthSufficient(true); + setShowPasswordLengthWarning(false); + } else { + setPasswordLengthSufficient(false); + if (passwordBlurred()) { + setShowPasswordLengthWarning(true); + } + } + }; + + // Handle password blur + const passwordLengthBlurCheck = () => { + if ( + !passwordLengthSufficient() && + newPasswordRef && + newPasswordRef.value !== "" + ) { + setShowPasswordLengthWarning(true); + } + setPasswordBlurred(true); + }; + + // Handle new password change + const handleNewPasswordChange = (e: Event) => { + const target = e.target as HTMLInputElement; + checkPasswordLength(target.value); + if (newPasswordConfRef) { + checkForMatch(target.value, newPasswordConfRef.value); + } + }; + + // Handle password confirmation change + const handlePasswordConfChange = (e: Event) => { + const target = e.target as HTMLInputElement; + if (newPasswordRef) { + checkForMatch(newPasswordRef.value, target.value); + } + }; + + // Handle password blur + const handlePasswordBlur = () => { + passwordLengthBlurCheck(); + }; + + // Render countdown timer + const renderTime = (timeRemaining: number) => { + if (timeRemaining === 0) { + navigate("/login"); + } + return ( +
+
Change Successful!
+
{timeRemaining}
+
Redirecting...
+
+ ); + }; + + return ( +
+
+ Set New Password +
+ +
setNewPasswordTrigger(e)} + class="mt-4 flex w-full justify-center" + > +
+ {/* New Password Input */} +
+ + + + +
+ + {/* Password Length Warning */} +
+ Password too short! Min Length: 8 +
+ + {/* Password Confirmation Input */} +
+ + + + +
+ + {/* Password Mismatch Warning */} +
= 6 + ? "" + : "select-none opacity-0" + } transition-opacity text-center text-red-500 text-sm duration-200 ease-in-out mt-2`} + > + Passwords do not match! +
+ + {/* Countdown Timer or Submit Button */} + + {passwordChangeLoading() ? "Setting..." : "Set New Password"} + + } + > +
+ false} + > + {({ remainingTime }) => renderTime(remainingTime)} + +
+
+
+
+ + {/* Error Message */} + +
+
{error()}
+
+
+ + {/* Token Expired Message */} +
+ Token has expired, request a new one{" "} + + here + +
+ + {/* Back to Login Link */} + + + +
+ ); +} diff --git a/src/routes/login/request-password-reset.tsx b/src/routes/login/request-password-reset.tsx new file mode 100644 index 0000000..b83daec --- /dev/null +++ b/src/routes/login/request-password-reset.tsx @@ -0,0 +1,204 @@ +import { createSignal, createEffect, onCleanup, Show } from "solid-js"; +import { A, useNavigate } from "@solidjs/router"; +import CountdownCircleTimer from "~/components/CountdownCircleTimer"; +import { isValidEmail } from "~/lib/validation"; +import { getClientCookie } from "~/lib/cookies.client"; + +export default function RequestPasswordResetPage() { + const navigate = useNavigate(); + + // State management + const [loading, setLoading] = createSignal(false); + const [countDown, setCountDown] = createSignal(0); + const [showSuccessMessage, setShowSuccessMessage] = createSignal(false); + const [error, setError] = createSignal(""); + + // Form refs + let emailRef: HTMLInputElement | undefined; + let timerInterval: number | undefined; + + // Calculate remaining time from cookie + const calcRemainder = (timer: string) => { + const expires = new Date(timer); + const remaining = expires.getTime() - Date.now(); + const remainingInSeconds = remaining / 1000; + + if (remainingInSeconds <= 0) { + setCountDown(0); + if (timerInterval) { + clearInterval(timerInterval); + } + } else { + setCountDown(remainingInSeconds); + } + }; + + // Check for existing timer on mount + createEffect(() => { + const timer = getClientCookie("passwordResetRequested"); + if (timer) { + timerInterval = setInterval(() => calcRemainder(timer), 1000) as unknown as number; + onCleanup(() => { + if (timerInterval) { + clearInterval(timerInterval); + } + }); + } + }); + + // Form submission handler + const requestPasswordResetTrigger = async (e: Event) => { + e.preventDefault(); + setError(""); + setShowSuccessMessage(false); + + if (!emailRef) { + setError("Please enter an email address"); + return; + } + + const email = emailRef.value; + + // Validate email + if (!isValidEmail(email)) { + setError("Invalid email address"); + return; + } + + setLoading(true); + + try { + const response = await fetch("/api/trpc/auth.requestPasswordReset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const result = await response.json(); + + if (response.ok && result.result?.data) { + setShowSuccessMessage(true); + setError(""); + + // Start countdown timer + const timer = getClientCookie("passwordResetRequested"); + if (timer) { + if (timerInterval) { + clearInterval(timerInterval); + } + timerInterval = setInterval(() => { + calcRemainder(timer); + }, 1000) as unknown as number; + } + } else { + const errorMsg = result.error?.message || "Failed to send reset email"; + if (errorMsg.includes("countdown not expired")) { + setError("Please wait before requesting another reset email"); + } else { + setError(errorMsg); + } + } + } catch (err) { + console.error("Password reset request error:", err); + setError("An error occurred. Please try again."); + } finally { + setLoading(false); + } + }; + + const renderTime = () => { + return ( +
+
{countDown().toFixed(0)}
+
+ ); + }; + + return ( +
+
+ Password Reset Request +
+ +
requestPasswordResetTrigger(e)} + class="mt-4 flex w-full justify-center" + > +
+ {/* Email Input */} +
+ + + +
+ + {/* Countdown Timer or Submit Button */} + 0} + fallback={ + + } + > +
+ false} + > + {renderTime} + +
+
+
+
+ + {/* Success Message */} +
+ If email exists, you will receive an email shortly! +
+ + {/* Error Message */} + +
+
{error()}
+
+
+ + {/* Back to Login Link */} + +
+ ); +} diff --git a/src/routes/marketing/life-and-lineage.tsx b/src/routes/marketing/life-and-lineage.tsx new file mode 100644 index 0000000..f818319 --- /dev/null +++ b/src/routes/marketing/life-and-lineage.tsx @@ -0,0 +1,46 @@ +import { A } from "@solidjs/router"; +import SimpleParallax from "~/components/SimpleParallax"; +import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; + +export default function LifeAndLineageMarketing() { + return ( + +
+
+ Lineage App Icon +
+

+ Life and Lineage +

+

A dark fantasy adventure

+ +
+
+ ); +} diff --git a/src/routes/privacy-policy/life-and-lineage.tsx b/src/routes/privacy-policy/life-and-lineage.tsx new file mode 100644 index 0000000..085f786 --- /dev/null +++ b/src/routes/privacy-policy/life-and-lineage.tsx @@ -0,0 +1,112 @@ +import { A } from "@solidjs/router"; + +export default function PrivacyPolicy() { + return ( +
+
+
+ Life and Lineage's Privacy Policy +
+
Last Updated: October 22, 2024
+
+ Welcome to Life and Lineage ('We', 'Us', + 'Our'). Your privacy is important to us. This privacy + policy will help you understand our policies and procedures related + to the collection, use, and storage of personal information from our + users. +
+
    +
    +
    + 1. Personal Information +
    +
    +
    +
    (a) Collection of Personal Data:
    {" "} + Life and Lineage collects and stores personal data only if + users opt to use the remote saving feature. The information + collected includes email address, and if using an OAuth + provider - first name, and last name. This information is used + solely for the purpose of providing and managing the remote + saving feature. It is and never will be shared with a third + party. +
    +
    +
    (b) Data Removal:
    Users can + request the removal of all information related to them by + visiting{" "} + + this page + {" "} + and filling out the provided form. +
    +
    +
    + +
    +
    + 2. Third-Party Access +
    +
    +
    (a) Limited Third-Party Access:
    We + do not share or sell user information to third parties. However, + we do utilize third-party services for crash reporting and + performance profiling. These services do not have access to + personal user information and only receive anonymized data + related to app performance and stability. +
    +
    + +
    +
    + 3. Security +
    +
    +
    (a) Data Protection:
    Life and + Lineage takes appropriate measures to protect the personal + information of users who opt for the remote saving feature. We + implement industry-standard security protocols to prevent + unauthorized access, disclosure, alteration, or destruction of + user data. +
    +
    + +
    +
    + 4. Changes to the Privacy + Policy +
    +
    +
    (a) Updates:
    We may update this + privacy policy periodically. Any changes to this privacy policy + will be posted on this page. We encourage users to review this + policy regularly to stay informed about how we protect their + information. +
    +
    + +
    +
    + 5. Contact Us +
    +
    +
    (a) Reaching Out:
    If there are any + questions or comments regarding this privacy policy, you can + contact us{" "} + + here + + . +
    +
    +
+
+
+ ); +} diff --git a/src/routes/privacy-policy/shapes-with-abigail.tsx b/src/routes/privacy-policy/shapes-with-abigail.tsx new file mode 100644 index 0000000..c60a549 --- /dev/null +++ b/src/routes/privacy-policy/shapes-with-abigail.tsx @@ -0,0 +1,98 @@ +import { A } from "@solidjs/router"; + +export default function PrivacyPolicy() { + return ( +
+
+
+ Shapes with Abigail!'s Privacy Policy +
+
Last Updated: December 21, 2023
+
+ Welcome to Shapes with Abigail! ('We' , 'Us', + 'Our'). Your privacy is important to us. For that reason, + our app, "Shapes with Abigail!" has been designed to + provide our users with a secure environment. This privacy policy + will help you understand our policies and procedures related to the + non-collection, non-use, and non-storage of personal information + from our users. +
+
    +
    +
    + 1. Personal Information +
    +
    +
    +
    + (a) Non-Collection of Personal Data: +
    {" "} + Shapes with Abigail! does not collect nor store personal data. + We respect the privacy of our users, especially considering + the age of our users. We believe that no information, whether + private or personal, should be required for children to enjoy + our fun and educational app. +
    +
    +
    + +
    +
    + 2. Third-Party Access +
    +
    +
    (a) No Third-Party Access:
    Since we + do not collect or store any user data, there is no possibility + of sharing or selling our users' information to third + parties. Our priority is the safety and privacy of our users. +
    +
    + +
    +
    + 3. Security +
    +
    +
    (a) Secure Environment:
    Shapes with + Abigail! offers a secure and safe platform for children to play + and learn. Not requiring any personal data naturally enhances + security by eliminating potential risks related to data breaches + and misuse of information. +
    +
    + +
    +
    + 4. Changes to the Privacy + Policy +
    +
    +
    (a) Updates:
    We may update this + privacy policy periodically. Any changes to this privacy policy + will be posted on this page. However, since we do not collect + any personal data, these updates are likely to be insignificant. +
    +
    + +
    +
    + 5. Contact Us +
    +
    +
    (a) Reaching Out:
    If there are any + questions or comments regarding this privacy policy, you can + contact us{" "} + + here + + . +
    +
    +
+
+
+ ); +}