This commit is contained in:
Michael Freno
2025-12-18 15:03:13 -05:00
parent 5aecf6e555
commit fec58c4c17
12 changed files with 790 additions and 249 deletions

View File

@@ -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 {

View File

@@ -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 (
<div class="flex max-w-screen flex-row">
<LeftBar />
<div
style={{
width: `${centerWidth()}px`,
"margin-left": `${leftBarSize()}px`
}}
>
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
<>
{/* Fullscreen loading splash until bars are initialized */}
<Show when={!barsInitialized()}>
<div class="bg-base fixed inset-0 z-50">
<TerminalSplash />
</div>
</Show>
<div class="flex max-w-screen flex-row">
<LeftBar />
<div
class="relative min-h-screen rounded-t-lg shadow-2xl"
style={{
width: `${centerWidth()}px`,
"margin-left": `${leftBarSize()}px`
}}
>
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
</div>
<RightBar />
</div>
<RightBar />
</div>
</>
);
}

View File

@@ -180,69 +180,79 @@ export function LeftBar() {
<a href="/">Freno.dev</a>
</h3>
</Typewriter>
<Typewriter keepAlive={false} class="z-50 h-full">
<div class="text-text flex flex-col px-4 text-xl font-bold">
<ul class="gap-4">
{/* Recent blog posts */}
<li class="mt-2 mb-6">
<div class="flex flex-col gap-2">
<span class="text-lg font-semibold">Recent Posts</span>
<div class="flex flex-col gap-3">
<Show when={recentPosts()} fallback={<TerminalSplash />}>
<For each={recentPosts()}>
{(post) => (
<a
href={`/blog/${post.title}`}
class="hover:text-subtext0 transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105 hover:font-bold"
>
<div class="flex flex-col">
<span>{post.title.replace(/_/g, " ")}</span>
<span class="text-subtext0 text-sm">
{new Date(post.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric"
})}
<div class="text-text flex flex-col px-4 text-xl font-bold">
<ul class="gap-4">
{/* Recent blog posts */}
<li class="mt-2 mb-6">
<div class="flex flex-col gap-2">
<span class="text-lg font-semibold">Recent Posts</span>
<div class="flex flex-col gap-3">
<Show when={recentPosts()} fallback={<TerminalSplash />}>
<For each={recentPosts()}>
{(post) => (
<a
href={`/blog/${post.title}`}
class="hover:text-subtext0 block transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105 hover:font-bold"
>
<Typewriter class="flex flex-col" keepAlive={false}>
<div class="relative overflow-hidden">
<img
src={post.banner_photo || "/blueprint.jpg"}
alt="post-cover"
class="float-right mb-1 ml-2 h-12 w-16 rounded object-cover"
/>
<span
class="inline wrap-break-word hyphens-auto"
style="hyphens: auto;"
>
{post.title.replace(/_/g, " ")}
</span>
</div>
</a>
)}
</For>
</Show>
</div>
<span class="text-subtext0 clear-both text-sm">
{new Date(post.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric"
})}
</span>
</Typewriter>
</a>
)}
</For>
</Show>
</div>
</li>
</ul>
<div class="absolute bottom-12">
<div class="flex flex-col gap-4">
<ul class="gap-4">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/">Home</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/blog">Blog</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#services">Services</a>
</li>
</ul>
{/* Right bar navigation merged for mobile */}
<ul class="border-overlay0 gap-4 border-t pt-4 md:hidden">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#home">Home</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#about">About</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#services">Services</a>
</li>
</ul>
</div>
</li>
</ul>
<Typewriter keepAlive={false} class="absolute bottom-12">
<div class="flex flex-col gap-4">
<ul class="gap-4">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/">Home</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/blog">Blog</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/contact">Contact</a>
</li>
</ul>
{/* Right bar navigation merged for mobile */}
<ul class="border-overlay0 gap-4 border-t pt-4 md:hidden">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/">Home</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#about">About</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/contact">Contact</a>
</li>
</ul>
</div>
</div>
</Typewriter>
</Typewriter>
</div>
</nav>
);
}
@@ -302,13 +312,13 @@ export function RightBar() {
<div class="text-text flex h-screen flex-col justify-between px-4 py-10 text-xl font-bold">
<ul class="gap-4">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#home">Home</a>
<a href="/">Home</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#about">About</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="#services">Services</a>
<a href="/contact">Contact</a>
</li>
</ul>
</div>

View File

@@ -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<CountdownCircleTimerProps> = (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 (
<div
style={{
position: "relative",
width: `${props.size}px`,
height: `${props.size}px`,
height: `${props.size}px`
}}
>
<svg
@@ -47,11 +70,11 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (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"
}}
/>
</svg>
@@ -61,10 +84,10 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
transform: "translate(-50%, -50%)"
}}
>
{props.children()}
{props.children(remainingTime())}
</div>
</div>
);

View File

@@ -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 (
<div class="border-surface0 relative mb-4 overflow-visible rounded-lg border">
{/* Button Header */}
<div
class="bg-mantle flex cursor-pointer items-center justify-between p-3"
onClick={toggleReveal}
>
<div class="flex items-center space-x-2">
<span class="text-gray-600 dark:text-gray-300">
{/* Life and lineage icon */}
</span>
<span class="font-medium">{props.title}</span>
</div>
<div class="flex items-center space-x-3">
{/* Reveal Arrow */}
<svg
class={`h-5 w-5 transition-transform duration-200 ${
isRevealed() ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
{/* Reveal Content */}
<div
class={`absolute right-0 left-0 z-10 overflow-hidden transition-all duration-300 ease-in-out ${
isRevealed() ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
}`}
>
<div class="bg-mantle p-4 shadow-lg dark:bg-gray-900">
{props.children}
</div>
</div>
</div>
);
}

View File

@@ -20,7 +20,7 @@ export function TerminalSplash() {
}
return (
<div class="bg-base mx-auto flex min-h-full w-full flex-col items-center justify-center overflow-hidden">
<div class="bg-base flex min-h-screen w-full flex-col items-center justify-center overflow-hidden">
<div class="text-text max-w-3xl p-8 font-mono text-4xl whitespace-pre-wrap">
<div class="flex items-center justify-center">
{spinnerChars[showing()]}

View File

@@ -15,6 +15,7 @@ const BarsContext = createContext<{
setRightBarVisible: (visible: boolean) => void;
toggleLeftBar: () => void;
toggleRightBar: () => void;
barsInitialized: Accessor<boolean>;
}>({
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 }) {
<BarsContext.Provider
value={{
leftBarSize,
setLeftBarSize,
setLeftBarSize: wrappedSetLeftBarSize,
rightBarSize,
setRightBarSize,
setRightBarSize: wrappedSetRightBarSize,
centerWidth,
setCenterWidth,
leftBarVisible,
@@ -70,7 +97,8 @@ export function BarsProvider(props: { children: any }) {
rightBarVisible,
setRightBarVisible,
toggleLeftBar,
toggleRightBar
toggleRightBar,
barsInitialized
}}
>
{props.children}

View File

@@ -20,7 +20,7 @@ export default function BlogIndex() {
<>
<Title>Blog | Michael Freno</Title>
<div class="relative mx-auto min-h-screen rounded-t-lg pt-8 pb-24 shadow-2xl">
<div class="mx-auto pt-8 pb-24">
<Suspense fallback={<TerminalSplash />}>
<div class="flex flex-col justify-center gap-4 md:flex-row md:justify-around">
<PostSortingSelect />

335
src/routes/contact.tsx Normal file
View File

@@ -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<number>(0);
const [emailSent, setEmailSent] = createSignal<boolean>(false);
const [error, setError] = createSignal<string>("");
const [loading, setLoading] = createSignal<boolean>(false);
const [user, setUser] = createSignal<UserProfile | null>(null);
let timerIdRef: ReturnType<typeof setInterval> | 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 (
<div class="mx-auto px-4 py-12 md:w-3/4 md:flex-row lg:w-1/2">
<RevealDropDown title={"Questions about Life and Lineage?"}>
<div>
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.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> Personal Information
</div>
<div class="pl-4">
<div class="pb-2">
You can find the entire privacy policy{" "}
<A
href="/privacy-policy/life-and-lineage"
class="text-blue underline-offset-4 hover:underline"
>
here
</A>
.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> Remote Backups
</div>
<div class="pl-4">
<em>Life and Lineage</em> 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&apos;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.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> Cross Device Play
</div>
<div class="pl-4">
You can use the above mentioned remote-backups to save progress
between devices/platforms.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Online Requirements
</div>
<div class="pl-4">
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.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Microtransactions
</div>
<div class="pl-4">
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.
</div>
</div>
</ol>
</RevealDropDown>
</div>
);
};
const renderTime = (time: number) => {
return (
<div class="timer">
<div class="value">{time.toFixed(0)}</div>
</div>
);
};
return (
<>
<Title>Contact | Michael Freno</Title>
<Meta name="description" content="Contact Me" />
<div class="flex min-h-screen w-full justify-center">
<div class="pt-[20vh]">
<div class="text-center text-3xl tracking-widest dark:text-white">
Contact
</div>
<Show when={viewer() !== "lineage"}>
<div class="mt-4 -mb-4 text-center text-xl tracking-widest dark:text-white">
(for this website or any of my apps...)
</div>
</Show>
<Show when={viewer() === "lineage"}>
<LineageQuestionsDropDown />
</Show>
<form onSubmit={sendEmailTrigger} class="min-w-[85vw] px-4">
<div
class={`flex w-full flex-col justify-evenly pt-6 ${
viewer() !== "lineage" ? "md:mt-24" : ""
}`}
>
<div class="mx-auto w-full justify-evenly md:flex md:w-3/4 md:flex-row lg:w-1/2">
<div class="input-group md:mx-4">
<input
type="text"
required
name="name"
value={user()?.displayName ?? ""}
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Name</label>
</div>
<div class="input-group md:mx-4">
<input
type="email"
required
name="email"
value={user()?.email ?? ""}
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Email</label>
</div>
</div>
<div class="mx-auto w-full pt-6 md:w-3/4 md:pt-12 lg:w-1/2">
<div class="textarea-group">
<textarea
required
name="message"
placeholder=" "
class="underlinedInput w-full bg-transparent"
rows={4}
/>
<span class="bar" />
<label class="underlinedInputLabel">Message</label>
</div>
</div>
<div class="mx-auto flex w-full justify-end pt-4 md:w-3/4 lg:w-1/2">
<Show
when={countDown() > 0}
fallback={
<button
type="submit"
disabled={loading()}
class={`${
loading()
? "bg-zinc-400"
: "bg-blue hover:brightness-125 active:scale-90"
} flex w-36 justify-center rounded py-3 text-base font-light transition-all duration-300 ease-out`}
>
<Show when={loading()} fallback="Send Message">
<LoadingSpinner height={24} width={24} />
</Show>
</button>
}
>
<CountdownCircleTimer
duration={60}
initialRemainingTime={countDown()}
size={48}
strokeWidth={6}
colors={"#60a5fa"}
onComplete={() => setCountDown(0)}
>
{renderTime}
</CountdownCircleTimer>
</Show>
</div>
</div>
</form>
<Show when={viewer() !== "lineage"}>
<LineageQuestionsDropDown />
</Show>
<div
class={`${
emailSent()
? "text-green-400"
: error() !== ""
? "text-red-400"
: "user-select opacity-0"
} flex justify-center text-center italic transition-opacity duration-300 ease-in-out`}
>
{emailSent() ? "Email Sent!" : error()}
</div>
<ul class="icons flex justify-center pt-24 pb-6">
<li>
<A
href="https://github.com/MikeFreno/"
target="_blank"
rel="noreferrer"
class="shaker rounded-full border-zinc-800 dark:border-zinc-300"
>
<span class="m-auto p-2">
<GitHub height={24} width={24} fill={undefined} />
</span>
</A>
</li>
<li>
<A
href="https://www.linkedin.com/in/michael-freno-176001256/"
target="_blank"
rel="noreferrer"
class="shaker rounded-full border-zinc-800 dark:border-zinc-300"
>
<span class="m-auto rounded-md p-2">
<LinkedIn height={24} width={24} fill={undefined} />
</span>
</A>
</li>
</ul>
</div>
</div>
</>
);
}

View File

@@ -2,111 +2,98 @@ import { A } from "@solidjs/router";
export default function PrivacyPolicy() {
return (
<div class="bg-zinc-100 dark:bg-zinc-900">
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">
Life and Lineage&apos;s Privacy Policy
</div>
<div class="py-2">Last Updated: October 22, 2024</div>
<div class="py-2">
Welcome to Life and Lineage (&apos;We&apos;, &apos;Us&apos;,
&apos;Our&apos;). 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.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> Personal Information
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Collection of Personal Data:</div>{" "}
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.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Data Removal:</div> Users can
request the removal of all information related to them by
visiting{" "}
<A
href="/deletion/life-and-lineage"
class="text-blue-400 underline-offset-4 hover:underline"
>
this page
</A>{" "}
and filling out the provided form.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> Third-Party Access
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Limited Third-Party Access:</div> 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.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> Security
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Data Protection:</div>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.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Changes to the Privacy
Policy
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Updates:</div> 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.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Contact Us
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Reaching Out:</div> If there are any
questions or comments regarding this privacy policy, you can
contact us{" "}
<A
href="/contact"
class="text-blue-400 underline-offset-4 hover:underline"
>
here
</A>
.
</div>
</div>
</ol>
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">Life and Lineage&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: October 22, 2024</div>
<div class="py-2">
Welcome to Life and Lineage (&apos;We&apos;, &apos;Us&apos;,
&apos;Our&apos;). 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.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> Personal Information
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Collection of Personal Data:</div> 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.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Data Removal:</div> Users can request the
removal of all information related to them by visiting{" "}
<A
href="/deletion/life-and-lineage"
class="text-blue hover-underline-animation"
>
this page
</A>{" "}
and filling out the provided form.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> Third-Party Access
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Limited Third-Party Access:</div> 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.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> Security
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Data Protection:</div>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.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Changes to the Privacy Policy
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Updates:</div> 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.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Contact Us
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Reaching Out:</div> If there are any
questions or comments regarding this privacy policy, you can contact
us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
.
</div>
</div>
</ol>
</div>
);
}

View File

@@ -24,6 +24,7 @@ export const blogRouter = createTRPCRouter({
p.published,
p.category,
p.author_id,
p.banner_photo,
p.reads,
COUNT(DISTINCT pl.user_id) as total_likes,
COUNT(DISTINCT c.id) as total_comments

View File

@@ -1,6 +1,11 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { z } from "zod";
import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import {
S3Client,
GetObjectCommand,
PutObjectCommand,
DeleteObjectCommand
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
@@ -11,7 +16,7 @@ import { getCookie, setCookie } from "vinxi/http";
const assets: Record<string, string> = {
"shapes-with-abigail": "shapes-with-abigail.apk",
"magic-delve": "magic-delve.apk",
cork: "Cork.zip",
cork: "Cork.zip"
};
export const miscRouter = createTRPCRouter({
@@ -25,35 +30,37 @@ export const miscRouter = createTRPCRouter({
const bucket = "frenomedownloads";
const params = {
Bucket: bucket,
Key: assets[input.asset_name],
Key: assets[input.asset_name]
};
if (!assets[input.asset_name]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Asset not found",
message: "Asset not found"
});
}
const credentials = {
accessKeyId: env._AWS_ACCESS_KEY,
secretAccessKey: env._AWS_SECRET_KEY,
secretAccessKey: env._AWS_SECRET_KEY
};
try {
const client = new S3Client({
region: env.AWS_REGION,
credentials: credentials,
credentials: credentials
});
const command = new GetObjectCommand(params);
const signedUrl = await getSignedUrl(client, command, { expiresIn: 120 });
const signedUrl = await getSignedUrl(client, command, {
expiresIn: 120
});
return { downloadURL: signedUrl };
} catch (error) {
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate download URL",
message: "Failed to generate download URL"
});
}
}),
@@ -63,21 +70,23 @@ export const miscRouter = createTRPCRouter({
// ============================================================
getPreSignedURL: publicProcedure
.input(z.object({
type: z.string(),
title: z.string(),
filename: z.string(),
}))
.input(
z.object({
type: z.string(),
title: z.string(),
filename: z.string()
})
)
.mutation(async ({ input }) => {
const credentials = {
accessKeyId: env._AWS_ACCESS_KEY,
secretAccessKey: env._AWS_SECRET_KEY,
secretAccessKey: env._AWS_SECRET_KEY
};
try {
const client = new S3Client({
region: env.AWS_REGION,
credentials: credentials,
credentials: credentials
});
const Key = `${input.type}/${input.title}/${input.filename}`;
@@ -86,38 +95,42 @@ export const miscRouter = createTRPCRouter({
const s3params = {
Bucket: env.AWS_S3_BUCKET_NAME,
Key,
ContentType: `image/${ext![1]}`,
ContentType: `image/${ext![1]}`
};
const command = new PutObjectCommand(s3params);
const signedUrl = await getSignedUrl(client, command, { expiresIn: 120 });
const signedUrl = await getSignedUrl(client, command, {
expiresIn: 120
});
return { uploadURL: signedUrl, key: Key };
} catch (error) {
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate pre-signed URL",
message: "Failed to generate pre-signed URL"
});
}
}),
deleteImage: publicProcedure
.input(z.object({
key: z.string(),
newAttachmentString: z.string(),
type: z.string(),
id: z.number(),
}))
.input(
z.object({
key: z.string(),
newAttachmentString: z.string(),
type: z.string(),
id: z.number()
})
)
.mutation(async ({ input }) => {
try {
const s3params = {
Bucket: env.AWS_S3_BUCKET_NAME,
Key: input.key,
Key: input.key
};
const client = new S3Client({
region: env.AWS_REGION,
region: env.AWS_REGION
});
const command = new DeleteObjectCommand(s3params);
@@ -127,7 +140,7 @@ export const miscRouter = createTRPCRouter({
const query = `UPDATE ${input.type} SET attachments = ? WHERE id = ?`;
await conn.execute({
sql: query,
args: [input.newAttachmentString, input.id],
args: [input.newAttachmentString, input.id]
});
return res;
@@ -135,7 +148,7 @@ export const miscRouter = createTRPCRouter({
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete image",
message: "Failed to delete image"
});
}
}),
@@ -146,11 +159,11 @@ export const miscRouter = createTRPCRouter({
try {
const s3params = {
Bucket: env.AWS_S3_BUCKET_NAME,
Key: input.key,
Key: input.key
};
const client = new S3Client({
region: env.AWS_REGION,
region: env.AWS_REGION
});
const command = new DeleteObjectCommand(s3params);
@@ -161,7 +174,7 @@ export const miscRouter = createTRPCRouter({
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete image",
message: "Failed to delete image"
});
}
}),
@@ -181,16 +194,18 @@ export const miscRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to hash password",
message: "Failed to hash password"
});
}
}),
checkPassword: publicProcedure
.input(z.object({
password: z.string(),
hash: z.string(),
}))
.input(
z.object({
password: z.string(),
hash: z.string()
})
)
.mutation(async ({ input }) => {
try {
const match = await bcrypt.compare(input.password, input.hash);
@@ -198,7 +213,78 @@ export const miscRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to check password",
message: "Failed to check password"
});
}
}),
// ============================================================
// Contact Form
// ============================================================
sendContactRequest: publicProcedure
.input(
z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(1).max(500)
})
)
.mutation(async ({ input }) => {
// Check if contact request was recently sent
const contactExp = getCookie("contactRequestSent");
let remaining = 0;
if (contactExp) {
const expires = new Date(contactExp);
remaining = expires.getTime() - Date.now();
}
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired"
});
}
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
const sendinblueData = {
sender: {
name: "freno.me",
email: "michael@freno.me"
},
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: ${input.name}</div><div>Request Email: ${input.email}</div><div>Request Message: ${input.message}</div></body></html>`,
subject: "freno.me Contact Request"
};
try {
await fetch(apiUrl, {
method: "POST",
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json"
},
body: JSON.stringify(sendinblueData)
});
// Set cookie to prevent spam (60 second cooldown)
const exp = new Date(Date.now() + 1 * 60 * 1000);
setCookie("contactRequestSent", exp.toUTCString(), {
expires: exp,
path: "/"
});
return { message: "email sent" };
} catch (error) {
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"SMTP server error: Sorry! You can reach me at michael@freno.me"
});
}
}),
@@ -222,7 +308,7 @@ export const miscRouter = createTRPCRouter({
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired",
message: "countdown not expired"
});
}
@@ -233,22 +319,22 @@ export const miscRouter = createTRPCRouter({
const sendinblueMyData = {
sender: {
name: "freno.me",
email: "michael@freno.me",
email: "michael@freno.me"
},
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Request Email: ${input.email}</div></body></html>`,
subject: "Life and Lineage Acct Deletion",
subject: "Life and Lineage Acct Deletion"
};
// Email to user
const sendinblueUserData = {
sender: {
name: "freno.me",
email: "michael@freno.me",
email: "michael@freno.me"
},
to: [{ email: input.email }],
htmlContent: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Account to delete: ${input.email}</div><div>You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation"</div></body></html>`,
subject: "Life and Lineage Acct Deletion",
subject: "Life and Lineage Acct Deletion"
};
try {
@@ -258,9 +344,9 @@ export const miscRouter = createTRPCRouter({
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json",
"content-type": "application/json"
},
body: JSON.stringify(sendinblueMyData),
body: JSON.stringify(sendinblueMyData)
});
await fetch(apiUrl, {
@@ -268,16 +354,16 @@ export const miscRouter = createTRPCRouter({
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json",
"content-type": "application/json"
},
body: JSON.stringify(sendinblueUserData),
body: JSON.stringify(sendinblueUserData)
});
// Set cookie to prevent spam (60 second cooldown)
const exp = new Date(Date.now() + 1 * 60 * 1000);
setCookie("deletionRequestSent", exp.toUTCString(), {
expires: exp,
path: "/",
path: "/"
});
return { message: "request sent" };
@@ -285,8 +371,9 @@ export const miscRouter = createTRPCRouter({
console.error(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "SMTP server error: Sorry! You can reach me at michael@freno.me",
message:
"SMTP server error: Sorry! You can reach me at michael@freno.me"
});
}
}),
})
});