From fec58c4c17858f024fe6516c68903cd58639ce03 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 18 Dec 2025 15:03:13 -0500 Subject: [PATCH] mostly --- src/app.css | 3 +- src/app.tsx | 38 +- src/components/Bars.tsx | 130 +++---- src/components/CountdownCircleTimer.tsx | 43 ++- src/components/RevealDropDown.tsx | 59 +++ src/components/TerminalSplash.tsx | 2 +- src/context/bars.tsx | 36 +- src/routes/blog/index.tsx | 2 +- src/routes/contact.tsx | 335 ++++++++++++++++++ .../privacy-policy/life-and-lineage.tsx | 195 +++++----- src/server/api/routers/blog.ts | 1 + src/server/api/routers/misc.ts | 195 +++++++--- 12 files changed, 790 insertions(+), 249 deletions(-) create mode 100644 src/components/RevealDropDown.tsx create mode 100644 src/routes/contact.tsx diff --git a/src/app.css b/src/app.css index 584da6a..364752e 100644 --- a/src/app.css +++ b/src/app.css @@ -220,8 +220,7 @@ textarea.underlinedInput:focus { bottom: 0px; position: absolute; transition: width 0.3s ease-out; - /*TODO:*/ - background: var(--color-surface2); + background: var(--color-blue); } .bar:before { diff --git a/src/app.tsx b/src/app.tsx index ae7e11a..9925990 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -5,7 +5,8 @@ import { ErrorBoundary, Suspense, onMount, - onCleanup + onCleanup, + Show } from "solid-js"; import "./app.css"; import { LeftBar, RightBar } from "./components/Bars"; @@ -25,7 +26,8 @@ function AppLayout(props: { children: any }) { toggleLeftBar, toggleRightBar, setLeftBarVisible, - setRightBarVisible + setRightBarVisible, + barsInitialized } = useBars(); let lastScrollY = 0; @@ -158,18 +160,28 @@ function AppLayout(props: { children: any }) { }); return ( -
- -
- }>{props.children} + <> + {/* Fullscreen loading splash until bars are initialized */} + +
+ +
+
+ +
+ +
+ }>{props.children} +
+
- -
+ ); } diff --git a/src/components/Bars.tsx b/src/components/Bars.tsx index 1da2d88..28a134f 100644 --- a/src/components/Bars.tsx +++ b/src/components/Bars.tsx @@ -180,69 +180,79 @@ export function LeftBar() { Freno.dev - -
- + +
+ + {/* Right bar navigation merged for mobile */} +
-
-
+ +
); } @@ -302,13 +312,13 @@ export function RightBar() {
diff --git a/src/components/CountdownCircleTimer.tsx b/src/components/CountdownCircleTimer.tsx index 6f0188a..db73635 100644 --- a/src/components/CountdownCircleTimer.tsx +++ b/src/components/CountdownCircleTimer.tsx @@ -1,4 +1,4 @@ -import { Component, createEffect, onCleanup } from "solid-js"; +import { Component, createSignal, onMount, onCleanup } from "solid-js"; interface CountdownCircleTimerProps { duration: number; @@ -6,23 +6,46 @@ interface CountdownCircleTimerProps { size: number; strokeWidth: number; colors: string; - children: () => any; + children: (time: number) => any; + onComplete?: () => void; } const CountdownCircleTimer: Component = (props) => { const radius = (props.size - props.strokeWidth) / 2; const circumference = radius * 2 * Math.PI; - + + const [remainingTime, setRemainingTime] = createSignal( + props.initialRemainingTime + ); + // Calculate progress (0 to 1) - const progress = () => props.initialRemainingTime / props.duration; + const progress = () => remainingTime() / props.duration; const strokeDashoffset = () => circumference * (1 - progress()); + onMount(() => { + const interval = setInterval(() => { + setRemainingTime((prev) => { + const newTime = prev - 1; + if (newTime <= 0) { + clearInterval(interval); + props.onComplete?.(); + return 0; + } + return newTime; + }); + }, 1000); + + onCleanup(() => { + clearInterval(interval); + }); + }); + return (
= (props) => { fill="none" stroke={props.colors} stroke-width={props.strokeWidth} - stroke-dasharray={circumference} - stroke-dashoffset={strokeDashoffset()} + stroke-dasharray={`${circumference}`} + stroke-dashoffset={`${strokeDashoffset()}`} stroke-linecap="round" style={{ - transition: "stroke-dashoffset 0.5s linear", + transition: "stroke-dashoffset 0.5s linear" }} /> @@ -61,10 +84,10 @@ const CountdownCircleTimer: Component = (props) => { position: "absolute", top: "50%", left: "50%", - transform: "translate(-50%, -50%)", + transform: "translate(-50%, -50%)" }} > - {props.children()} + {props.children(remainingTime())}
); diff --git a/src/components/RevealDropDown.tsx b/src/components/RevealDropDown.tsx new file mode 100644 index 0000000..56b30b8 --- /dev/null +++ b/src/components/RevealDropDown.tsx @@ -0,0 +1,59 @@ +import { createSignal, JSX } from "solid-js"; + +export default function RevealDropDown(props: { + title: string; + children: JSX.Element; +}) { + const [isRevealed, setIsRevealed] = createSignal(false); + + const toggleReveal = () => { + setIsRevealed(!isRevealed()); + }; + + return ( +
+ {/* Button Header */} +
+
+ + {/* Life and lineage icon */} + + {props.title} +
+
+ {/* Reveal Arrow */} + + + +
+
+ + {/* Reveal Content */} +
+
+ {props.children} +
+
+
+ ); +} diff --git a/src/components/TerminalSplash.tsx b/src/components/TerminalSplash.tsx index 4f6de15..24e0178 100644 --- a/src/components/TerminalSplash.tsx +++ b/src/components/TerminalSplash.tsx @@ -20,7 +20,7 @@ export function TerminalSplash() { } return ( -
+
{spinnerChars[showing()]} diff --git a/src/context/bars.tsx b/src/context/bars.tsx index fb336f0..f598cd1 100644 --- a/src/context/bars.tsx +++ b/src/context/bars.tsx @@ -15,6 +15,7 @@ const BarsContext = createContext<{ setRightBarVisible: (visible: boolean) => void; toggleLeftBar: () => void; toggleRightBar: () => void; + barsInitialized: Accessor; }>({ leftBarSize: () => 0, setLeftBarSize: () => {}, @@ -27,7 +28,8 @@ const BarsContext = createContext<{ rightBarVisible: () => true, setRightBarVisible: () => {}, toggleLeftBar: () => {}, - toggleRightBar: () => {} + toggleRightBar: () => {}, + barsInitialized: () => false }); export function useBars() { @@ -41,6 +43,31 @@ export function BarsProvider(props: { children: any }) { const [centerWidth, setCenterWidth] = createSignal(0); const [leftBarVisible, _setLeftBarVisible] = createSignal(true); const [rightBarVisible, _setRightBarVisible] = createSignal(true); + const [barsInitialized, setBarsInitialized] = createSignal(false); + + // Track when both bars have been sized at least once + let leftBarSized = false; + let rightBarSized = false; + + const wrappedSetLeftBarSize = (size: number) => { + setLeftBarSize(size); + if (!leftBarSized && size > 0) { + leftBarSized = true; + if (rightBarSized) { + setBarsInitialized(true); + } + } + }; + + const wrappedSetRightBarSize = (size: number) => { + setRightBarSize(size); + if (!rightBarSized && size > 0) { + rightBarSized = true; + if (leftBarSized) { + setBarsInitialized(true); + } + } + }; // Wrap visibility setters with haptic feedback const setLeftBarVisible = (visible: boolean) => { @@ -60,9 +87,9 @@ export function BarsProvider(props: { children: any }) { {props.children} diff --git a/src/routes/blog/index.tsx b/src/routes/blog/index.tsx index 641cd17..862ee9c 100644 --- a/src/routes/blog/index.tsx +++ b/src/routes/blog/index.tsx @@ -20,7 +20,7 @@ export default function BlogIndex() { <> Blog | Michael Freno -
+
}>
diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx new file mode 100644 index 0000000..dcf763b --- /dev/null +++ b/src/routes/contact.tsx @@ -0,0 +1,335 @@ +import { createSignal, onMount, onCleanup, Show } from "solid-js"; +import { useSearchParams } from "@solidjs/router"; +import { Title, Meta } from "@solidjs/meta"; +import { A } from "@solidjs/router"; +import { api } from "~/lib/api"; +import GitHub from "~/components/icons/GitHub"; +import LinkedIn from "~/components/icons/LinkedIn"; +import { getClientCookie, setClientCookie } from "~/lib/cookies.client"; +import CountdownCircleTimer from "~/components/CountdownCircleTimer"; +import LoadingSpinner from "~/components/LoadingSpinner"; +import RevealDropDown from "~/components/RevealDropDown"; +import type { UserProfile } from "~/types/user"; + +export default function ContactPage() { + const [searchParams] = useSearchParams(); + const viewer = () => searchParams.viewer ?? "default"; + + const [countDown, setCountDown] = createSignal(0); + const [emailSent, setEmailSent] = createSignal(false); + const [error, setError] = createSignal(""); + const [loading, setLoading] = createSignal(false); + const [user, setUser] = createSignal(null); + + let timerIdRef: ReturnType | null = null; + + 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 (timerIdRef !== null) { + clearInterval(timerIdRef); + } + } else { + setCountDown(remainingInSeconds); + } + }; + + onMount(() => { + // Check for existing timer + const timer = getClientCookie("contactRequestSent"); + if (timer) { + timerIdRef = setInterval(() => calcRemainder(timer), 1000); + } + + // Fetch user data if authenticated + api.user.getProfile + .query() + .then((userData) => { + if (userData) { + setUser(userData); + } + }) + .catch(() => { + // User not authenticated, no problem + }); + + onCleanup(() => { + if (timerIdRef !== null) { + clearInterval(timerIdRef); + } + }); + }); + + const sendEmailTrigger = async (e: Event) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + + const name = formData.get("name") as string; + const email = formData.get("email") as string; + const message = formData.get("message") as string; + + if (name && email && message) { + setLoading(true); + try { + const res = await api.misc.sendContactRequest.mutate({ + name, + email, + message + }); + + if (res.message === "email sent") { + setEmailSent(true); + setError(""); + const timer = getClientCookie("contactRequestSent"); + if (timer) { + if (timerIdRef !== null) { + clearInterval(timerIdRef); + } + timerIdRef = setInterval(() => calcRemainder(timer), 1000); + } + } + } catch (err: any) { + setError(err.message || "An error occurred"); + setEmailSent(false); + } + setLoading(false); + } + }; + + const LineageQuestionsDropDown = () => { + return ( +
+ +
+ Feel free to use the form{" "} + {viewer() === "lineage" ? "below" : "above"}, I will respond as + quickly as possible, however, you may find an answer to your + question in the following. +
+
    +
    +
    + 1. Personal Information +
    +
    +
    + You can find the entire privacy policy{" "} + + here + + . +
    +
    +
    +
    +
    + 2. Remote Backups +
    +
    + Life and Lineage uses a per-user database approach for + its remote storage, this provides better separation of users and + therefore privacy, and it makes requesting the removal of your + data simpler, you can even request the database dump if you so + choose. This isn't particularly expensive, but not free for + n users, so use of this feature requires a purchase of an + IAP(in-app purchase) - this can be the specific IAP for the + remote save feature, and any other IAP will also unlock this + feature. +
    +
    +
    +
    + 3. Cross Device Play +
    +
    + You can use the above mentioned remote-backups to save progress + between devices/platforms. +
    +
    +
    +
    + 4. Online Requirements +
    +
    + Currently, the only time you need to be online is for remote + save access. There are plans for pvp, which will require an + internet connection, but this is not implemented at time of + writing. +
    +
    +
    +
    + 5. Microtransactions +
    +
    + Microtransactions are not required to play or complete the game, + the game can be fully completed without spending any money, + however 2 of the classes(necromancer and ranger) are pay-walled. + Microtransactions are supported cross-platform, so no need to + pay for each device, you simply need to login to your + gmail/apple/email account. This would require first creating a + character, signing in under options{">"}remote backups first. +
    +
    +
+
+
+ ); + }; + + const renderTime = (time: number) => { + return ( +
+
{time.toFixed(0)}
+
+ ); + }; + + return ( + <> + Contact | Michael Freno + + +
+
+
+ Contact +
+ +
+ (for this website or any of my apps...) +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+
+
+