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;
+3243 -2968
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);
}
}
+27 -72
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,66 +43,37 @@ 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"
type="submit"
className="bg-white w-full text-black font-bold rounded-lg max-w-fit p-2"
onClick={onSubmit}
>
Submit
</button>
@@ -136,6 +90,7 @@ export default function ContactForm() {
)
}
</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>
+97 -26
View File
@@ -1,11 +1,4 @@
import { useState, useEffect } from "react"
export default function Header({
isAudioActive = false
}: {
isAudioActive: boolean
}) {
const quoteArray = [
const quoteArray = [
"It works on my machine",
"Revert 'Revert 'Revert",
"'fuck the tests'",
@@ -19,25 +12,14 @@ export default function Header({
"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)]
async function getQuote() {
return 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>
@@ -55,3 +37,92 @@ export default function Header({
</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;
+22 -8
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'
}
}
})