most done, outside the blog

This commit is contained in:
Michael Freno
2025-12-17 01:11:14 -05:00
parent 81969ae907
commit 7479378dd1
17 changed files with 2116 additions and 18 deletions

2
.gitignore vendored
View File

@@ -23,7 +23,7 @@ tasks
# Temp # Temp
gitignore gitignore
#*_migration_source *_migration_source
# System Files # System Files
.DS_Store .DS_Store

BIN
bun.lockb

Binary file not shown.

View File

@@ -10,6 +10,7 @@
"@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/client-s3": "^3.953.0",
"@aws-sdk/s3-request-presigner": "^3.953.0", "@aws-sdk/s3-request-presigner": "^3.953.0",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@motionone/solid": "^10.16.4",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0", "@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0", "@solidjs/start": "^1.1.0",
@@ -21,6 +22,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"google-auth-library": "^10.5.0", "google-auth-library": "^10.5.0",
"jose": "^6.1.3", "jose": "^6.1.3",
"motion": "^12.23.26",
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"valibot": "^0.29.0", "valibot": "^0.29.0",

View File

@@ -5,9 +5,11 @@ import "./app.css";
import { LeftBar, RightBar } from "./components/Bars"; import { LeftBar, RightBar } from "./components/Bars";
import { TerminalSplash } from "./components/TerminalSplash"; import { TerminalSplash } from "./components/TerminalSplash";
import { SplashProvider } from "./context/splash"; import { SplashProvider } from "./context/splash";
import { MetaProvider } from "@solidjs/meta";
export default function App() { export default function App() {
return ( return (
<MetaProvider>
<SplashProvider> <SplashProvider>
<div> <div>
<TerminalSplash /> <TerminalSplash />
@@ -26,5 +28,6 @@ export default function App() {
</Router> </Router>
</div> </div>
</SplashProvider> </SplashProvider>
</MetaProvider>
); );
} }

View File

@@ -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) => (
<div
class="absolute"
style={{
left: `${groupOffset * props.caveParallax.size.width * props.imagesNeeded}px`,
width: `${props.caveParallax.size.width * props.imagesNeeded}px`,
height: `${props.caveParallax.size.height}px`,
}}
>
{Array.from({ length: props.imagesNeeded }).map((_, index) => (
<div
class="absolute"
style={{
width: `${props.caveParallax.size.width}px`,
height: `${props.caveParallax.size.height}px`,
left: `${index * props.caveParallax.size.width}px`,
}}
>
<img
src={props.caveParallax.imageSet[props.layer]}
alt={`Parallax layer ${props.layer}`}
width={props.caveParallax.size.width}
height={props.caveParallax.size.height}
style={{ "object-fit": "cover" }}
loading={props.layer > Object.keys(props.caveParallax.imageSet).length - 3 ? "eager" : "lazy"}
/>
</div>
))}
</div>
));
});
return (
<div
ref={containerRef}
class="absolute"
style={containerStyle()}
>
{imageGroups()}
</div>
);
}
const SimpleParallax: ParentComponent = (props) => {
let containerRef: HTMLDivElement | undefined;
const [dimensions, setDimensions] = createSignal({ width: 0, height: 0 });
const [direction, setDirection] = createSignal(1);
const caveParallax = createMemo<ParallaxBackground>(() => ({
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<typeof setTimeout>;
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 (
<ParallaxLayer
layer={layerIndex}
caveParallax={cave}
dimensions={dims}
scale={calc.scale}
scaledWidth={calc.scaledWidth}
scaledHeight={calc.scaledHeight}
verticalOffsetPixels={calc.verticalOffsetPixels}
imagesNeeded={imagesNeeded}
direction={dir}
/>
);
});
});
const resolved = resolveChildren(() => props.children);
return (
<div
ref={containerRef}
class="fixed inset-0 w-screen h-screen overflow-hidden"
>
<div class="absolute inset-0 bg-black"></div>
<div
class="absolute inset-0"
style={{
"margin-top": `${calculations().verticalOffsetPixels}px`,
}}
>
{parallaxLayers()}
</div>
<div class="relative z-10 h-full w-full">{resolved()}</div>
<style>{`
html,
body {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
`}</style>
</div>
);
};
export default SimpleParallax;

View File

@@ -0,0 +1,71 @@
import { Component } from "solid-js";
const DownloadOnAppStore: Component<{ size: number }> = (props) => {
return (
<svg
id="livetype"
xmlns="http://www.w3.org/2000/svg"
width={props.size * 3}
height={props.size}
viewBox="0 0 119.66407 40"
>
<title>Download_on_the_App_Store_Badge</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" />
<path
d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"
fill="#fff"
/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path
id="_Path_"
data-name="&lt;Path&gt;"
d="M24.99671,19.88935a5.14625,5.14625,0,0,1,2.45058-4.31771,5.26776,5.26776,0,0,0-4.15039-2.24376c-1.74624-.1833-3.43913,1.04492-4.329,1.04492-.90707,0-2.27713-1.02672-3.75247-.99637a5.52735,5.52735,0,0,0-4.65137,2.8367c-2.01111,3.482-.511,8.59939,1.41551,11.414.96388,1.37823,2.09037,2.91774,3.56438,2.86315,1.4424-.05983,1.98111-.91977,3.7222-.91977,1.72494,0,2.23035.91977,3.73427.88506,1.54777-.02512,2.52292-1.38435,3.453-2.77563a11.39931,11.39931,0,0,0,1.579-3.21589A4.97284,4.97284,0,0,1,24.99671,19.88935Z"
/>
<path
id="_Path_2"
data-name="&lt;Path&gt;"
d="M22.15611,11.47681a5.06687,5.06687,0,0,0,1.159-3.62989,5.15524,5.15524,0,0,0-3.33555,1.72582,4.82131,4.82131,0,0,0-1.18934,3.4955A4.26259,4.26259,0,0,0,22.15611,11.47681Z"
/>
</g>
</g>
<g>
<path d="M42.30178,27.13965h-4.7334l-1.13672,3.35645H34.42678l4.4834-12.418h2.083l4.4834,12.418H43.43752Zm-4.24316-1.54883h3.752L39.961,20.14355H39.9092Z" />
<path d="M55.1592,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238h1.79883v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.64455,21.34766,55.1592,23.16406,55.1592,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30178,29.01563,53.249,27.81934,53.249,25.96973Z" />
<path d="M65.12453,25.96973c0,2.81348-1.50635,4.62109-3.77881,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238h1.79883v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C63.6094,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91064,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26662,29.01563,63.21389,27.81934,63.21389,25.96973Z" />
<path d="M71.70949,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51758-3.61426,2.625,0,4.42383,1.47168,4.48438,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60547,1.626,3.60547,3.44238,0,2.32324-1.84961,3.77832-4.793,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" />
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" />
<path d="M86.064,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72512,30.6084,86.064,28.82617,86.064,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40137,1.16211-2.40137,3.10742c0,1.96191.89551,3.10645,2.40137,3.10645S92.7593,27.93164,92.7593,25.96973Z" />
<path d="M96.18508,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" />
<path d="M109.38332,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10109,25.13477Z" />
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" />
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" />
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" />
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" />
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" />
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" />
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" />
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" />
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" />
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" />
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" />
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" />
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" />
</g>
</g>
</g>
</svg>
);
};
export default DownloadOnAppStore;

View File

@@ -0,0 +1,129 @@
export default function DownloadOnAppStoreDark(props: { size: number }) {
return (
<svg
id="livetype"
xmlns="http://www.w3.org/2000/svg"
width={props.size * 3}
height={props.size}
viewBox="0 0 119.66407 40"
>
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path
d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z"
fill="#a6a6a6"
/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z" />
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path
id="_Path_"
data-name="&lt;Path&gt;"
d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z"
fill="#fff"
/>
<path
id="_Path_2"
data-name="&lt;Path&gt;"
d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z"
fill="#fff"
/>
</g>
</g>
<g>
<path
d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z"
fill="#fff"
/>
<path
d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z"
fill="#fff"
/>
<path
d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z"
fill="#fff"
/>
<path
d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z"
fill="#fff"
/>
<path
d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z"
fill="#fff"
/>
<path
d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z"
fill="#fff"
/>
<path
d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z"
fill="#fff"
/>
<path
d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z"
fill="#fff"
/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path
d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z"
fill="#fff"
/>
<path
d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z"
fill="#fff"
/>
<path
d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z"
fill="#fff"
/>
<path
d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z"
fill="#fff"
/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" fill="#fff" />
<path
d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z"
fill="#fff"
/>
<path
d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z"
fill="#fff"
/>
<path
d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z"
fill="#fff"
/>
<path
d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z"
fill="#fff"
/>
<path
d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z"
fill="#fff"
/>
<path
d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z"
fill="#fff"
/>
<path
d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z"
fill="#fff"
/>
<path
d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z"
fill="#fff"
/>
</g>
</g>
</g>
</svg>
);
}

View File

@@ -0,0 +1,26 @@
import { Component } from "solid-js";
const LinkedIn: Component<{
height: string | number;
width: string | number;
fill?: string;
stroke?: string;
}> = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512"
height={props.height}
width={props.width}
fill={props.fill || ""}
class={props.fill ? "" : "fill-[#0077B5]"}
>
<path
stroke={props.stroke}
d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z"
/>
</svg>
);
};
export default LinkedIn;

52
src/lib/s3upload.ts Normal file
View File

@@ -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<string | undefined> {
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;
}
}

624
src/routes/account.tsx Normal file
View File

@@ -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<UserProfile | null>(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 (
<div class="mx-8 min-h-screen md:mx-24 lg:mx-36 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
<div class="pt-24">
<Show
when={!loading() && user()}
fallback={
<div class="w-full mt-[35vh] flex justify-center">
<div class="text-xl">Loading...</div>
</div>
}
>
{(currentUser) => (
<>
<div class="text-center text-3xl font-bold mb-8 text-slate-800 dark:text-slate-100">
Account Settings
</div>
{/* Email Section */}
<div class="mx-auto flex flex-col md:grid md:grid-cols-2 gap-6 max-w-4xl">
<div class="flex justify-center text-lg md:justify-normal items-center">
<div class="flex flex-col lg:flex-row">
<div class="whitespace-nowrap pr-1 font-semibold">Current email:</div>
{currentUser().email ? (
<span>{currentUser().email}</span>
) : (
<span class="font-light italic underline underline-offset-4">None Set</span>
)}
</div>
<Show when={currentUser().email && !currentUser().emailVerified}>
<button
onClick={sendEmailVerification}
class="ml-2 text-red-500 hover:text-red-600 text-sm underline"
>
Verify Email
</button>
</Show>
</div>
<form onSubmit={setEmailTrigger} class="mx-auto">
<div class="input-group mx-4">
<input
ref={emailRef}
type="email"
required
disabled={emailButtonLoading() || (currentUser().email !== null && !currentUser().emailVerified)}
placeholder=" "
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Set New Email</label>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={emailButtonLoading() || (currentUser().email !== null && !currentUser().emailVerified)}
class={`${
emailButtonLoading() || (currentUser().email !== null && !currentUser().emailVerified)
? "bg-zinc-400 cursor-not-allowed"
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
} mt-2 flex justify-center rounded px-4 py-2 text-white transition-all duration-300 ease-out`}
>
{emailButtonLoading() ? "Submitting..." : "Submit"}
</button>
</div>
<Show when={showEmailSuccess()}>
<div class="text-green-500 text-sm text-center mt-2">Email updated!</div>
</Show>
</form>
{/* Display Name Section */}
<div class="flex justify-center text-lg md:justify-normal items-center">
<div class="flex flex-col lg:flex-row">
<div class="whitespace-nowrap pr-1 font-semibold">Display Name:</div>
{currentUser().displayName ? (
<span>{currentUser().displayName}</span>
) : (
<span class="font-light italic underline underline-offset-4">None Set</span>
)}
</div>
</div>
<form onSubmit={setDisplayNameTrigger} class="mx-auto">
<div class="input-group mx-4">
<input
ref={displayNameRef}
type="text"
required
disabled={displayNameButtonLoading()}
placeholder=" "
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">
Set {currentUser().displayName ? "New " : ""}Display Name
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={displayNameButtonLoading()}
class={`${
displayNameButtonLoading()
? "bg-zinc-400 cursor-not-allowed"
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
} mt-2 flex justify-center rounded px-4 py-2 text-white transition-all duration-300 ease-out`}
>
{displayNameButtonLoading() ? "Submitting..." : "Submit"}
</button>
</div>
<Show when={showDisplayNameSuccess()}>
<div class="text-green-500 text-sm text-center mt-2">Display name updated!</div>
</Show>
</form>
</div>
{/* Password Change/Set Section */}
<form onSubmit={handlePasswordSubmit} class="mt-8 flex w-full justify-center">
<div class="flex flex-col justify-center max-w-md w-full">
<div class="text-center text-xl font-semibold mb-4">
{currentUser().hasPassword ? "Change Password" : "Set Password"}
</div>
<Show when={currentUser().hasPassword}>
<div class="input-group mx-4 relative mb-6">
<input
ref={oldPasswordRef}
type={showOldPasswordInput() ? "text" : "password"}
required
disabled={passwordChangeLoading()}
placeholder=" "
class="underlinedInput w-full bg-transparent pr-10"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Old Password</label>
<button
type="button"
onClick={() => setShowOldPasswordInput(!showOldPasswordInput())}
class="absolute right-0 top-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
<Show when={showOldPasswordInput()} fallback={<Eye />}>
<EyeSlash />
</Show>
</button>
</div>
</Show>
<div class="input-group mx-4 relative mb-2">
<input
ref={newPasswordRef}
type={showPasswordInput() ? "text" : "password"}
required
onInput={handleNewPasswordChange}
onBlur={handlePasswordBlur}
disabled={passwordChangeLoading()}
placeholder=" "
class="underlinedInput w-full bg-transparent pr-10"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">New Password</label>
<button
type="button"
onClick={() => setShowPasswordInput(!showPasswordInput())}
class="absolute right-0 top-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
<Show when={showPasswordInput()} fallback={<Eye />}>
<EyeSlash />
</Show>
</button>
</div>
<Show when={showPasswordLengthWarning()}>
<div class="text-red-500 text-sm text-center mb-4">
Password too short! Min Length: 8
</div>
</Show>
<div class="input-group mx-4 relative mb-2">
<input
ref={newPasswordConfRef}
type={showPasswordConfInput() ? "text" : "password"}
required
onInput={handlePasswordConfChange}
disabled={passwordChangeLoading()}
placeholder=" "
class="underlinedInput w-full bg-transparent pr-10"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Password Confirmation</label>
<button
type="button"
onClick={() => setShowPasswordConfInput(!showPasswordConfInput())}
class="absolute right-0 top-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
<Show when={showPasswordConfInput()} fallback={<Eye />}>
<EyeSlash />
</Show>
</button>
</div>
<Show when={!passwordsMatch() && passwordLengthSufficient() && newPasswordConfRef && newPasswordConfRef.value.length >= 6}>
<div class="text-red-500 text-sm text-center mb-4">
Passwords do not match!
</div>
</Show>
<button
type="submit"
disabled={passwordChangeLoading() || !passwordsMatch()}
class={`${
passwordChangeLoading() || !passwordsMatch()
? "bg-zinc-400 cursor-not-allowed"
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
} my-6 flex justify-center rounded px-4 py-2 text-white transition-all duration-300 ease-out`}
>
{passwordChangeLoading() ? "Setting..." : "Set"}
</button>
<Show when={passwordError()}>
<div class="text-red-500 text-sm text-center">
{currentUser().hasPassword
? "Password did not match record"
: "Error setting password"}
</div>
</Show>
<Show when={showPasswordSuccess()}>
<div class="text-green-500 text-sm text-center">
Password {currentUser().hasPassword ? "changed" : "set"} successfully!
</div>
</Show>
</div>
</form>
<hr class="mt-8 mb-8" />
{/* Delete Account Section */}
<div class="py-8 max-w-2xl mx-auto">
<div class="w-full rounded-md bg-red-300 px-6 pb-4 pt-8 shadow-md dark:bg-red-950">
<div class="pb-4 text-center text-xl font-semibold">Delete Account</div>
<div class="text-center text-sm mb-4 text-red-700 dark:text-red-300">
Warning: This will delete all account information and is irreversible
</div>
<form onSubmit={deleteAccountTrigger}>
<div class="flex w-full justify-center">
<div class="input-group delete mx-4">
<input
ref={deleteAccountPasswordRef}
type="password"
required
disabled={deleteAccountButtonLoading()}
placeholder=" "
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Enter Password</label>
</div>
</div>
<button
type="submit"
disabled={deleteAccountButtonLoading()}
class={`${
deleteAccountButtonLoading()
? "bg-zinc-400 cursor-not-allowed"
: "bg-red-500 hover:bg-red-600 active:scale-90 dark:bg-red-600 dark:hover:bg-red-700"
} mx-auto mt-4 flex justify-center rounded px-4 py-2 text-white transition-all duration-300 ease-out`}
>
{deleteAccountButtonLoading() ? "Deleting..." : "Delete Account"}
</button>
<Show when={passwordDeletionError()}>
<div class="text-red-500 text-sm text-center mt-2">
Password did not match record
</div>
</Show>
</form>
</div>
</div>
</>
)}
</Show>
</div>
</div>
);
}

167
src/routes/downloads.tsx Normal file
View File

@@ -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 (
<div class="pb-12 pt-[15vh] bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 min-h-screen">
<div class="text-center text-3xl tracking-widest dark:text-white">
Downloads
</div>
<div class="pt-12">
<div class="text-center text-xl tracking-wide dark:text-white">
Life and Lineage
<br />
</div>
<div class="flex justify-evenly md:mx-[25vw]">
<div class="flex flex-col w-1/3">
<div class="text-center text-lg">Android (apk only)</div>
<button
onClick={() => download("lineage")}
class="mt-2 rounded-md bg-blue-500 px-4 py-2 text-white shadow-lg transition-all duration-200 ease-out hover:opacity-90 active:scale-95 active:opacity-90"
>
Download APK
</button>
<div class="text-center italic text-sm mt-2">
Note the android version is not well tested, and has performance
issues.
</div>
<div class="rule-around">Or</div>
<div class="italic mx-auto">(Coming soon)</div>
<button
onClick={joinBetaPrompt}
class="transition-all mx-auto duration-200 ease-out active:scale-95"
>
<img
src="/google-play-badge.png"
alt="google-play"
width={180}
height={60}
/>
</button>
</div>
<div class="flex flex-col">
<div class="text-center text-lg">iOS</div>
<A
class="my-auto transition-all duration-200 ease-out active:scale-95"
href="https://apps.apple.com/us/app/life-and-lineage/id6737252442"
>
<DownloadOnAppStore size={50} />
</A>
</div>
</div>
</div>
<div class="pt-12">
<div class="text-center text-xl tracking-wide dark:text-white">
Shapes with Abigail!
<br />
(apk and iOS)
</div>
<div class="flex justify-evenly md:mx-[25vw]">
<div class="flex flex-col">
<div class="text-center text-lg">Android</div>
<button
onClick={() => download("shapes-with-abigail")}
class="mt-2 rounded-md bg-blue-500 px-4 py-2 text-white shadow-lg transition-all duration-200 ease-out hover:opacity-90 active:scale-95 active:opacity-90"
>
Download APK
</button>
<div class="rule-around">Or</div>
<div class="italic mx-auto">(Coming soon)</div>
<button
onClick={joinBetaPrompt}
class="transition-all duration-200 ease-out active:scale-95"
>
<img
src="/google-play-badge.png"
alt="google-play"
width={180}
height={60}
/>
</button>
</div>
<div class="flex flex-col">
<div class="text-center text-lg">iOS</div>
<A
class="my-auto transition-all duration-200 ease-out active:scale-95"
href="https://apps.apple.com/us/app/shapes-with-abigail/id6474561117"
>
<DownloadOnAppStore size={50} />
</A>
</div>
</div>
<div class="pt-12">
<div class="text-center text-xl tracking-wide dark:text-white">
Cork
<br />
(macOS 13 Ventura or later)
</div>
<div class="flex justify-center">
<button
onClick={() => download("cork")}
class="my-2 rounded-md bg-blue-500 px-4 py-2 text-white shadow-lg transition-all duration-200 ease-out hover:opacity-90 active:scale-95 active:opacity-90"
>
Download app
</button>
</div>
<div class="text-center text-sm">
Just unzip and drag into 'Applications' folder
</div>
</div>
<ul class="icons flex justify-center pb-6 pt-24 gap-4">
<li>
<A
href="https://github.com/MikeFreno/"
target="_blank"
rel="noreferrer"
class="shaker rounded-full border border-zinc-800 dark:border-zinc-300 inline-block hover:scale-110 transition-transform"
>
<span class="m-auto p-2 block">
<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 border-zinc-800 dark:border-zinc-300 inline-block hover:scale-110 transition-transform"
>
<span class="m-auto rounded-md p-2 block">
<LinkedIn height={24} width={24} fill={undefined} />
</span>
</A>
</li>
</ul>
</div>
</div>
);
}

View File

@@ -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 (
<div class="timer text-center">
<div class="text-sm text-slate-700 dark:text-slate-300">Change Successful!</div>
<div class="value py-1 text-3xl text-blue-500 dark:text-blue-400">{timeRemaining}</div>
<div class="text-sm text-slate-700 dark:text-slate-300">Redirecting...</div>
</div>
);
};
return (
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
<div class="pt-24 text-center text-xl font-semibold text-slate-800 dark:text-slate-100">
Set New Password
</div>
<form
onSubmit={(e) => setNewPasswordTrigger(e)}
class="mt-4 flex w-full justify-center"
>
<div class="flex flex-col justify-center max-w-md w-full px-4">
{/* New Password Input */}
<div class="input-group mx-4 relative">
<input
ref={newPasswordRef}
name="newPassword"
type={showPasswordInput() ? "text" : "password"}
required
onInput={handleNewPasswordChange}
onBlur={handlePasswordBlur}
disabled={passwordChangeLoading()}
placeholder=" "
class="underlinedInput w-full bg-transparent pr-10"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">New Password</label>
<button
type="button"
onClick={() => setShowPasswordInput(!showPasswordInput())}
class="absolute right-0 top-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
<Show when={showPasswordInput()} fallback={<Eye />}>
<EyeSlash />
</Show>
</button>
</div>
{/* Password Length Warning */}
<div
class={`${
showPasswordLengthWarning() ? "" : "select-none opacity-0"
} transition-opacity text-center text-red-500 text-sm duration-200 ease-in-out mt-2`}
>
Password too short! Min Length: 8
</div>
{/* Password Confirmation Input */}
<div class="input-group mx-4 mt-6 relative">
<input
ref={newPasswordConfRef}
name="newPasswordConf"
onInput={handlePasswordConfChange}
type={showPasswordConfInput() ? "text" : "password"}
required
disabled={passwordChangeLoading()}
placeholder=" "
class="underlinedInput w-full bg-transparent pr-10"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Password Confirmation</label>
<button
type="button"
onClick={() => setShowPasswordConfInput(!showPasswordConfInput())}
class="absolute right-0 top-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
<Show when={showPasswordConfInput()} fallback={<Eye />}>
<EyeSlash />
</Show>
</button>
</div>
{/* Password Mismatch Warning */}
<div
class={`${
!passwordsMatch() &&
passwordLengthSufficient() &&
newPasswordConfRef &&
newPasswordConfRef.value.length >= 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!
</div>
{/* Countdown Timer or Submit Button */}
<Show
when={countDown()}
fallback={
<button
type="submit"
disabled={passwordChangeLoading() || !passwordsMatch()}
class={`${
passwordChangeLoading() || !passwordsMatch()
? "bg-zinc-400 cursor-not-allowed"
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
} flex justify-center rounded transition-all duration-300 ease-out my-6 px-4 py-2 text-white font-medium`}
>
{passwordChangeLoading() ? "Setting..." : "Set New Password"}
</button>
}
>
<div class="mx-auto pt-4">
<CountdownCircleTimer
isPlaying={countDown()}
duration={5}
size={200}
strokeWidth={12}
colors="#60a5fa"
onComplete={() => false}
>
{({ remainingTime }) => renderTime(remainingTime)}
</CountdownCircleTimer>
</div>
</Show>
</div>
</form>
{/* Error Message */}
<Show when={error() && !showRequestNewEmail()}>
<div class="flex justify-center mt-4">
<div class="text-red-500 text-sm italic">{error()}</div>
</div>
</Show>
{/* Token Expired Message */}
<div
class={`${
showRequestNewEmail() ? "" : "select-none opacity-0"
} text-red-500 italic transition-opacity flex justify-center duration-300 ease-in-out px-4`}
>
Token has expired, request a new one{" "}
<A
class="pl-1 text-blue-500 underline underline-offset-4 hover:text-blue-400"
href="/login/request-password-reset"
>
here
</A>
</div>
{/* Back to Login Link */}
<Show when={!countDown()}>
<div class="flex justify-center mt-6">
<A
href="/login"
class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 underline underline-offset-4 transition-colors"
>
Back to Login
</A>
</div>
</Show>
</div>
);
}

View File

@@ -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 (
<div class="timer">
<div class="value">{countDown().toFixed(0)}</div>
</div>
);
};
return (
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
<div class="pt-24 text-center text-xl font-semibold text-slate-800 dark:text-slate-100">
Password Reset Request
</div>
<form
onSubmit={(e) => requestPasswordResetTrigger(e)}
class="mt-4 flex w-full justify-center"
>
<div class="flex flex-col justify-center">
{/* Email Input */}
<div class="input-group mx-4">
<input
ref={emailRef}
name="email"
type="text"
required
disabled={loading()}
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Enter Email</label>
</div>
{/* Countdown Timer or Submit Button */}
<Show
when={countDown() > 0}
fallback={
<button
type="submit"
disabled={loading()}
class={`${
loading()
? "bg-zinc-400"
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
} flex justify-center rounded transition-all duration-300 ease-out my-6 px-4 py-2 text-white font-medium`}
>
{loading() ? "Sending..." : "Request Password Reset"}
</button>
}
>
<div class="mx-auto pt-4">
<CountdownCircleTimer
isPlaying={true}
duration={300}
initialRemainingTime={countDown()}
size={48}
strokeWidth={6}
colors="#60a5fa"
onComplete={() => false}
>
{renderTime}
</CountdownCircleTimer>
</div>
</Show>
</div>
</form>
{/* Success Message */}
<div
class={`${
showSuccessMessage() ? "" : "select-none opacity-0"
} text-green-500 italic transition-opacity flex justify-center duration-300 ease-in-out`}
>
If email exists, you will receive an email shortly!
</div>
{/* Error Message */}
<Show when={error()}>
<div class="flex justify-center mt-4">
<div class="text-red-500 text-sm italic">{error()}</div>
</div>
</Show>
{/* Back to Login Link */}
<div class="flex justify-center mt-6">
<A
href="/login"
class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 underline underline-offset-4 transition-colors"
>
Back to Login
</A>
</div>
</div>
);
}

View File

@@ -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 (
<SimpleParallax>
<div class="flex flex-col items-center justify-center h-full text-white">
<div>
<img
src="/LineageIcon.png"
alt="Lineage App Icon"
height={128}
width={128}
class="object-cover object-center"
/>
</div>
<h1 class="text-5xl font-bold mb-4 text-center">
Life and Lineage
</h1>
<p class="text-xl mb-8">A dark fantasy adventure</p>
<div class="flex space-x-4">
<a
class="my-auto transition-all duration-200 ease-out active:scale-95"
href="https://apps.apple.com/us/app/life-and-lineage/id6737252442"
target="_blank"
rel="noopener noreferrer"
>
<DownloadOnAppStoreDark size={50} />
</a>
<A
href="/downloads"
class="transition-all duration-200 ease-out active:scale-95"
>
<img
src="/google-play-badge.png"
alt="google-play"
width={180}
height={60}
/>
</A>
</div>
</div>
</SimpleParallax>
);
}

View File

@@ -0,0 +1,112 @@
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>
</div>
);
}

View File

@@ -0,0 +1,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-[8vh]">
<div class="py-4 text-xl">
Shapes with Abigail!&apos;s Privacy Policy
</div>
<div class="py-2">Last Updated: December 21, 2023</div>
<div class="py-2">
Welcome to Shapes with Abigail! (&apos;We&apos; , &apos;Us&apos;,
&apos;Our&apos;). Your privacy is important to us. For that reason,
our app, &quot;Shapes with Abigail!&quot; 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.
</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) Non-Collection of Personal Data:
</div>{" "}
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.
</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) No Third-Party Access:</div> Since we
do not collect or store any user data, there is no possibility
of sharing or selling our users&apos; information to third
parties. Our priority is the safety and privacy of our users.
</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) Secure Environment:</div>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.
</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. However, since we do not collect
any personal data, these updates are likely to be insignificant.
</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>
</div>
);
}