nextjs
@@ -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
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
logs
|
|
||||||
*.log
|
# 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*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
# env files (can opt-in for committing if needed)
|
||||||
dist
|
.env*
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Editor directories and files
|
# vercel
|
||||||
.vscode/*
|
.vercel
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
# typescript
|
||||||
.DS_Store
|
*.tsbuildinfo
|
||||||
*.suo
|
next-env.d.ts
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|||||||
@@ -1,2 +1,36 @@
|
|||||||
# Website
|
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).
|
||||||
This is my website, [alexav.gg](https://alexav.gg)!
|
|
||||||
|
## 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",
|
"name": "website",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:client && npm run build:server",
|
"dev": "next dev --turbopack",
|
||||||
"build:client": "vite build --outDir dist/client",
|
"build": "next build",
|
||||||
"build:server": "cross-env SSR=true vite build --outDir dist/server"
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"react": "^19.0.0",
|
||||||
"@types/node": "^22.13.1",
|
"react-dom": "^19.0.0",
|
||||||
"compression": "^1.7.5",
|
"next": "15.1.6"
|
||||||
"express": "^4.21.2",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-router-dom": "^6.26.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/compression": "^1.7.5",
|
"typescript": "^5",
|
||||||
"@types/react": "^18.2.66",
|
"@types/node": "^20",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react": "^19",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@types/react-dom": "^19",
|
||||||
"@typescript-eslint/parser": "^7.2.0",
|
"postcss": "^8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"tailwindcss": "^3.4.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"eslint": "^9",
|
||||||
"cross-env": "^7.0.3",
|
"eslint-config-next": "15.1.6",
|
||||||
"eslint": "^8.57.0",
|
"@eslint/eslintrc": "^3"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"start_url": ".",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#000000",
|
"background_color": "#000000",
|
||||||
"display": "standalone"
|
"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 { useState, useEffect } from 'react';
|
||||||
import InputBox from './InputBox';
|
import InputBox from './InputBox';
|
||||||
|
import { submitContact } from '@/actions/contact';
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const [ error, setError ] = useState<string>("");
|
const [ error, setError ] = useState<string>("");
|
||||||
const [ success, setSuccess ] = useState<boolean>(false);
|
const [ success, setSuccess ] = useState<boolean>(false);
|
||||||
const [ inputState, setInputState ] = useState<{name: string, email: string, subject: string, message: string}>({
|
const [ pending, setPending ] = useState<boolean>(false);
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
subject: "",
|
|
||||||
message: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = () => {
|
const handleSubmission = async (formData: FormData) => {
|
||||||
if (
|
setPending(true)
|
||||||
inputState.name === ""
|
|
||||||
|| inputState.email === ""
|
|
||||||
|| inputState.subject === ""
|
|
||||||
|| inputState.message === ""
|
|
||||||
) {
|
|
||||||
setError("A required field was missing")
|
|
||||||
} else {
|
|
||||||
console.log("Form submitted")
|
|
||||||
|
|
||||||
fetch(import.meta.env.VITE_API_BASE_URL + "/contact", {
|
try {
|
||||||
method: "post",
|
const result = await submitContact(formData)
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json'
|
if (result.error || !result.success) {
|
||||||
},
|
setError('An unexpected error occurred')
|
||||||
body: JSON.stringify(inputState)
|
} else setSuccess(true)
|
||||||
}).then((res) => {
|
|
||||||
if (res.ok) {
|
const form = document.getElementById('contact-form') as HTMLFormElement
|
||||||
setSuccess(true)
|
form.reset()
|
||||||
} else {
|
} catch (e) {
|
||||||
if (res.status === 429) {
|
setError('An unexpected error occurred')
|
||||||
setError("Too many tries! Try again later")
|
} finally {
|
||||||
} else {
|
setPending(false)
|
||||||
setError("An error occurred while submitting your info.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error(e)
|
|
||||||
setError("An error occurred while submitting your info.")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,66 +43,37 @@ export default function ContactForm() {
|
|||||||
<div className="rounded-lg p-2 w-full md:w-[50%] mx-auto">
|
<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>
|
<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
|
<InputBox
|
||||||
isText={true}
|
isText={true}
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Your name.."
|
placeholder="Your name.."
|
||||||
onChange={(value: string) => {
|
disabled={pending}
|
||||||
setInputState((prevState) => {
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
name: value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<InputBox
|
<InputBox
|
||||||
name="email"
|
name="email"
|
||||||
|
type="email"
|
||||||
isText={true}
|
isText={true}
|
||||||
placeholder="your@supercool.email"
|
placeholder="your@supercool.email"
|
||||||
onChange={(value: string) => {
|
disabled={pending}
|
||||||
setInputState((prevState) => {
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
email: value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<InputBox
|
<InputBox
|
||||||
isText={true}
|
isText={true}
|
||||||
name="subject"
|
name="subject"
|
||||||
placeholder="Subject.."
|
placeholder="Subject.."
|
||||||
onChange={(value: string) => {
|
disabled={pending}
|
||||||
setInputState((prevState) => {
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
subject: value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<InputBox
|
<InputBox
|
||||||
isText={false}
|
isText={false}
|
||||||
name="message"
|
name="message"
|
||||||
placeholder="Your message here.."
|
placeholder="Your message here.."
|
||||||
onChange={(value: string) => {
|
disabled={pending}
|
||||||
setInputState((prevState) => {
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
message: value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="block text-center">
|
<div className="block text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className="bg-white w-full text-black font-bold rounded-lg max-w-fit p-2"
|
className="bg-white w-full text-black font-bold rounded-lg max-w-fit p-2"
|
||||||
onClick={onSubmit}
|
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
@@ -136,6 +90,7 @@ export default function ContactForm() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</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 { useState, useEffect } from 'react';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
// states
|
// states
|
||||||
@@ -18,12 +19,6 @@ export default function Footer() {
|
|||||||
<h1 className="font-bold text-sm">© {new Date().getFullYear()}, Alex Frantz. All Rights Reserved</h1>
|
<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>
|
<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" />
|
<hr className="my-2 w-1/2 mx-auto" />
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -34,8 +29,8 @@ export default function Footer() {
|
|||||||
) : (
|
) : (
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
setMailState(true)
|
setMailState(true)
|
||||||
navigator.clipboard.writeText("alex@alexav.gg")
|
navigator.clipboard.writeText("alex@vierfrantz.com")
|
||||||
}} className="hover:underline">alex@alexav.gg</button>
|
}} className="hover:underline">alex@vierfrantz.com</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
import { useState, useEffect } from "react"
|
|
||||||
|
|
||||||
export default function Header({
|
|
||||||
isAudioActive = false
|
|
||||||
}: {
|
|
||||||
isAudioActive: boolean
|
|
||||||
}) {
|
|
||||||
const quoteArray = [
|
const quoteArray = [
|
||||||
"It works on my machine",
|
"It works on my machine",
|
||||||
"Revert 'Revert 'Revert",
|
"Revert 'Revert 'Revert",
|
||||||
@@ -20,24 +13,13 @@ export default function Header({
|
|||||||
"where else would you rather be..",
|
"where else would you rather be..",
|
||||||
"..than right here, right now?!"
|
"..than right here, right now?!"
|
||||||
]
|
]
|
||||||
const [ quote, setQuote ] = useState<string>(quoteArray[0]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
async function getQuote() {
|
||||||
setQuote(quoteArray[Math.floor(Math.random() * quoteArray.length)])
|
return quoteArray[Math.floor(Math.random() * quoteArray.length)]
|
||||||
}, [])
|
}
|
||||||
const drawNewQuote = () => {
|
|
||||||
const newQuote = quoteArray[Math.floor(Math.random() * quoteArray.length)]
|
|
||||||
|
|
||||||
// redraw if this quote == current quote
|
export default async function Header() {
|
||||||
if (newQuote !== quote) {
|
const quote = await getQuote()
|
||||||
setQuote(newQuote);
|
|
||||||
} else drawNewQuote();
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAudioActive) {
|
|
||||||
setQuote("go bills")
|
|
||||||
}
|
|
||||||
}, [isAudioActive])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 p-4 flex justify-between">
|
<div className="bg-zinc-900 p-4 flex justify-between">
|
||||||
@@ -45,7 +27,7 @@ export default function Header({
|
|||||||
<a href="/" className="hover:underline">
|
<a href="/" className="hover:underline">
|
||||||
<h1 className="text-2xl font-bold">Alex Frantz</h1>
|
<h1 className="text-2xl font-bold">Alex Frantz</h1>
|
||||||
</a>
|
</a>
|
||||||
<p className="opacity-50 italic select-none" onClick={drawNewQuote}>{quote}</p>
|
<p className="opacity-50 italic select-none">{quote}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-2 items-center justify-center flex select-none">
|
<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="#projects">Projects</a>
|
||||||
@@ -55,3 +37,92 @@ export default function Header({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "use client";
|
||||||
|
|
||||||
|
// import { useState, useEffect, useCallback } from "react"
|
||||||
|
|
||||||
|
// export default function Header() {
|
||||||
|
// const [ shouldShowAudio, setShouldShowAudio ] = useState<boolean>(false)
|
||||||
|
// const [ pressedKeys, setPressedKeys ] = useState<string[]>([])
|
||||||
|
// const keyCombination = [
|
||||||
|
// "ArrowUp",
|
||||||
|
// "ArrowUp",
|
||||||
|
// "ArrowDown",
|
||||||
|
// "ArrowDown",
|
||||||
|
// "ArrowLeft",
|
||||||
|
// "ArrowRight",
|
||||||
|
// "ArrowLeft",
|
||||||
|
// "ArrowRight",
|
||||||
|
// "b",
|
||||||
|
// "a",
|
||||||
|
// "Enter"
|
||||||
|
// ]
|
||||||
|
// const isArraysEqual = (arr1: string[], arr2: string[]) => {
|
||||||
|
// return arr1.length === arr2.length &&
|
||||||
|
// arr1.every((val, ind) => val === arr2[ind]);
|
||||||
|
// }
|
||||||
|
// const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
|
// const currentlyPressedKeys = pressedKeys;
|
||||||
|
// currentlyPressedKeys.push(e.key)
|
||||||
|
|
||||||
|
// if (!isArraysEqual(keyCombination, currentlyPressedKeys) && keyCombination.filter((v) => v === e.key) && !e.repeat) {
|
||||||
|
// setPressedKeys(() => {
|
||||||
|
// return currentlyPressedKeys
|
||||||
|
// })
|
||||||
|
// } else {
|
||||||
|
// document.removeEventListener('keydown', onKeyDown)
|
||||||
|
// setShouldShowAudio(true)
|
||||||
|
// console.log('heeey-ey-ey-ey')
|
||||||
|
// }
|
||||||
|
// }, [])
|
||||||
|
// useEffect(() => {
|
||||||
|
// document.addEventListener('keydown', onKeyDown);
|
||||||
|
// })
|
||||||
|
|
||||||
|
// const quoteArray = [
|
||||||
|
// "It works on my machine",
|
||||||
|
// "Revert 'Revert 'Revert",
|
||||||
|
// "'fuck the tests'",
|
||||||
|
// "this is so cooked",
|
||||||
|
// "Professional Production Tester",
|
||||||
|
// "I break hearts to save lives.",
|
||||||
|
// "go bills",
|
||||||
|
// "josh allen is my goat",
|
||||||
|
// "lets go buffalo",
|
||||||
|
// "oorah",
|
||||||
|
// "how bout them bills??",
|
||||||
|
// "where else would you rather be..",
|
||||||
|
// "..than right here, right now?!"
|
||||||
|
// ]
|
||||||
|
// const [ quote, setQuote ] = useState<string>(quoteArray[0]);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// setQuote(quoteArray[Math.floor(Math.random() * quoteArray.length)])
|
||||||
|
// }, [])
|
||||||
|
// const drawNewQuote = () => {
|
||||||
|
// const newQuote = quoteArray[Math.floor(Math.random() * quoteArray.length)]
|
||||||
|
|
||||||
|
// // redraw if this quote == current quote
|
||||||
|
// if (newQuote !== quote) {
|
||||||
|
// setQuote(newQuote);
|
||||||
|
// } else drawNewQuote();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="bg-zinc-900 p-4 flex justify-between">
|
||||||
|
// { shouldShowAudio && <audio onEnded={() => setShouldShowAudio(false)} autoPlay={true} src={"https://storage.alexav.gg/content/0f4ce103-62d0-4ff1-8b8b-e38becced35d.mp3"} />}
|
||||||
|
// <div>
|
||||||
|
// <a href="/" className="hover:underline">
|
||||||
|
// <h1 className="text-2xl font-bold">Alex Frantz</h1>
|
||||||
|
// </a>
|
||||||
|
// <p className="opacity-50 italic select-none" onClick={drawNewQuote}>{quote}</p>
|
||||||
|
// </div>
|
||||||
|
// <div className="space-x-2 items-center justify-center flex select-none">
|
||||||
|
// <a className="hover:underline" href="#projects">Projects</a>
|
||||||
|
// <a className="hover:underline" href="#data">Data</a>
|
||||||
|
// <a className="hover:underline" href="#contact">Contact</a>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
@@ -2,22 +2,27 @@ export default function InputBox({
|
|||||||
name,
|
name,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
isText = true,
|
isText = true,
|
||||||
|
disabled = false,
|
||||||
|
type = "text",
|
||||||
onChange
|
onChange
|
||||||
}: {
|
}: {
|
||||||
name: string,
|
name: string,
|
||||||
isText?: boolean,
|
isText?: boolean,
|
||||||
|
disabled?: boolean,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
onChange: (value: string) => void
|
type?: string,
|
||||||
|
onChange?: (value: string) => void
|
||||||
}) {
|
}) {
|
||||||
if (isText) {
|
if (isText) {
|
||||||
return (
|
return (
|
||||||
<div className="block">
|
<div className="block">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type={type}
|
||||||
name={name}
|
name={name}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="rounded-lg p-2 w-1/2 text-black"
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -27,7 +32,8 @@ export default function InputBox({
|
|||||||
<textarea
|
<textarea
|
||||||
name={name}
|
name={name}
|
||||||
placeholder={placeholder}
|
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"
|
className="rounded-lg p-2 w-1/2 h-32 text-black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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({
|
export default function ProjectComponent({
|
||||||
url,
|
urlTo,
|
||||||
header,
|
description,
|
||||||
projectHeader,
|
name,
|
||||||
projectSubheader,
|
tags
|
||||||
projectImage
|
|
||||||
}: {
|
}: {
|
||||||
url: string,
|
urlTo: string,
|
||||||
header: string,
|
description: string,
|
||||||
projectHeader: string,
|
name: string,
|
||||||
projectSubheader: string,
|
tags: string[]
|
||||||
projectImage?: string,
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<a href={url} target="_blank">
|
<a href={urlTo} target="_blank">
|
||||||
<div className="bg-white rounded-lg text-black p-2">
|
<div className="bg-white text-black mx-auto rounded-lg overflow-hidden">
|
||||||
<h1 className="text-center font-semibold">{header}</h1>
|
<div className="p-4 space-y-2">
|
||||||
|
<h1 className="font-bold text-xl">{name}</h1>
|
||||||
|
<p>{description}</p>
|
||||||
|
|
||||||
<div className="block">
|
<div className="space-x-2">
|
||||||
<div className="flex items-center">
|
|
||||||
{
|
{
|
||||||
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>
|
</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 = {
|
type MusicData = {
|
||||||
artist: {
|
artist: {
|
||||||
mbid: string,
|
mbid: string,
|
||||||
@@ -17,8 +32,20 @@ type MusicImage = {
|
|||||||
size: string,
|
size: string,
|
||||||
"#text": string
|
"#text": string
|
||||||
}
|
}
|
||||||
|
type GameData = {
|
||||||
// tracker type
|
name: string,
|
||||||
|
location: string,
|
||||||
|
startedOn: number,
|
||||||
|
finished: boolean
|
||||||
|
}
|
||||||
|
type MedalClip = {
|
||||||
|
contentId: string,
|
||||||
|
contentTitle: string,
|
||||||
|
contentViews: number,
|
||||||
|
contentLikes: number,
|
||||||
|
contentThumbnail: string,
|
||||||
|
directClipUrl: string
|
||||||
|
}
|
||||||
type ContentTrackerItem = {
|
type ContentTrackerItem = {
|
||||||
id: number,
|
id: number,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -35,7 +62,11 @@ type ContentTrackerItem = {
|
|||||||
finished: boolean
|
finished: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
WatchData,
|
||||||
MusicData,
|
MusicData,
|
||||||
|
GameData,
|
||||||
|
MedalClip,
|
||||||
ContentTrackerItem
|
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": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ES2017",
|
||||||
"module": "ESNext",
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"moduleResolution": "node",
|
"allowJs": true,
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["vite/client", "node"]
|
"strict": true,
|
||||||
},
|
"noEmit": true,
|
||||||
"include": ["src"]
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||