@@ -1 +0,0 @@
|
||||
VITE_API_BASE_URL="http://localhost:3001/v4"
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
{
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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={"/"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">© {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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
// )
|
||||
// }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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={"/"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,12 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,tsx,jsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||