This commit is contained in:
2025-02-09 14:28:51 -05:00
parent d171572400
commit 1a51cd28c7
66 changed files with 4122 additions and 4298 deletions
-1
View File
@@ -1 +0,0 @@
VITE_API_BASE_URL="http://localhost:3001/v4"
-18
View File
@@ -1,18 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
+36 -20
View File
@@ -1,25 +1,41 @@
# Logs
logs
*.log
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# env files (can opt-in for committing if needed)
.env*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36 -2
View File
@@ -1,2 +1,36 @@
# Website
This is my website, [alexav.gg](https://alexav.gg)!
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
-35
View File
@@ -1,35 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta content="#000000" name="theme-color" />
<base href="/" />
<!-- metadata -->
<meta name="description" content="I am Alex Frantz, a junior developer working primarily as QA at Medal.tv. I do JavaScript and Python alongside learning Java and more." />
<meta name="author" content="Alex Frantz" />
<meta name="keywords" content="alex frantz, frantz, alex, qa, javascript, html, react, python, java" />
<!-- opengraph -->
<meta property="og:title" content="Alex Frantz" />
<meta property="og:description" content="I am Alex Frantz, a junior developer working primarily as QA at Medal.tv. I do JavaScript and Python alongside learning Java and more." />
<meta property="og:url" content="https://alexav.gg" />
<meta property="og:image" content="/fs-favicon.png" />
<!-- touch icons -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" type="image/png" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- plausible -->
<script defer data-domain="alexav.gg" src="https://metrics.alexav.gg/js/script.js"></script>
<title>Alex Frantz</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+3250 -2975
View File
File diff suppressed because it is too large Load Diff
+17 -28
View File
@@ -1,38 +1,27 @@
{
"name": "website",
"version": "0.1.0",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "cross-env SSR=true vite build --outDir dist/server"
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.13.1",
"compression": "^1.7.5",
"express": "^4.21.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.2"
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.1.6"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.19",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"ts-node": "^10.9.2",
"typescript": "^5.2.2",
"vite": "^5.2.0"
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^9",
"eslint-config-next": "15.1.6",
"@eslint/eslintrc": "^3"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before

Width:  |  Height:  |  Size: 801 B

After

Width:  |  Height:  |  Size: 801 B

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 222 KiB

@@ -13,6 +13,7 @@
"type": "image/png"
}
],
"start_url": ".",
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
-44
View File
@@ -1,44 +0,0 @@
import express, { Request, Response } from 'express'
import { createServer as createViteServer } from 'vite'
import compression from 'compression'
import fs from 'fs'
import path from 'path'
async function createServer() {
const app = express()
app.use(compression())
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom'
})
app.use(vite.middlewares)
app.use('*', async (req: Request, res: Response) => {
const url = req.originalUrl
try {
const template = fs.readFileSync(
path.resolve(process.cwd(), 'index.html'),
'utf-8'
)
const transformedTemplate = await vite.transformIndexHtml(url, template)
const { render } = await vite.ssrLoadModule('./src/entry-server.tsx')
const appHtml = await render(url)
const html = transformedTemplate.replace(`<!--app-html-->`, appHtml)
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
vite.ssrFixStacktrace(e as Error)
console.error(e)
res.status(500).end((e as Error).stack)
}
})
app.listen(3000, () => {
console.log('server started')
})
}
createServer()
-21
View File
@@ -1,21 +0,0 @@
import { Routes, Route } from 'react-router-dom'
import MainPage from './pages/index';
import PrivacyPage from './pages/privacy';
import MusicWidget from './pages/music';
import LinksPage from './pages/links';
import NotFound from './pages/NotFound';
import './assets/main.css'
export default function App() {
return (
<Routes>
<Route path="/" element={<MainPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/music" element={<MusicWidget />} />
<Route path="/links" element={<LinksPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
)
}
+27
View File
@@ -0,0 +1,27 @@
'use server'
export async function submitContact(formData: FormData) {
try {
const name = formData.get('name')
const email = formData.get('email')
const subject = formData.get('subject')
const message = formData.get('message')
const state = await fetch("https://api.alexav.gg/v4/contact", {
method: 'post',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
name,
email,
subject,
message
})
})
return { success: state.ok }
} catch (e) {
return { success: false, error: (e as Error).message }
}
}
+21
View File
@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
+65
View File
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
// components
import Header from "@/components/Header";
import Footer from "@/components/Footer";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Alex Frantz",
description: "I am Alex Frantz, a junior developer working primarily as QA at Medal.tv. I do JavaScript and Python alongside learning Java and more.",
keywords: "alex frantz, frantz, alex, qa, javascript, html, react, python, java",
openGraph: {
title: "Alex Frantz",
url: "https://alexav.gg",
description: "I am Alex Frantz, a junior developer working primarily as QA at Medal.tv. I do JavaScript and Python alongside learning Java and more."
},
appleWebApp: {
title: "Alex Frantz",
startupImage: "https://alexav.gg/apple-touch-icon.png"
},
icons: [
{
rel: "icon",
type: "image/png",
sizes: "32x32",
url: "https://alexav.gg/favicon-32x32.png"
},
{
rel: "icon",
type: "image/png",
sizes: "16x16",
url: "https://alexav.gg/favicon-16x16.png"
}
],
manifest: "https://alexav.gg/site.webmanifest"
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Header />
{children}
<Footer />
</body>
</html>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { Twitter, Bluesky, Medal, GitHub, LinkedIn } from '@/components/Icons'
import ProjectComponent from '@/components/ProjectComponent';
import ContactForm from '@/components/ContactForm';
// data components
import RecentWatch from "@/components/DataComponents/RecentWatch";
import RecentListen from "@/components/DataComponents/RecentListen";
import RecentGame from "@/components/DataComponents/RecentGame";
import RecentClip from "@/components/DataComponents/RecentClip";
import RecentBook from "@/components/DataComponents/RecentBook";
export default function Home() {
return (
<div className="">
<section id="about">
<div className="w-full mx-auto text-center p-10">
<div className="my-1">
<h1 className="text-3xl font-bold">Hi, I'm Alex</h1>
<p>I'm a junior developer primarily working as QA, but sometimes writing questionable code at <a className="hover:underline text-yellow-400" href="https://medal.tv" target="_blank">Medal.tv</a></p>
</div>
<div className="block space-x-2">
{/* socials */}
<a href="https://twitter.com/carlgrimesdupe" target="_blank">
<Twitter />
</a>
<a href="https://bsky.app/profile/alexav.gg" target="_blank">
<Bluesky />
</a>
<a href="https://medal.tv/users/215577" target="_blank">
<Medal />
</a>
<a href="https://github.com/azpha/website" target="_blank">
<GitHub />
</a>
<a href="https://linkedin.com/in/thatalex" target="_blank">
<LinkedIn />
</a>
</div>
<a href="/links" className="opacity-40 text-sm">
<p className="underline">and more..</p>
</a>
</div>
</section>
<section id="projects">
<div className="bg-zinc-900 py-8">
<h1 className="pb-8 text-3xl text-center font-bold">My Projects</h1>
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full md:w-[70%] lg:w-[50%] mx-auto">
<ProjectComponent
name="medaltv-wrapper"
description="A JavaScript wrapper for the Medal.tv developer API"
urlTo="https://github.com/azpha/medaltv-wrapper"
tags={["JavaScript"]}
/>
<ProjectComponent
name="splitstat"
description="A Discord bot for SplitStat: Arena Warfare statistics"
urlTo="https://github.com/azpha/splitstat"
tags={["JavaScript", "DiscordJS"]}
/>
<ProjectComponent
name="steam-deck-file-sync"
description="Sync clips from Steam Deck"
urlTo="https://github.com/azpha/steam-deck-file-sync"
tags={["Python", "Flask"]}
/>
<ProjectComponent
name="subscription-tracker"
description="Track upcoming subscriptions"
urlTo="https://github.com/azpha/subscription-tracker"
tags={["JavaScript", "ReactJS", "Vite"]}
/>
</div>
</div>
</div>
</section>
<section id="data">
<div className="py-8">
<h1 className="pb-8 text-3xl text-center font-bold">My Data</h1>
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full md:w-[70%] lg:w-[50%] mx-auto">
<RecentWatch />
<RecentListen />
<RecentGame />
<RecentClip />
<RecentBook />
</div>
</div>
</div>
</section>
<section id="contact">
<div className="bg-zinc-900 py-8">
<ContactForm />
</div>
</section>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
export default function Privacy() {
return (
<div className="my-6 text-left w-full lg:w-1/4 mx-auto">
<h1 className="text-2xl font-bold">Privacy</h1>
<h1>This site doesn't collect anything on you. No cookies (although tasty), no ominous advertisements, nothing.</h1>
<h1 className="my-2 font-bold text-2xl">Plausible</h1>
<h1>I use <a className="underline" href="https://plausible.io" target="_blank">Plausible analytics</a> to track traffic anonymously. Plausible;</h1>
<ul className="list-disc list-inside">
<li>does not collect personal identifying information</li>
<li className="underline"><a href="https://plausible.io/privacy-focused-web-analytics" target="_blank">is dedicated to privacy</a></li>
<li>compliant with the GDPR, CCPA and PECR data protection laws</li>
</ul>
<h1>I use a self-hosted instance in the US that doesn't use cookies.</h1>
<h1 className="my-2 font-bold text-2xl">Cloudflare</h1>
<h1>I use <a className="underline" href="https://pages.cloudflare.com" target="_blank">Cloudflare Pages</a> for this site. Most of my sites
are proxied through Cloudflare as well. You can view their privacy policy <a className="underline" href="https://cloudflare.com/privacy" target="_blank">here</a>.</h1>
<h1 className="my-2 font-bold text-2xl">Email</h1>
<h1>When you submit a contact request on my page, it is sent to Discord via a webhook & my own API.</h1>
<h1>This will be shown to me in an embed. I will never receive or process anything but what you provide.</h1>
<h1>You can learn more about Discord's dedication to privacy <a className='underline' href="https://discord.com/privacy" target="_blank">here</a>.</h1>
</div>
)
}
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-30
View File
@@ -1,30 +0,0 @@
.snow-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1000;
}
.snowflake {
position: absolute;
top: -10px;
width: 8px;
height: 8px;
background: white;
border-radius: 50%;
animation-name: snowfall;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes snowfall {
0% {
transform: translateY(-10px) rotate(0deg);
}
100% {
transform: translateY(100vh) rotate(360deg);
}
}
+45 -90
View File
@@ -1,47 +1,30 @@
'use client';
import { useState, useEffect } from 'react';
import InputBox from './InputBox';
import { submitContact } from '@/actions/contact';
export default function ContactForm() {
const [ error, setError ] = useState<string>("");
const [ success, setSuccess ] = useState<boolean>(false);
const [ inputState, setInputState ] = useState<{name: string, email: string, subject: string, message: string}>({
name: "",
email: "",
subject: "",
message: ""
});
const [ pending, setPending ] = useState<boolean>(false);
const onSubmit = () => {
if (
inputState.name === ""
|| inputState.email === ""
|| inputState.subject === ""
|| inputState.message === ""
) {
setError("A required field was missing")
} else {
console.log("Form submitted")
const handleSubmission = async (formData: FormData) => {
setPending(true)
fetch(import.meta.env.VITE_API_BASE_URL + "/contact", {
method: "post",
headers: {
'content-type': 'application/json'
},
body: JSON.stringify(inputState)
}).then((res) => {
if (res.ok) {
setSuccess(true)
} else {
if (res.status === 429) {
setError("Too many tries! Try again later")
} else {
setError("An error occurred while submitting your info.")
}
}
}).catch((e) => {
console.error(e)
setError("An error occurred while submitting your info.")
})
try {
const result = await submitContact(formData)
if (result.error || !result.success) {
setError('An unexpected error occurred')
} else setSuccess(true)
const form = document.getElementById('contact-form') as HTMLFormElement
form.reset()
} catch (e) {
setError('An unexpected error occurred')
} finally {
setPending(false)
}
}
@@ -60,82 +43,54 @@ export default function ContactForm() {
<div className="rounded-lg p-2 w-full md:w-[50%] mx-auto">
<h1 className="text-2xl font-bold text-center pb-8">Contact Me</h1>
<div className="space-y-2 text-center">
<form id="contact-form" action={handleSubmission} className="space-y-2 text-center">
<InputBox
isText={true}
name="name"
placeholder="Your name.."
onChange={(value: string) => {
setInputState((prevState) => {
return {
...prevState,
name: value
}
})
}}
disabled={pending}
/>
<InputBox
name="email"
type="email"
isText={true}
placeholder="your@supercool.email"
onChange={(value: string) => {
setInputState((prevState) => {
return {
...prevState,
email: value
}
})
}}
disabled={pending}
/>
<InputBox
isText={true}
name="subject"
placeholder="Subject.."
onChange={(value: string) => {
setInputState((prevState) => {
return {
...prevState,
subject: value
}
})
}}
disabled={pending}
/>
<InputBox
isText={false}
name="message"
placeholder="Your message here.."
onChange={(value: string) => {
setInputState((prevState) => {
return {
...prevState,
message: value
}
})
}}
disabled={pending}
/>
</div>
<div className="block text-center">
<button
type="button"
className="bg-white w-full text-black font-bold rounded-lg max-w-fit p-2"
onClick={onSubmit}
>
Submit
</button>
{
error && (
<p className="text-red-400 font-bold text-center pt-2">{error}</p>
)
}
<div className="block text-center">
<button
type="submit"
className="bg-white w-full text-black font-bold rounded-lg max-w-fit p-2"
>
Submit
</button>
{
error && (
<p className="text-red-400 font-bold text-center pt-2">{error}</p>
)
}
{
success && (
<p className="font-bold text-green-500 text-center pt-2">Success! Your message has been received</p>
)
}
</div>
{
success && (
<p className="font-bold text-green-500 text-center pt-2">Success! Your message has been received</p>
)
}
</div>
</form>
</div>
)
}
-26
View File
@@ -1,26 +0,0 @@
export default function CustomButton({
to,
icon,
children
}: {
to: string,
icon?: JSX.Element,
children: string | JSX.Element | JSX.Element[]
}) {
return (
<div>
<a href={to} target="_blank">
<div className={`bg-black text-white text-lg font-semibold text-left p-2 w-52 rounded-lg`}>
<div className="px-1 space-x-2">
<div className="inline">
{ icon && icon }
</div>
<div className="inline">
{children}
</div>
</div>
</div>
</a>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
export default async function DataComponent({
url,
header,
projectHeader,
projectSubheader,
projectImage
}: {
url: string,
header: string,
projectHeader: string,
projectSubheader: string,
projectImage?: string
}) {
return (
<a href={url} target="_blank">
<div className="bg-white rounded-lg text-black p-2">
<h1 className="text-center font-semibold">{header}</h1>
<div className="block">
<div className="flex items-center">
{
projectImage && <img alt="Project Image" src={projectImage} width={100} height={100} className="w-[80px] h-[50px] object-cover" />
}
<div className="flex flex-col mx-2 min-w-0 flex-grow">
<h1 className="font-bold text-left truncate">{projectHeader}</h1>
<h1 className="text-left truncate">{projectSubheader}</h1>
</div>
</div>
</div>
</div>
</a>
)
}
@@ -0,0 +1,45 @@
import DataComponent from '../DataComponent';
import type { ContentTrackerItem } from '@/utils/types';
const fetchData = async () => {
const { data } = await fetch("https://api.alexav.gg/v4/tracker/latest/book", {
method: 'get'
}).then((res) => res.json())
return data
}
export default async function RecentBook() {
const data = await fetchData() as ContentTrackerItem
const getDateString = (startedOn: Date, finished: boolean) => {
if (startedOn && !finished) {
const startedOnDate = new Date(startedOn);
return "Started on " + startedOnDate.toLocaleDateString();
} else if (finished) {
return "Finished!"
} else {
return "Just Added"
}
}
if (data) {
return (
<DataComponent
header={"What I'm reading"}
projectHeader={data.title}
projectSubheader={getDateString(data.startedOn, data.finished)}
url={"https://tracker.alexav.gg/?type=books&id=" + data.id}
projectImage={data.image}
/>
)
} else {
return (
<DataComponent
header={"What I'm watching"}
projectHeader={"No data :("}
projectSubheader={"Nothing was returned"}
url={"/"}
/>
)
}
}
@@ -0,0 +1,46 @@
import DataComponent from '../DataComponent';
import type { MedalClip } from '@/utils/types';
const fetchData = async () => {
const { data } = await fetch("https://api.alexav.gg/v4/social/medal", {
method: 'get'
}).then((res) => res.json())
return data[0]
}
export default async function RecentClip() {
const data = await fetchData() as MedalClip
const translateToWebsiteUrl = (url: string) => {
return url.replace('/clip/', "/clips/")
}
// i'm a neat man what can i say
const likeOrLikes = (likes: number) => {
return (likes > 1 && likes !== 0) ? "likes" : "like"
}
const viewOrViews = (views: number) => {
return (views > 1 && views !== 0) ? "views" : "view"
}
if (data) {
return (
<DataComponent
url={translateToWebsiteUrl(data.directClipUrl)}
header={"Clips I've taken"}
projectHeader={data.contentTitle}
projectSubheader={`${data.contentLikes} ${likeOrLikes(data.contentLikes)} | ${data.contentViews} ${viewOrViews(data.contentViews)}`}
projectImage={data.contentThumbnail}
/>
)
} else {
return (
<DataComponent
header={"What I'm watching"}
projectHeader={"No data :("}
projectSubheader={"Nothing was returned"}
url={"/"}
/>
)
}
}
@@ -0,0 +1,43 @@
import DataComponent from '../DataComponent';
import type { GameData } from '@/utils/types';
const fetchData = async () => {
const { data } = await fetch("https://api.alexav.gg/v4/game", {
method: 'get'
}).then((res) => res.json())
return data
}
export default async function RecentGame() {
const data = await fetchData() as GameData
const getTime = (time: number) => {
const date = new Date(time*1000);
return `${date.toLocaleDateString()} at ${date.toLocaleTimeString()}`
}
if (data) {
return (
<DataComponent
url={"https://medal.tv/u/alexav"}
header={"Games I'm playing"}
projectHeader={data.name}
projectSubheader={
data.finished ? `Last played on ${getTime(data.startedOn)}`
: `Started on ${getTime(data.startedOn)}`
}
projectImage={"https://storage.alexav.gg/content/933d5523-5e20-4040-9fe9-b73552eac8ab.png"}
/>
)
} else {
return (
<DataComponent
header={"What I'm watching"}
projectHeader={"No data :("}
projectSubheader={"Nothing was returned"}
url={"/"}
/>
)
}
}
@@ -0,0 +1,34 @@
import DataComponent from '../DataComponent';
import type { MusicData } from '@/utils/types';
const fetchData = async () => {
const { data } = await fetch("https://api.alexav.gg/v4/social/music", {
method: 'get'
}).then((res) => res.json())
return data.tracks[0]
}
export default async function RecentListen() {
const data = await fetchData() as MusicData
if (data) {
return (
<DataComponent
url={"https://last.fm/user/lulawex"}
header={"Music I'm listening to"}
projectHeader={data.name}
projectSubheader={data.artist["#text"]}
projectImage={data.image.filter((v) => v.size === "medium")[0]["#text"]}
/>
)
} else {
return (
<DataComponent
header={"What I'm watching"}
projectHeader={"No data :("}
projectSubheader={"Nothing was returned"}
url={"/"}
/>
)
}
}
@@ -0,0 +1,44 @@
import DataComponent from '../DataComponent';
import type { WatchData } from '@/utils/types';
const fetchData = async () => {
const data = await fetch("https://api.alexav.gg/v4/tracker/latest/tv", {
method: 'get'
});
return await data.json()
}
export default async function RecentWatch() {
const data = await fetchData() as WatchData
const getSubheaderText = () => {
if (data?.data.finished) {
return "Finished!"
} else if (data?.data.startedOn) {
return "Last update on " + new Date(data.data.updatedAt).toLocaleDateString()
} else {
return "Just Added"
}
}
if (data) {
return (
<DataComponent
header={"What I'm watching"}
projectHeader={data.data.title}
projectSubheader={ getSubheaderText() }
url={"https://tracker.alexav.gg/?type=tv&id=" + data.data.id}
projectImage={data.data.image}
/>
)
} else {
return (
<DataComponent
header={"What I'm watching"}
projectHeader={"No data :("}
projectSubheader={"Nothing was returned"}
url={"/"}
/>
)
}
}
-93
View File
@@ -1,93 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
interface Particle {
id: number;
angle: number;
color: string;
distance: number;
opacity: number;
x: number;
y: number;
}
export default function Firework() {
const [particles, setParticles] = useState<Particle[]>([]);
const getRandomPosition = (): { x: number; y: number } => {
// Get container dimensions (in percentage to be responsive)
return {
x: Math.random() * 100, // Random position from 0% to 100% of container width
y: Math.random() * 70 + 15, // Random position from 15% to 85% of container height
};
};
const createParticles = useCallback((): Particle[] => {
const particleCount = 30;
const colors = ['red', 'gold', 'blue', 'green', 'purple', 'pink'];
const position = getRandomPosition();
return Array.from({ length: particleCount }, (_, i) => ({
id: Date.now() + i,
angle: (i * 360) / particleCount,
color: colors[Math.floor(Math.random() * colors.length)],
distance: 0,
opacity: 1,
x: position.x,
y: position.y,
}));
}, []);
const explode = useCallback(() => {
const newParticles = createParticles();
setParticles(prev => [...prev, ...newParticles]);
setTimeout(() => {
setParticles(prev => prev.filter(p => p.id !== newParticles[0].id));
}, 500);
}, [createParticles]);
// Automatic firework launcher
useEffect(() => {
const launchInterval = setInterval(() => {
explode();
}, 2000); // Launch a new firework every 2 seconds
return () => clearInterval(launchInterval);
}, [explode]);
// Particle animation
useEffect(() => {
if (particles.length > 0) {
const animationInterval = setInterval(() => {
setParticles(prev =>
prev.map(particle => ({
...particle,
distance: particle.distance + 2,
opacity: particle.opacity - 0.02
}))
);
}, 20);
return () => clearInterval(animationInterval);
}
}, [particles.length]);
return (
<div className="fixed h-screen w-full overflow-hidden">
{particles.map(particle => (
<div
key={particle.id}
className="absolute h-2 w-2 rounded-full"
style={{
backgroundColor: particle.color,
transform: `rotate(${particle.angle}deg) translateX(${particle.distance}px)`,
opacity: particle.opacity,
transition: 'transform 20ms linear, opacity 20ms linear',
left: `${particle.x}%`,
top: `${particle.y}%`,
}}
/>
))}
</div>
);
}
+4 -9
View File
@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect } from 'react';
import { Link as RouterLink } from 'react-router-dom';
export default function Footer() {
// states
@@ -18,12 +19,6 @@ export default function Footer() {
<h1 className="font-bold text-sm">&copy; {new Date().getFullYear()}, Alex Frantz. All Rights Reserved</h1>
<p>All logos & images on this page belong to their respective owners.</p>
<div className="max-w-fit mx-auto">
<RouterLink to={"/privacy"}>
<p className="underline">Privacy</p>
</RouterLink>
</div>
<hr className="my-2 w-1/2 mx-auto" />
{
@@ -34,8 +29,8 @@ export default function Footer() {
) : (
<button onClick={() => {
setMailState(true)
navigator.clipboard.writeText("alex@alexav.gg")
}} className="hover:underline">alex@alexav.gg</button>
navigator.clipboard.writeText("alex@vierfrantz.com")
}} className="hover:underline">alex@vierfrantz.com</button>
)
}
</div>
+111 -40
View File
@@ -1,43 +1,25 @@
import { useState, useEffect } from "react"
const quoteArray = [
"It works on my machine",
"Revert 'Revert 'Revert",
"'fuck the tests'",
"this is so cooked",
"Professional Production Tester",
"I break hearts to save lives.",
"go bills",
"josh allen is my goat",
"lets go buffalo",
"oorah",
"how bout them bills??",
"where else would you rather be..",
"..than right here, right now?!"
]
export default function Header({
isAudioActive = false
}: {
isAudioActive: boolean
}) {
const quoteArray = [
"It works on my machine",
"Revert 'Revert 'Revert",
"'fuck the tests'",
"this is so cooked",
"Professional Production Tester",
"I break hearts to save lives.",
"go bills",
"josh allen is my goat",
"lets go buffalo",
"oorah",
"how bout them bills??",
"where else would you rather be..",
"..than right here, right now?!"
]
const [ quote, setQuote ] = useState<string>(quoteArray[0]);
async function getQuote() {
return quoteArray[Math.floor(Math.random() * quoteArray.length)]
}
useEffect(() => {
setQuote(quoteArray[Math.floor(Math.random() * quoteArray.length)])
}, [])
const drawNewQuote = () => {
const newQuote = quoteArray[Math.floor(Math.random() * quoteArray.length)]
// redraw if this quote == current quote
if (newQuote !== quote) {
setQuote(newQuote);
} else drawNewQuote();
}
useEffect(() => {
if (isAudioActive) {
setQuote("go bills")
}
}, [isAudioActive])
export default async function Header() {
const quote = await getQuote()
return (
<div className="bg-zinc-900 p-4 flex justify-between">
@@ -45,7 +27,7 @@ export default function Header({
<a href="/" className="hover:underline">
<h1 className="text-2xl font-bold">Alex Frantz</h1>
</a>
<p className="opacity-50 italic select-none" onClick={drawNewQuote}>{quote}</p>
<p className="opacity-50 italic select-none">{quote}</p>
</div>
<div className="space-x-2 items-center justify-center flex select-none">
<a className="hover:underline" href="#projects">Projects</a>
@@ -54,4 +36,93 @@ export default function Header({
</div>
</div>
)
}
}
// "use client";
// import { useState, useEffect, useCallback } from "react"
// export default function Header() {
// const [ shouldShowAudio, setShouldShowAudio ] = useState<boolean>(false)
// const [ pressedKeys, setPressedKeys ] = useState<string[]>([])
// const keyCombination = [
// "ArrowUp",
// "ArrowUp",
// "ArrowDown",
// "ArrowDown",
// "ArrowLeft",
// "ArrowRight",
// "ArrowLeft",
// "ArrowRight",
// "b",
// "a",
// "Enter"
// ]
// const isArraysEqual = (arr1: string[], arr2: string[]) => {
// return arr1.length === arr2.length &&
// arr1.every((val, ind) => val === arr2[ind]);
// }
// const onKeyDown = useCallback((e: KeyboardEvent) => {
// const currentlyPressedKeys = pressedKeys;
// currentlyPressedKeys.push(e.key)
// if (!isArraysEqual(keyCombination, currentlyPressedKeys) && keyCombination.filter((v) => v === e.key) && !e.repeat) {
// setPressedKeys(() => {
// return currentlyPressedKeys
// })
// } else {
// document.removeEventListener('keydown', onKeyDown)
// setShouldShowAudio(true)
// console.log('heeey-ey-ey-ey')
// }
// }, [])
// useEffect(() => {
// document.addEventListener('keydown', onKeyDown);
// })
// const quoteArray = [
// "It works on my machine",
// "Revert 'Revert 'Revert",
// "'fuck the tests'",
// "this is so cooked",
// "Professional Production Tester",
// "I break hearts to save lives.",
// "go bills",
// "josh allen is my goat",
// "lets go buffalo",
// "oorah",
// "how bout them bills??",
// "where else would you rather be..",
// "..than right here, right now?!"
// ]
// const [ quote, setQuote ] = useState<string>(quoteArray[0]);
// useEffect(() => {
// setQuote(quoteArray[Math.floor(Math.random() * quoteArray.length)])
// }, [])
// const drawNewQuote = () => {
// const newQuote = quoteArray[Math.floor(Math.random() * quoteArray.length)]
// // redraw if this quote == current quote
// if (newQuote !== quote) {
// setQuote(newQuote);
// } else drawNewQuote();
// }
// return (
// <div className="bg-zinc-900 p-4 flex justify-between">
// { shouldShowAudio && <audio onEnded={() => setShouldShowAudio(false)} autoPlay={true} src={"https://storage.alexav.gg/content/0f4ce103-62d0-4ff1-8b8b-e38becced35d.mp3"} />}
// <div>
// <a href="/" className="hover:underline">
// <h1 className="text-2xl font-bold">Alex Frantz</h1>
// </a>
// <p className="opacity-50 italic select-none" onClick={drawNewQuote}>{quote}</p>
// </div>
// <div className="space-x-2 items-center justify-center flex select-none">
// <a className="hover:underline" href="#projects">Projects</a>
// <a className="hover:underline" href="#data">Data</a>
// <a className="hover:underline" href="#contact">Contact</a>
// </div>
// </div>
// )
// }
+10 -4
View File
@@ -2,22 +2,27 @@ export default function InputBox({
name,
placeholder = "",
isText = true,
disabled = false,
type = "text",
onChange
}: {
name: string,
isText?: boolean,
disabled?: boolean,
placeholder?: string,
onChange: (value: string) => void
type?: string,
onChange?: (value: string) => void
}) {
if (isText) {
return (
<div className="block">
<input
type="text"
type={type}
name={name}
placeholder={placeholder}
className="rounded-lg p-2 w-1/2 text-black"
onChange={({target}) => onChange(target.value)}
onChange={onChange ? ({target}) => onChange(target.value) : undefined}
disabled={disabled}
/>
</div>
)
@@ -27,7 +32,8 @@ export default function InputBox({
<textarea
name={name}
placeholder={placeholder}
onChange={({ target }) => onChange(target.value)}
onChange={onChange ? ({ target }) => onChange(target.value) : undefined}
disabled={disabled}
className="rounded-lg p-2 w-1/2 h-32 text-black"
/>
</div>
-56
View File
@@ -1,56 +0,0 @@
import Header from "./Header"
import Footer from "./Footer"
import { useState, useEffect, useCallback } from 'react';
export default function Layout({
children
}: {
children: JSX.Element | JSX.Element[]
}) {
// teehee
const [ shouldShowAudio, setShouldShowAudio ] = useState<boolean>(false)
const [ pressedKeys, setPressedKeys ] = useState<string[]>([])
const keyCombination = [
"ArrowUp",
"ArrowUp",
"ArrowDown",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowLeft",
"ArrowRight",
"b",
"a",
"Enter"
]
const isArraysEqual = (arr1: string[], arr2: string[]) => {
return arr1.length === arr2.length &&
arr1.every((val, ind) => val === arr2[ind]);
}
const onKeyDown = useCallback((e: KeyboardEvent) => {
const currentlyPressedKeys = pressedKeys;
currentlyPressedKeys.push(e.key)
if (!isArraysEqual(keyCombination, currentlyPressedKeys) && keyCombination.filter((v) => v === e.key) && !e.repeat) {
setPressedKeys(() => {
return currentlyPressedKeys
})
} else {
document.removeEventListener('keydown', onKeyDown)
setShouldShowAudio(true)
console.log('heeey-ey-ey-ey')
}
}, [])
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
})
return (
<main className="bg-black select-none text-white min-h-screen">
<Header isAudioActive={shouldShowAudio} />
{children}
<Footer />
{shouldShowAudio && <audio onEnded={() => setShouldShowAudio(false)} autoPlay={true} src={"https://storage.alexav.gg/content/0f4ce103-62d0-4ff1-8b8b-e38becced35d.mp3"} />}
</main>
)
}
-20
View File
@@ -1,20 +0,0 @@
export default function ListBox({
title,
points
}: {
title: string,
points: string[]
}) {
return (
<div className="bg-slate-100 text-black rounded-lg p-4">
<h3 className="text-lg font-bold">{title}</h3>
<ul className="mt-2 space-y-2 text-muted-foreground">
{
points.map((v,k) => {
return <li key={k}>{v}</li>
})
}
</ul>
</div>
)
}
-30
View File
@@ -1,30 +0,0 @@
export default function Project({
name,
description,
urlTo,
tags
}: {
name: string,
description: string,
urlTo?: string,
tags: string[]
}) {
return (
<a href={urlTo} target="_blank">
<div className="bg-white text-black mx-auto rounded-lg overflow-hidden">
<div className="p-4 space-y-2">
<h1 className="font-bold text-xl">{name}</h1>
<p>{description}</p>
<div className="space-x-2">
{
tags.map((v, k) => (
<div className="bg-zinc-600 inline max-w-fit p-[5px] text-xs font-bold rounded-xl bg-opacity-20" key={k}>{v}</div>
))
}
</div>
</div>
</div>
</a>
)
}
+17 -21
View File
@@ -1,31 +1,27 @@
export default function ProjectComponent({
url,
header,
projectHeader,
projectSubheader,
projectImage
urlTo,
description,
name,
tags
}: {
url: string,
header: string,
projectHeader: string,
projectSubheader: string,
projectImage?: string,
urlTo: string,
description: string,
name: string,
tags: string[]
}) {
return (
<a href={url} target="_blank">
<div className="bg-white rounded-lg text-black p-2">
<h1 className="text-center font-semibold">{header}</h1>
<a href={urlTo} target="_blank">
<div className="bg-white text-black mx-auto rounded-lg overflow-hidden">
<div className="p-4 space-y-2">
<h1 className="font-bold text-xl">{name}</h1>
<p>{description}</p>
<div className="block">
<div className="flex items-center">
<div className="space-x-2">
{
projectImage && <img alt="Project Image" src={projectImage} width={100} height={100} className="w-[80px] h-[50px] object-cover" />
tags.map((v, k) => (
<div className="bg-zinc-600 inline max-w-fit p-[5px] text-xs font-bold rounded-xl bg-opacity-20" key={k}>{v}</div>
))
}
<div className="flex flex-col mx-2 min-w-0 flex-grow">
<h1 className="font-bold text-left truncate">{projectHeader}</h1>
<h1 className="text-left truncate">{projectSubheader}</h1>
</div>
</div>
</div>
</div>
@@ -1,61 +0,0 @@
import { useState, useEffect } from 'react';
import ProjectComponent from '../ProjectComponent';
import type { ContentTrackerItem } from '../../utils/types';
export default function RecentBook() {
const [ data, setData ] = useState<ContentTrackerItem | null>(null);
const [ loading, setLoading ] = useState<boolean>(true);
const [ errorState, setErrorState ] = useState<string>("");
useEffect(() => {
fetch("https://api.alexav.gg/v4/tracker/latest/book", {
method: 'get'
})
.then(async (res) => {
if (res.ok) {
const json = await res.json();
setData(json.data);
setLoading(false);
} else {
setErrorState("Failed to fetch data!");
setLoading(false);
}
})
.catch((e) => {
setLoading(false);
setErrorState(e.message)
})
}, []);
const getDateString = (startedOn: Date, finished: boolean) => {
if (startedOn && !finished) {
const startedOnDate = new Date(startedOn);
return "Started on " + startedOnDate.toLocaleDateString();
} else if (finished) {
return "Finished!"
} else {
return "Just Added"
}
}
if (data && !loading) {
return (
<ProjectComponent
header={"What I'm reading"}
projectHeader={data.title}
projectSubheader={getDateString(data.startedOn, data.finished)}
url={"https://tracker.alexav.gg/?type=books&id=" + data.id}
projectImage={data.image}
/>
)
} else {
return (
<ProjectComponent
header={"What I'm reading"}
projectHeader={"No data :("}
projectSubheader={loading ? "Loading data.." : "Something went wrong fetching data! " + errorState}
url={"/"}
/>
)
}
}
@@ -1,70 +0,0 @@
import { useState, useEffect } from 'react';
import ProjectComponent from '../ProjectComponent';
type MedalAPIResponse = {
data: MedalClip[]
}
type MedalClip = {
contentId: string,
contentTitle: string,
contentViews: number,
contentLikes: number,
contentThumbnail: string,
directClipUrl: string
}
export default function RecentClip() {
const [ data, setData ] = useState<MedalClip[]>();
const [ errorState, setErrorState ] = useState<string>("");
const [ loading, setLoading ] = useState<boolean>(true);
const translateToWebsiteUrl = (url: string) => {
return url.replace('/clip/', "/clips/")
}
// i'm a neat man what can i say
const likeOrLikes = (likes: number) => {
return (likes > 1 && likes !== 0) ? "likes" : "like"
}
const viewOrViews = (views: number) => {
return (views > 1 && views !== 0) ? "views" : "view"
}
useEffect(() => {
fetch("https://api.alexav.gg/v4/social/medal", {
method: "get"
})
.then(async (res) => {
if (res.ok) {
const data = await res.json() as MedalAPIResponse
setData(data.data);
} else {
setErrorState("Failed to load! :(")
}
})
.catch((e: Error) => {
setErrorState(e.message)
})
setLoading(false);
}, []);
if (!loading && data) {
return (
<ProjectComponent
url={translateToWebsiteUrl(data[0].directClipUrl)}
header={"Clips I've taken"}
projectHeader={data[0].contentTitle}
projectSubheader={`${data[0].contentLikes} ${likeOrLikes(data[0].contentLikes)} | ${data[0].contentViews} ${viewOrViews(data[0].contentViews)}`}
projectImage={data[0].contentThumbnail}
/>
)
} else {
return <ProjectComponent
header={"Clips I've taken"}
projectHeader={"No data :("}
projectSubheader={loading ? "Loading data.." : "Something went wrong fetching data! " + errorState}
url={"/"}
/>
}
}
@@ -1,63 +0,0 @@
import { useState, useEffect } from 'react';
import ProjectComponent from '../ProjectComponent';
type GameAPIResponse = {
data: GameData
}
type GameData = {
name: string,
location: string,
startedOn: number,
finished: boolean
}
export default function RecentGame() {
const [ data, setData ] = useState<GameData>();
const [ errorState, setErrorState ] = useState<string>("");
const [ loading, setLoading ] = useState<boolean>(true);
const getTime = (time: number) => {
const date = new Date(time*1000);
return `${date.toLocaleDateString()} at ${date.toLocaleTimeString()}`
}
useEffect(() => {
fetch("https://api.alexav.gg/v4/game", {
method: "get"
})
.then(async (res) => {
if (res.ok) {
const data = await res.json() as GameAPIResponse
setData(data.data);
} else {
setErrorState("Failed to load! :(")
}
})
.catch((e: Error) => {
setErrorState(e.message)
})
setLoading(false);
}, []);
if (!loading && data) {
return (
<ProjectComponent
url={"https://medal.tv/u/alexav"}
header={"Games I'm playing"}
projectHeader={data.name}
projectSubheader={
data.finished ? `Last played on ${getTime(data.startedOn)}`
: `Started on ${getTime(data.startedOn)}`
}
projectImage={"https://storage.alexav.gg/content/933d5523-5e20-4040-9fe9-b73552eac8ab.png"}
/>
)
} else {
return <ProjectComponent
header={"Games I'm playing"}
projectHeader={"No data :("}
projectSubheader={loading ? "Loading data.." : "Something went wrong fetching data! " + errorState}
url={"/"}
/>
}
}
@@ -1,47 +0,0 @@
import { useState, useEffect } from 'react';
import ProjectComponent from '../ProjectComponent';
import type { MusicData } from '../../utils/types';
export default function RecentListens() {
const [ data, setData ] = useState<MusicData[]>();
const [ errorState, setErrorState ] = useState<string>("");
const [ loading, setLoading ] = useState<boolean>(true);
useEffect(() => {
fetch("https://api.alexav.gg/v4/social/music", {
method: "get"
})
.then(async (res) => {
if (res.ok) {
const data = await res.json()
setData(data.data.tracks);
} else {
setErrorState("Failed to load! :(")
}
})
.catch((e: Error) => {
setErrorState(e.message)
})
setLoading(false);
}, []);
if (!loading && data) {
return (
<ProjectComponent
url={"https://last.fm/user/lulawex"}
header={"Music I'm listening to"}
projectHeader={data[0].name}
projectSubheader={data[0].artist["#text"]}
projectImage={data[0].image.filter((v) => v.size === "medium")[0]["#text"]}
/>
)
} else {
return <ProjectComponent
header={"Music I'm listening to"}
projectHeader={"No data :("}
projectSubheader={loading ? "Loading data.." : "Something went wrong fetching data! " + errorState}
url={"/"}
/>
}
}
@@ -1,60 +0,0 @@
import { useState, useEffect } from 'react';
import ProjectComponent from '../ProjectComponent';
type PhotoData = {
id: number,
name: string,
imageUrl: string,
dateTaken: Date
}
export default function RecentPhoto() {
const [ data, setData ] = useState<PhotoData | null>(null);
const [ loading, setLoading ] = useState<boolean>(true);
const [ errorState, setErrorState ] = useState<string | null>(null);
useEffect(() => {
fetch("https://api.alexav.gg/v4/social/photos", {
method: 'get',
headers: {
'content-type': 'application/json'
}
})
.then(async (res) => {
if (res.ok) {
const json = await res.json()
setData(json.data[0]);
setLoading(false)
} else {
setErrorState("Failed to fetch data!");
setLoading(false);
}
})
.catch((e) => {
setLoading(false);
setErrorState(e.message)
})
}, []);
if (data && !loading) {
return (
<ProjectComponent
url={"https://photos.alexav.gg?id=" + data.id}
header={"Photos I've captured"}
projectHeader={data.name}
projectSubheader={`Taken on ${new Date(data.dateTaken).toLocaleDateString()}`}
projectImage={data.imageUrl}
/>
)
} else {
return (
<ProjectComponent
header={"Photos I've captured"}
projectHeader={"No data :("}
projectSubheader={loading ? "Loading data.." : "Something went wrong fetching data! " + errorState}
url={"/"}
/>
)
}
}
@@ -1,74 +0,0 @@
import { useState, useEffect } from 'react';
import ProjectComponent from '../ProjectComponent';
type WatchData = {
id: number,
title: string,
description: string,
author?: string,
image: string,
episode?: string,
tmdbId?: string,
finished: boolean,
startedOn?: Date,
createdAt: Date,
updatedAt: Date,
createdById: string
}
export default function RecentWatch() {
const [ data, setData ] = useState<WatchData | null>(null);
const [ loading, setLoading ] = useState<boolean>(true);
const [ errorState, setErrorState ] = useState<string>("");
useEffect(() => {
fetch("https://api.alexav.gg/v4/tracker/latest/tv", {
method: 'get'
})
.then(async (res) => {
if (res.ok) {
const json = await res.json();
setData(json.data);
setLoading(false)
} else {
setErrorState("Failed to fetch data!");
setLoading(false);
}
})
.catch((e) => {
setLoading(false);
setErrorState(e.message)
})
}, []);
const getSubheaderText = () => {
if (data?.finished) {
return "Finished!"
} else if (data?.startedOn) {
return "Last update on " + new Date(data.updatedAt).toLocaleDateString()
} else {
return "Just Added"
}
}
if (data && !loading) {
return (
<ProjectComponent
header={"What I'm watching"}
projectHeader={data.title}
projectSubheader={ getSubheaderText() }
url={"https://tracker.alexav.gg/?type=tv&id=" + data.id}
projectImage={data.image}
/>
)
} else {
return (
<ProjectComponent
header={"What I'm watching"}
projectHeader={"No data :("}
projectSubheader={loading ? "Loading data.." : "Something went wrong fetching data! " + errorState}
url={"/"}
/>
)
}
}
-49
View File
@@ -1,49 +0,0 @@
import { useEffect, useState } from 'react';
import '../assets/snow.css';
type Snowflake = {
id: number;
left: number;
animationDuration: number;
opacity: number;
};
export default function Snow() {
const [snowflakes, setSnowflakes] = useState<Snowflake[]>([]);
useEffect(() => {
const createSnowflake = (): Snowflake => ({
id: Math.random(),
left: Math.random() * 100, // percentage across screen
animationDuration: 3 + Math.random() * 7, // between 3-10 seconds
opacity: 0.4 + Math.random() * 0.6, // between 0.4-1
});
// Create initial snowflakes
const initialSnowflakes = Array.from({ length: 50 }, createSnowflake);
setSnowflakes(initialSnowflakes);
// Add new snowflakes periodically
const interval = setInterval(() => {
setSnowflakes(prev => [...prev.slice(-49), createSnowflake()]);
}, 200);
return () => clearInterval(interval);
}, []);
return (
<div className="snow-container">
{snowflakes.map(flake => (
<div
key={flake.id}
className="snowflake"
style={{
left: `${flake.left}%`,
animationDuration: `${flake.animationDuration}s`,
opacity: flake.opacity,
}}
/>
))}
</div>
);
}
-10
View File
@@ -1,10 +0,0 @@
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from './App'
ReactDOM.hydrateRoot(
document.getElementById("app") as HTMLElement,
<BrowserRouter>
<App />
</BrowserRouter>
)
-11
View File
@@ -1,11 +0,0 @@
import { StaticRouter } from "react-router-dom/server"
import { renderToString } from "react-dom/server"
import App from "./App"
export function render(url: string): string {
return renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>
)
}
-14
View File
@@ -1,14 +0,0 @@
import Layout from "../components/Layout";
export default function NotFound() {
return (
<Layout>
<div className="min-h-screen flex justify-center items-center">
<div className="text-center pb-28">
<h1 className="text-2xl font-bold">well that sucks</h1>
<p>that page doesn't exist. skill issue</p>
<a href="#" className="underline" onClick={() => history.back()}>go back?</a>
</div>
</div>
</Layout>
)
}
-107
View File
@@ -1,107 +0,0 @@
import Project from "../components/ProjectCard";
import ContactForm from "../components/ContactForm";
import Layout from "../components/Layout";
import { GitHub, Bluesky, Twitter, Medal, LinkedIn } from '../components/Icons';
// project components
import RecentWatch from "../components/ProjectComponents/RecentWatch";
import RecentPhoto from "../components/ProjectComponents/RecentPhoto";
import RecentListens from "../components/ProjectComponents/RecentListens";
import RecentClip from '../components/ProjectComponents/RecentClip';
import RecentGame from "../components/ProjectComponents/RecentGame";
import RecentBook from "../components/ProjectComponents/RecentBook";
export default function Home() {
return (
<Layout>
<section id="about">
<div className="w-full mx-auto text-center p-10">
<div className="my-1">
<h1 className="text-3xl font-bold">Hi, I'm Alex</h1>
<p>I'm a junior developer primarily working as QA, but sometimes writing questionable code at <a className="hover:underline text-yellow-400" href="https://medal.tv" target="_blank">Medal.tv</a></p>
</div>
<div className="block space-x-2">
{/* socials */}
<a href="https://twitter.com/carlgrimesdupe" target="_blank">
<Twitter />
</a>
<a href="https://bsky.app/profile/alexav.gg" target="_blank">
<Bluesky />
</a>
<a href="https://medal.tv/users/215577" target="_blank">
<Medal />
</a>
<a href="https://github.com/azpha/website" target="_blank">
<GitHub />
</a>
<a href="https://linkedin.com/in/thatalex" target="_blank">
<LinkedIn />
</a>
</div>
<a href="/links" className="opacity-40 text-sm">
<p className="underline">and more..</p>
</a>
</div>
</section>
<section id="projects">
<div className="bg-zinc-900 py-8">
<h1 className="pb-8 text-3xl text-center font-bold">My Projects</h1>
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full md:w-[70%] lg:w-[50%] mx-auto">
<Project
name="medaltv-wrapper"
description="A JavaScript wrapper for the Medal.tv developer API"
urlTo="https://github.com/azpha/medaltv-wrapper"
tags={["JavaScript"]}
/>
<Project
name="splitstat"
description="A Discord bot for SplitStat: Arena Warfare statistics"
urlTo="https://github.com/azpha/splitstat"
tags={["JavaScript", "DiscordJS"]}
/>
<Project
name="steam-deck-file-sync"
description="Sync clips from Steam Deck"
urlTo="https://github.com/azpha/steam-deck-file-sync"
tags={["Python", "Flask"]}
/>
<Project
name="subscription-tracker"
description="Track upcoming subscriptions"
urlTo="https://github.com/azpha/subscription-tracker"
tags={["JavaScript", "ReactJS", "Vite"]}
/>
</div>
</div>
</div>
</section>
<section id="data">
<div className="py-8">
<h1 className="pb-8 text-3xl text-center font-bold">My Data</h1>
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full md:w-[70%] lg:w-[50%] mx-auto">
<RecentListens />
<RecentPhoto />
<RecentWatch />
<RecentBook />
<RecentClip />
<RecentGame />
</div>
</div>
</div>
</section>
<section id="contact">
<div className="bg-zinc-900 py-8">
<ContactForm />
</div>
</section>
</Layout>
)
}
-26
View File
@@ -1,26 +0,0 @@
import CustomButton from "../components/CustomButton";
import { Bluesky, GitHub, LastFM, LinkedIn, Medal, Twitter } from "../components/Icons";
export default function Links() {
return (
<div className="min-h-screen bg-black flex justify-center items-center">
<div className="bg-white rounded-lg p-4 text-center">
<a href="/">
<h1 className="hover:underline font-bold text-2xl text-center">Alex Frantz</h1>
</a>
<p>QA @ <a href="https://medal.tv" className="hover:underline" target="_blank">Medal.tv</a></p>
<hr className="my-2 mx-auto border-black" />
<div className="space-y-2">
<CustomButton icon={<Medal />} to="https://medal.tv/users/215577">Medal</CustomButton>
<CustomButton icon={<Twitter />} to="https://twitter.com/carlgrimesdupe">Twitter</CustomButton>
<CustomButton icon={<Bluesky />} to="https://bsky.app/profile/alexav.gg">Bluesky</CustomButton>
<CustomButton icon={<GitHub />} to="https://github.com/azpha">GitHub</CustomButton>
<CustomButton icon={<LastFM />} to="https://last.fm/user/lulawex">Last.fm</CustomButton>
<CustomButton icon={<LinkedIn />} to="https://linkedin.com/in/thatalex">LinkedIn</CustomButton>
</div>
</div>
</div>
)
}
-47
View File
@@ -1,47 +0,0 @@
import { useEffect, useState } from 'react';
import type { MusicData } from '../utils/types';
export default function MusicWidget() {
const [ data, setData ] = useState<MusicData | null>(null);
const fetchMusicData = () => {
fetch("https://api.alexav.gg/v4/social/music", {
method: 'get'
})
.then(async (res) => {
if (res.ok) {
const data = await res.json()
setData(data.data.tracks[0]);
} else {
console.error("Failed to get music data!", res.status);
}
})
}
useEffect(() => {
fetchMusicData()
setInterval(() => {
fetchMusicData();
}, 60000)
}, [])
if (data) {
return (
<div className="bg-black p-2 text-white min-h-screen w-full justify-center flex items-center">
<div className="mx-auto text-center">
<img className="mx-auto mb-2" width="100" src={data.image.filter((v) => v.size === "large")[0]['#text']} />
<div className="text-2xl">
<h1>{data.name}</h1>
<p>{data.artist['#text']}</p>
</div>
</div>
</div>
)
} else {
return (
<div>
<p>Loading..</p>
</div>
)
}
}
-30
View File
@@ -1,30 +0,0 @@
import Layout from "../components/Layout";
export default function PrivacyPage() {
return (
<Layout>
<div className="my-6 text-left w-full lg:w-1/4 mx-auto">
<h1 className="text-2xl font-bold">Privacy</h1>
<h1>This site doesn't collect anything on you. No cookies (although tasty), no ominous advertisements, nothing.</h1>
<h1 className="my-2 font-bold text-2xl">Plausible</h1>
<h1>I use <a className="underline" href="https://plausible.io" target="_blank">Plausible analytics</a> to track traffic anonymously. Plausible;</h1>
<ul className="list-disc list-inside">
<li>does not collect personal identifying information</li>
<li className="underline"><a href="https://plausible.io/privacy-focused-web-analytics" target="_blank">is dedicated to privacy</a></li>
<li>compliant with the GDPR, CCPA and PECR data protection laws</li>
</ul>
<h1>I use a self-hosted instance in the US that doesn't use cookies.</h1>
<h1 className="my-2 font-bold text-2xl">Cloudflare</h1>
<h1>I use <a className="underline" href="https://pages.cloudflare.com" target="_blank">Cloudflare Pages</a> for this site. Most of my sites
are proxied through Cloudflare as well. You can view their privacy policy <a className="underline" href="https://cloudflare.com/privacy" target="_blank">here</a>.</h1>
<h1 className="my-2 font-bold text-2xl">Email</h1>
<h1>When you submit a contact request on my page, it is sent to Discord via a webhook & my own API.</h1>
<h1>This will be shown to me in an embed. I will never receive or process anything but what you provide.</h1>
<h1>You can learn more about Discord's dedication to privacy <a className='underline' href="https://discord.com/privacy" target="_blank">here</a>.</h1>
</div>
</Layout>
)
}
+34 -3
View File
@@ -1,4 +1,19 @@
// music type
type WatchData = {
data: {
id: number,
title: string,
description: string,
author?: string,
image: string,
episode?: string,
tmdbId?: string,
finished: boolean,
startedOn?: Date,
createdAt: Date,
updatedAt: Date,
createdById: string
}
}
type MusicData = {
artist: {
mbid: string,
@@ -17,8 +32,20 @@ type MusicImage = {
size: string,
"#text": string
}
// tracker type
type GameData = {
name: string,
location: string,
startedOn: number,
finished: boolean
}
type MedalClip = {
contentId: string,
contentTitle: string,
contentViews: number,
contentLikes: number,
contentThumbnail: string,
directClipUrl: string
}
type ContentTrackerItem = {
id: number,
title: string,
@@ -35,7 +62,11 @@ type ContentTrackerItem = {
finished: boolean
}
export type {
WatchData,
MusicData,
GameData,
MedalClip,
ContentTrackerItem
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-12
View File
@@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,tsx,jsx}"
],
theme: {
extend: {}
},
plugins: [],
}
+18
View File
@@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
} satisfies Config;
+23 -9
View File
@@ -1,13 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"types": ["vite/client", "node"]
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-21
View File
@@ -1,21 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
publicDir: './src/public',
server: {
allowedHosts: [
"alexav.gg",
"www.alexav.gg"
]
},
build: {
copyPublicDir: !!process.env.SSR,
ssr: !!process.env.SSR,
outDir: process.env.SSR ? 'dist/server' : 'dist/client',
rollupOptions: {
input: process.env.SSR ? './server.ts' : './index.html'
}
}
})