3 Commits

Author SHA1 Message Date
Mikayla Dobson
38d9ae2d48 Merge pull request #4 from innocuous-symmetry/staging
Staging
2024-05-27 12:37:55 -05:00
7e543aebc0 tweak to working details 2024-05-27 17:37:24 +00:00
e70c6736f6 content updates 2024-05-27 14:03:53 +00:00
59 changed files with 390 additions and 1077 deletions

View File

@@ -8,23 +8,10 @@ env:
on:
push:
branches:
# just kidding, these are like, not ready at all
- never
- gonna
- give
- you
- up
# ... COPILOT JUST RICKROLLED ME
# - master
# # temporarily include dev branches
# - mongodb
# - music-streaming
- master
jobs:
build:
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
- name: checkout
@@ -35,10 +22,3 @@ jobs:
run: npm install
- name: build check
run: npm run build
deploy:
runs-on: self-hosted
steps:
- name: deploy service
run: ""

10
.gitignore vendored
View File

@@ -5,10 +5,7 @@
/.pnp
.pnp.js
package-lock.json
# assets
/seed_data
/certs
pnpm-lock.yaml
# testing
/coverage
@@ -20,15 +17,10 @@ package-lock.json
# production
/build
# python
mgmt-venv/
blob_staging/
# misc
.DS_Store
*.pem
*.env
sandbox/
# debug
npm-debug.log*

View File

@@ -1,11 +0,0 @@
FROM node:current
# Create app directory
WORKDIR /usr/src/app
COPY . .
RUN "npm install"
RUN "npm run build"
EXPOSE 3000
CMD [ "npm", "start" ]

View File

@@ -1,6 +1,6 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useMemo, useState } from "react";
import { submitMessage } from "@/server/actions/contact.actions";
import { useMemo, useState } from "react";
export default function ContactPage() {
const MESSAGE_LIMIT = 600;
@@ -12,32 +12,32 @@ export default function ContactPage() {
const characterCount = useMemo(() => message.length.toString(), [message]);
return (
<div className="min-w-screen min-h-screen bg-gradient-to-b from-slate-300 to-purple-300 bg-opacity-40 dark:from-black dark:to-darkPlum flex flex-col items-center">
<motion.div /* initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.25, delay: 0 }} */ className="flex flex-col mx-24 items-center dark:text-white bg-zinc-800 p-12 mt-12 rounded-lg w-5/6 lg:w-3/5">
<div className="min-w-screen min-h-screen bg-gradient-to-b from-slate-300 to-purple-300 bg-opacity-40 dark:from-black dark:to-darkPlum">
<div className="flex flex-col mx-24 items-center dark:text-white ">
<h1 className="text-3xl my-8 place-self-start">Thanks for your interest! I&apos;m looking forward to hearing from you.</h1>
<motion.form /* initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.25, delay: 0.5 }} */ className="w-full">
<div className="flex flex-col lg:flex-row w-full">
<div className="flex flex-col w-full lg:w-1/2 lg:mr-2">
<form className="w-full" action={async () => await submitMessage({ from: email, text: message })}>
<div className="flex w-full">
<div className="flex flex-col w-1/2 mr-2">
<label htmlFor="name">Name</label>
<input required className="bg-neutral-100 rounded px-1.5 py-1 text-black" value={name} onChange={(e) => setName(e.target.value)} type="text" name="name" id="name" />
<input required className="bg-neutral-100 px-1.5 py-1 text-black" value={name} onChange={(e) => setName(e.target.value)} type="text" name="name" id="name" />
</div>
<div className="flex flex-col w-full lg:w-1/2 lg:ml-2">
<div className="flex flex-col w-1/2 ml-2">
<label htmlFor="email">Email</label>
<input required className="bg-neutral-100 rounded px-1.5 py-1 text-black" value={email} onChange={(e) => setEmail(e.target.value)} type="email" name="email" id="email" />
<input required className="bg-neutral-100 px-1.5 py-1 text-black" value={email} onChange={(e) => setEmail(e.target.value)} type="email" name="email" id="email" />
</div>
</div>
<div className="flex flex-col w-full mt-4">
<label htmlFor="message">Message</label>
<textarea required className="bg-neutral-100 rounded px-1.5 py-1 text-black" value={message} onChange={(e) => setMessage(e.target.value)} name="message" id="message" cols={30} rows={5}></textarea>
<textarea required className="bg-neutral-100 px-1.5 py-1 text-black" value={message} onChange={(e) => setMessage(e.target.value)} name="message" id="message" cols={30} rows={5}></textarea>
<p className={"text-sm " + (parseInt(characterCount) > MESSAGE_LIMIT ? "text-red-500" : "text-black dark:text-white")}>{characterCount}/{MESSAGE_LIMIT}</p>
</div>
<button disabled={parseInt(characterCount) > MESSAGE_LIMIT} className="p-2 px-8 mt-8 rounded-lg bg-rose-300 hover:bg-rose-400 text-black" type="submit">Send!</button>
</motion.form>
</motion.div>
</form>
</div>
</div>
)
}

View File

@@ -3,7 +3,6 @@ import './globals.css'
import Head from 'next/head'
import Navbar from '@/components/Navbar'
import { Inter, Besley, Cabin } from 'next/font/google'
import { AudioProvider } from '@/hooks/useAudio'
export const inter = Inter({ subsets: ['latin'] })
export const besley = Besley({ subsets: ['latin'] })
@@ -12,21 +11,19 @@ export const cabin = Cabin({ subsets: ['latin'] })
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<Head>
<title>Mikayla Dobson | Software Engineer</title>
<meta name="description" content="Mikayla Dobson is a software engineer based in Nashville, TN" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<body className={inter.className}>
<AudioProvider>
<Navbar />
<div>
<div id="navbar-spacer" className="h-[6rem] w-full bg-slate-300 dark:bg-black " />
{children}
</div>
</AudioProvider>
</body>
</html>
<Head>
<title>Mikayla Dobson | Software Engineer</title>
<meta name="description" content="Mikayla Dobson is a software engineer based in Nashville, TN" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<body className={inter.className}>
<Navbar />
<div>
<div id="navbar-spacer" className="h-[6rem] w-full bg-slate-300 dark:bg-black " />
{children}
</div>
</body>
</html>
)
}

View File

@@ -1,41 +1,5 @@
import { AudioGallery } from "@/components/Music/AudioGallery";
import NotFound from "@/components/NotFound";
import MusicController from "@/server/controllers/music.controller";
import { S3Service } from "@/server/s3";
import { TrackWithURL } from "@/server/s3/service";
import { Suspense } from "react";
import InProgress from "@/components/InProgress";
export default async function ListenByCollectionID({ params }: { params: { collectionid?: string }}) {
const { collectionid: id } = params;
if (!id) return <NotFound />
let collection: Awaited<ReturnType<MusicController["getByID"]>>;
let trackList: Awaited<ReturnType<typeof S3Service.prepareTrackList>>;
let thumbnail: TrackWithURL | undefined;
try {
const controller = new MusicController();
collection = await controller.getByID(id);
if (!collection) return <NotFound />
trackList = await S3Service.prepareTrackList(collection.pathtoentry);
thumbnail = trackList.filter(t => t.Key.includes(".png") || t.Key.includes(".jpg") || t.Key.includes(".jpeg"))[0];
} catch {
return <NotFound />
}
return (
<div>
<header>
<h1>{collection.name}</h1>
<p>{collection.shortdescription}</p>
</header>
<p>{collection.longdescription}</p>
<Suspense fallback={<p>Loading...</p>}>
<AudioGallery trackList={trackList} collection={collection} />
</Suspense>
</div>
)
export default async function ListenByCollectionID() {
return <InProgress />
}

View File

@@ -1,24 +1,10 @@
import FullMusicList from "@/components/Music/FullMusicList";
import MusicController from "@/server/controllers/music.controller";
import { Suspense } from "react";
import InProgress from "@/components/InProgress";
export default async function ListenIndex() {
let allResults: Awaited<ReturnType<MusicController["getAll"]>>;
try {
const controller = new MusicController();
allResults = await controller.getAll();
} catch {
allResults = [];
}
return (
<div>
<h1>Listen</h1>
<Suspense fallback={<p>Loading...</p>}>
<FullMusicList allResults={allResults} />
</Suspense>
<InProgress />
</div>
)
}

View File

@@ -1,35 +0,0 @@
import BlogPostController from "@/server/controllers/blogpost.controller";
import Image from "next/image";
import { MDXRemote } from "next-mdx-remote";
import { serialize } from "next-mdx-remote/serialize";
import { notFound } from "next/navigation";
export default async function PostById({ params }: { params: { postid: string }}) {
const { postid } = params;
const controller = new BlogPostController();
console.log({ postid });
const post = await controller.getByID(postid);
if (!post) notFound();
const mdxSource = await serialize(post.content);
return (
<div>
<h1>Post {postid}</h1>
<p>Coming soon...</p>
<article>
<h1>{post.title}</h1>
<p>{post.author}</p>
{ post.written && <p>{post.written.toLocaleDateString()}</p> }
<hr />
<MDXRemote {...mdxSource} />
</article>
</div>
)
}

View File

@@ -1,29 +0,0 @@
import BlogPostController from "@/server/controllers/blogpost.controller";
export default async function DevLogIndex() {
let posts: Awaited<ReturnType<BlogPostController["getAll"]>>;
try {
const controller = new BlogPostController();
posts = await controller.getAll();
} catch {
posts = [];
}
return (
<div>
<h1>Dev Log</h1>
<p>Coming soon...</p>
{ posts?.map((post, idx) => {
return (
<div key={idx}>
<a href={`/log/${post._id.toString()}`}>{post.title}</a>
<p>{post.author}</p>
</div>
)
}) ?? null}
</div>
)
}

View File

@@ -1,8 +0,0 @@
// import { MDXRemote } from "next-mdx-remote";
// import { serialize } from "next-mdx-remote/serialize";
// import { BlogPost } from "@/server/db/schema";
// export default async function PostComponent(post: BlogPost) {
// const mdxSource = await serialize(post.content);
// return <MDXRemote />
// }

View File

@@ -1,19 +0,0 @@
'use client';
import { MusicStreamingEntry } from "@/server/db/schema";
import { AudioTrack } from "./AudioTrack"
import { _Object } from "@aws-sdk/client-s3";
import { TrackWithURL } from "@/server/s3/service";
export function AudioGallery({ trackList, collection }: { trackList: TrackWithURL[], collection: MusicStreamingEntry }) {
return (
<div className="p-8 m-8 rounded-lg bg-neutral-600">
{ trackList.length
? trackList.map((each, idx) => {
if (!(each.Key.includes(".wav") || each.Key.includes(".mp3"))) return <div key={idx} />
return <AudioTrack metadata={each} collection={collection} key={idx} />
})
: <p>No audio results found for this work.</p>
}
</div>
)
}

View File

@@ -1,16 +0,0 @@
type ButtonProps = {
children: React.ReactNode;
onClick: (...args: []) => void;
extraClasses?: string;
disabled?: boolean
}
export function _Button({ children, extraClasses="", onClick, disabled=false }: ButtonProps) {
return (
<button disabled={disabled} onClick={onClick} className={
extraClasses + " transition-all duration-700 rounded-full bg-stone-400 border-black h-16 w-16 flex flex-col items-center justify-center"
}>
{ children }
</button>
)
}

View File

@@ -1,70 +0,0 @@
'use client';
import useAudio from "@/hooks/useAudio";
import { FaArrowDown, FaArrowUp, FaPause, FaPlay } from "react-icons/fa";
import { useCallback } from "react";
import { _Button } from "./_Button";
import Link from "@/components/ui/Link";
import { prettyFileName } from "@/util/helpers";
import Image from "next/image";
export default function AudioPlayer() {
const { isPlaying, isOpen, currentTrack, currentCollection, thumbnailSrc, setIsPlaying, setIsOpen } = useAudio();
const changePlayState = useCallback(() => {
if (!currentTrack) {
setIsPlaying(false);
return;
}
setIsPlaying(!isPlaying);
}, [currentTrack, isPlaying, setIsPlaying])
return (
<>
<div id="audio-player-panel" className={`${isOpen ? "bg-stone-300 border-black dark:bg-black dark:border-stone-300 text-black dark:text-stone-300 translate-y-0 " : " translate-y-32 "} fixed bottom-0 right-0 flex items-center justify-between w-1/2 h-36 rounded-tl-xl z-0 transition-all duration-700 px-8`}>
{/* track thumbnail, if it exists */}
<div>
{ !isOpen ? null : thumbnailSrc ? (
<Image alt={`thumbnail for track ${currentTrack ? prettyFileName(currentTrack.Key) : ""}`} src={thumbnailSrc} />
) : (
<div className="w-24 h-24 rounded-lg bg-stone-300 dark:bg-black" />
)}
</div>
{/* track info, if it is set */}
<div className="flex flex-col">
{ !isOpen ? null : currentTrack ? (
<>
<h3>{prettyFileName(currentTrack.Key)}</h3>
<p>{currentCollection?.artist ?? "(no artist listed)"} - {currentCollection?.year ?? "(no year listed)"}</p>
</>
) : (
<>
<p className="text-white text-opacity-80">No track selected</p>
<Link href="/listen">Browse works I have available for streaming!</Link>
</>
)}
</div>
<div id="audio-panel-actions" className="flex items-center justify-end pr-16">
{/* conditionally-rendered button to close audio player once it's open */}
<_Button extraClasses={"fixed right-28 " + (isOpen ? "-bottom-30" : "bottom-10")} disabled={!currentTrack} onClick={changePlayState}>
{ isPlaying
? <FaPause className="h-8 w-8 text-black" />
: <FaPlay className="h-8 w-8 text-black" />
}
</_Button>
</div>
</div>
{/* floating button to open/close the player */}
<_Button extraClasses="fixed bottom-10 right-10" onClick={() => setIsOpen(!isOpen)}>
{ isOpen
? <FaArrowDown className="h-8 w-8 text-black" />
: <FaArrowUp className="h-8 w-8 text-black" />
}
</_Button>
</>
)
}

View File

@@ -1,49 +0,0 @@
'use client';
import useAudio from "@/hooks/useAudio";
import { MusicStreamingEntry } from "@/server/db/schema";
import { TrackWithURL } from "@/server/s3/service";
import { prettyFileName } from "@/util/helpers";
import { useCallback, useMemo } from "react";
import { FaPause, FaPlay } from "react-icons/fa";
export function AudioTrack({ collection, metadata }: { collection: MusicStreamingEntry, metadata: TrackWithURL }) {
const { isPlaying, currentTrack, setIsPlaying, handleTrackChange } = useAudio();
const isMatchingTrack: boolean = useMemo(() => {
return currentTrack?.Key == metadata.Key;
}, [currentTrack, metadata]);
const handleClick = useCallback(() => {
console.log({ isMatchingTrack, isPlaying, currentTrack })
if (!currentTrack) {
handleTrackChange(metadata, collection);
return;
}
if (isMatchingTrack && isPlaying) {
setIsPlaying(false);
return;
}
handleTrackChange(metadata, collection);
}, [currentTrack, collection, metadata, isMatchingTrack, isPlaying, setIsPlaying, handleTrackChange])
return (
<div className="flex">
<div>
<button className="mr-3" onClick={handleClick}>
{ isMatchingTrack && isPlaying
? <FaPause className="h-8 w-8 text-black" />
: <FaPlay className="h-8 w-8 text-black" />
}
</button>
</div>
<div className="flex flex-col">
<h3>{prettyFileName(metadata.Key)}</h3>
{ collection.artist && <small>{collection.artist}</small> }
</div>
</div>
)
}

View File

@@ -1,20 +0,0 @@
'use client';
import { MusicStreamingEntry } from "@/server/db/schema";
import { Maybe } from "@/util/helpers";
import Link from "../ui/Link";
export default function FullMusicList({ allResults }: { allResults?: Maybe<Partial<MusicStreamingEntry>[]> }) {
return (
<div>
{ allResults
? allResults.map((result, idx) => {
return (
<div key={idx}>
<Link href={`/listen/${result._id?.toString()}`}>{result.name}</Link>
</div>
)
}) : <p>No music available for streaming.</p>
}
</div>
)
}

View File

@@ -1,54 +0,0 @@
'use client';
import Link from "next/link";
import { motion, useAnimate } from "framer-motion";
import { useCallback, useEffect } from "react";
type MobileMenuProps = {
mobileMenuOpen: boolean,
setMobileMenuOpen: (arg0: boolean) => void
}
export default function MobileMenu({ mobileMenuOpen, setMobileMenuOpen }: MobileMenuProps) {
// const [scope, animate] = useAnimate();
// const handleClickout = useCallback((e: MouseEvent) => {
// const target = e.target as HTMLElement;
// if (target.id !== 'mobile-sidebar') return;
// setMobileMenuOpen(false);
// animate('-16px', '100%')
// }, [setMobileMenuOpen, animate]);
// useEffect(() => {
// // handle clickout
// document.addEventListener('click', handleClickout)
// return () => document.removeEventListener('click', handleClickout);
// })
return (
<motion.div
// ref={scope}
id="mobile-sidebar"
animate={{ x: mobileMenuOpen ? '100%' : '-16px'}}
onMouseLeave={() => setMobileMenuOpen(false)}
className={`md:hidden w-1/3 fixed right-0 top-20 flex flex-col p-8 z-50 rounded-xl justify-end bg-[#131313] bg-opacity-90`}>
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/" className="w-auto px-2">
<p className='text-lg text-center text-white text-opacity-80 hover:text-opacity-100 hover:bg-[#232323] rounded-xl uppercase p-2 border-opacity-50 hover:border-opacity-75'>Home</p>
</Link>
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/about" className="w-auto px-2">
<p className='text-lg text-center text-white text-opacity-80 hover:text-opacity-100 hover:bg-[#232323] rounded-xl uppercase p-2 border-opacity-50 hover:border-opacity-75'>About</p>
</Link>
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/projects" className="w-auto px-2">
<p className='text-lg text-center text-white text-opacity-80 hover:text-opacity-100 hover:bg-[#232323] rounded-xl hover:border-opacity-75 uppercase p-2 border-opacity-50'>Projects</p>
</Link>
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/contact" className="w-auto px-2">
<p className='text-lg text-center text-white text-opacity-80 hover:text-opacity-100 hover:bg-[#232323] rounded-xl uppercase p-2 border-opacity-50 hover:border-opacity-75'>Contact</p>
</Link>
</motion.div>
)
}

View File

@@ -4,9 +4,6 @@ import { InlineLogo, useColorShift } from './logo'
import { useEffect, useState } from 'react';
import { RxActivityLog } from "react-icons/rx";
import { NavbarButton } from '../ui/Button';
import useAudio from '@/hooks/useAudio';
import AudioPlayer from '../Music/AudioPlayer/index';
import MobileMenu from './MobileMenu';
const SHIFT_INTERVAL = 3000;
@@ -40,9 +37,6 @@ export default function Navbar() {
<div className="hidden md:inline-flex justify-end w-3/4">
<NavbarButton href="/about" label="About" />
<NavbarButton href="/projects" label="Projects" />
<NavbarButton href="/read" label="Read" />
<NavbarButton href="/listen" label="Listen" />
<NavbarButton href="/links" label="Links" />
<NavbarButton href="/contact" label="Contact" />
</div>
@@ -53,9 +47,24 @@ export default function Navbar() {
</button>
</div>
</div>
<div onMouseLeave={() => setMobileMenuOpen(false)} className={`flex flex-col z-50 rounded-bl-lg justify-end md:hidden fixed top-24 w-[35vw] text-right place-self-end bg-[#131313] ${mobileMenuOpen ? 'translate-x-[65vw]' : 'translate-x-[100vw]'} transition-all duration-500`}>
<div className="bg-slate-300 dark:bg-black h-48" />
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/" className="w-auto px-2">
<p className='text-lg text-right text-white text-opacity-80 hover:text-opacity-100 uppercase p-2 border-opacity-50 hover:border-opacity-75'>Home</p>
</Link>
<MobileMenu mobileMenuOpen={mobileMenuOpen} setMobileMenuOpen={setMobileMenuOpen} />
<AudioPlayer />
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/about" className="w-auto px-2">
<p className='text-lg text-right text-white text-opacity-80 hover:text-opacity-100 uppercase p-2 border-opacity-50 hover:border-opacity-75'>About</p>
</Link>
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/links" className="w-auto px-2">
<p className='text-lg text-right text-white text-opacity-80 hover:text-opacity-100 hover:border-opacity-75 uppercase p-2 border-opacity-50'>Links</p>
</Link>
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/contact" className="w-auto px-2">
<p className='text-lg text-right text-white text-opacity-80 hover:text-opacity-100 uppercase p-2 border-opacity-50 hover:border-opacity-75'>Contact</p>
</Link>
</div>
</>
)
}

View File

@@ -1,8 +0,0 @@
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center w-screen h-screen">
<h1 className="text-4xl font-bold">404</h1>
<h2 className="text-2xl font-semibold">Page not found</h2>
</div>
)
}

View File

@@ -1,24 +1,42 @@
import Link from "next/link";
import Card from "../ui/Card";
const Experience = () => {
export default function Experience() {
return (
<section className="w-full">
<Card>
<Link target="_blank" referrerPolicy="no-referrer" href="https://vertafore.com" className="uppercase text-2xl text-rose-600 hover:text-rose-400 active:text-rose-600">Vertafore</Link>
<p className="font-light italic text-black dark:text-rose-300">Software Engineer</p>
<p className="dark:text-white">May 2024 - present</p>
<div className="h-[1px] w-full my-3 bg-rose-600 dark:bg-rose-300" />
<p className="dark:text-white leading-relaxed font-light">Contributing as part of a development team building an insurance tech solution.</p>
</Card>
<Card>
<Link target="_blank" referrerPolicy="no-referrer" href="https://epicstockmedia.com" className="uppercase text-2xl text-rose-600 hover:text-rose-400 active:text-rose-600">Epic Stock Media</Link>
<p className="font-light italic text-black dark:text-rose-300">Software Engineer</p>
<p className="dark:text-white">Dec 2023 - May 2024</p>
<div className="h-[1px] w-full my-3 bg-rose-600 dark:bg-rose-300" />
<p className="dark:text-white leading-relaxed font-light">Building a dedicated tool for sound design professionals to browse, stream, and download from a large library of audio assets in an on-premise managed cloud solution.</p>
</Card>
<Card>
<Link target="_blank" referrerPolicy="no-referrer" href="https://dropper.studio" className="uppercase text-2xl text-rose-600 hover:text-rose-400 active:text-rose-600">Dropper Studio</Link>
<p className="font-light italic text-black dark:text-rose-300">Nashville, TN (hybrid) - Software Engineer</p>
<p className="font-light italic text-black dark:text-rose-300">Software Engineer</p>
<p className="dark:text-white">March 2023 - present</p>
<div className="h-[1px] w-full my-3 bg-rose-600 dark:bg-rose-300" />
<p className="dark:text-white leading-relaxed font-light">Building a full-stack e-commerce platform for the music industry. Experience includes: producing a functional proof of concept from design specifications; constructing a scalable, performant full-stack architecture; and project/team management skills.</p>
<Link href="/about/work/dropper" className="text-rose-600 hover:text-rose-400 active:text-rose-300">Learn more about my work with Dropper</Link>
</Card>
<Card>
<Link target="_blank" referrerPolicy="no-referrer" href="https://dization.com/" className="uppercase text-2xl text-rose-600 hover:text-rose-400 active:text-rose-600">Dization, Inc.</Link>
<p className="font-light italic text-black dark:text-rose-300">Pittsburgh, PA (remote) - Software Engineer (intern)</p>
<p className="font-light italic text-black dark:text-rose-300">Software Engineer (intern)</p>
<p className="dark:text-white">October 2022 - March 2023</p>
<div className="h-[1px] w-full my-3 bg-rose-600 dark:bg-rose-300" />
@@ -30,17 +48,13 @@ const Experience = () => {
<Card>
<h3 className="uppercase text-2xl text-rose-600">Metazu Studio</h3>
<p className="font-light italic text-black dark:text-rose-300">Nashville, TN (hybrid) - Software Engineer (consultant)</p>
<p className="font-light italic text-black dark:text-rose-300">Software Engineer (consultant)</p>
<p className="dark:text-white">March 2022 - December 2022</p>
<div className="h-[1px] w-full my-3 bg-rose-600 dark:bg-rose-300" />
<p className="dark:text-white leading-relaxed font-light">Consulted on small teams for the design and engineering of full-stack web applications for clients. Used technologies including Node.js, React, MongoDB, and PostgreSQL.</p>
</Card>
<Link href="/about/work" className="text-rose-300 hover:text-rose-500 active:text-rose-300 bg-slate-950 p-2 rounded-lg shadow-lg">See more about my experience</Link>
</section>
)
}
export default Experience;

View File

@@ -13,9 +13,9 @@ const Projects = () => (
</Card>
<Card>
<h3 className="uppercase text-2xl text-rose-600">Recipin</h3>
<h3 className="uppercase text-2xl text-rose-600">Unbinder</h3>
<p className="font-light italic text-rose-300">October 2022 - present</p>
<p className="text-rose-300">React, Express, TypeScript, PostgreSQL</p>
<p className="text-rose-300">ASP.NET</p>
<div className="h-[1px] w-full my-3 bg-rose-600 dark:bg-rose-300" />

View File

@@ -15,8 +15,8 @@ const Skills = () => (
<div className="opacity-0 group-open:opacity-100 transition-opacity duration-500 flex flex-wrap">
<Chip label="Typescript" href="https://github.com/innocuous-symmetry?tab=repositories&language=typescript" />
<Chip label="React" href="https://github.com/innocuous-symmetry?tab=repositories&q=react" />
<Chip label="AWS S3 SDK" />
<Chip label="Next.js" />
<Chip label="AWS S3 SDK" />
<Chip label="tRPC" />
<Chip label="React Query" />
<Chip label="jQuery" />
@@ -53,7 +53,6 @@ const Skills = () => (
<div className="opacity-0 group-open:opacity-100 transition-opacity duration-500 flex flex-wrap">
<Chip label="Micropython" href="https://github.com/innocuous-symmetry/picosynth" />
<Chip label="Flask" />
<Chip label="Flet" />
</div>
</details>
</article>
@@ -80,6 +79,7 @@ const Skills = () => (
<RxChevronDown className="transition group-open:rotate-180" />
</summary>
<div className="opacity-0 group-open:opacity-100 transition-opacity duration-500 flex flex-wrap">
<Chip label="Virtualization / Proxmox" />
<Chip label="Docker" />
<Chip label="Github Actions" />
<Chip label="Git / Github" />
@@ -125,24 +125,11 @@ const Skills = () => (
<RxChevronDown className="transition group-open:rotate-180" />
</summary>
<div className="opacity-0 group-open:opacity-100 transition-opacity duration-500 flex flex-wrap">
<Chip label="Ruby / Rails" />
<Chip label="C# / ASP.NET" />
<Chip label="Golang" />
</div>
</details>
</article>
{/* <article className="mt-2">
<details className="group">
<summary className="flex items-center text-rose-600 dark:text-rose-300 uppercase tracking-wide text-lg mb-2 list-none">
<p className="mr-2">Natural Language Processing</p>
<RxChevronDown className="transition group-open:rotate-180" />
</summary>
<div className="opacity-0 group-open:opacity-100 transition-opacity duration-500 flex flex-wrap">
<p className="py-0.5 px-2 bg-rose-900 rounded-xl m-0.5">NLPT</p>
<p className="py-0.5 px-2 bg-rose-900 rounded-xl m-0.5">Spacy</p>
</div>
</details>
</article> */}
</div>
<details className="group bg-slate-400 dark:bg-slate-800 dark:bg-opacity-40 p-3 my-4 rounded-lg">

View File

@@ -1,5 +0,0 @@
import { default as NextLink } from "next/link";
export default function Link({ children, href }: { children: React.ReactNode, href: string }) {
return <NextLink className="dark:text-white dark:hover:text-violet-200 dark:active:text-white text-black hover:text-violet-950 active:text-black" href={href}>{children}</NextLink>
}

53
env.mjs
View File

@@ -1,44 +1,19 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from 'zod';
export const server = {
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
MONGO_URL: z.string().url(),
MONGO_USER: z.string().optional(),
MONGO_PASSWORD: z.string().optional(),
S3_ENDPOINT: z.string().url(),
S3_REGION: z.string(),
S3_ACCESS_KEY: z.string(),
S3_BUCKET: z.string(),
S3_SECRET: z.string().optional(),
KV_URL: z.string(),
}
export const client = {};
export const runtimeEnv = {
NODE_ENV: process.env.NODE_ENV,
MONGO_URL: process.env.MONGO_URL,
MONGO_USER: process.env.MONGO_USER,
MONGO_PASSWORD: process.env.MONGO_PASSWORD,
S3_ENDPOINT: process.env.S3_ENDPOINT,
S3_REGION: process.env.S3_REGION,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_BUCKET: process.env.S3_BUCKET,
S3_SECRET: process.env.S3_SECRET,
KV_URL: process.env.KV_URL,
}
const env = createEnv({ server, client, runtimeEnv })
export function envFactory() {
return createEnv({ server, client, runtimeEnv });
}
const env = createEnv({
server: {
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_TO: z.string().optional(),
SMTP_HOST: z.string().optional(),
},
runtimeEnv: {
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_TO: process.env.SMTP_TO,
SMTP_HOST: process.env.SMTP_HOST,
},
})
export { env }

View File

@@ -1,94 +0,0 @@
'use client';
import { MusicStreamingEntry } from "@/server/db/schema";
import { TrackWithURL } from "@/server/s3/service";
import { createContext, createRef, useCallback, useContext, useEffect, useRef, useState } from "react";
type AudioContextType = {
// boolean properties
isPlaying: boolean;
setIsPlaying: (isPlaying: boolean) => void;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
// track properties
// duration: number;
// volume: number;
// setVolume: (volume: number) => void;
// currentTime: number;
// setCurrentTime: (currentTime: number) => void;
handleTrackChange: (metadata: TrackWithURL, collection: MusicStreamingEntry) => void;
// identifying data
currentTrack?: TrackWithURL;
currentCollection?: MusicStreamingEntry;
setCurrentCollection: (collection: MusicStreamingEntry) => void;
// properties
thumbnailSrc?: string;
setThumbnailSrc: (src: string) => void;
}
const initialContext: AudioContextType = {
isPlaying: false,
setIsPlaying: () => {},
isOpen: false,
setIsOpen: () => {},
// volume: 0.5,
// setVolume: () => {},
// duration: 0,
// currentTime: 0,
// setCurrentTime: () => {},
handleTrackChange: () => {},
currentTrack: undefined,
currentCollection: undefined,
setCurrentCollection: () => {},
thumbnailSrc: undefined,
setThumbnailSrc: () => {},
}
const AudioContext = createContext<AudioContextType>(initialContext);
const AudioProvider = ({ children }: { children: React.ReactNode }) => {
const [currentCollection, setCurrentCollection] = useState<MusicStreamingEntry>();
const [currentTrack, setCurrentTrack] = useState<TrackWithURL>();
const [audioSource, setAudioSource] = useState<string>();
const [thumbnailSrc, setThumbnailSrc] = useState<string>();
const [isPlaying, setIsPlaying] = useState(false);
const [isOpen, setIsOpen] = useState(false);
// const [volume, setVolume] = useState(0.5);
// const [duration, setDuration] = useState(0);
// const [currentTime, setCurrentTime] = useState(0);
const audioRef = createRef<HTMLAudioElement>();
const handleTrackChange = useCallback((metadata: TrackWithURL, collection: MusicStreamingEntry) => {
setCurrentTrack(metadata);
setAudioSource(metadata.url);
setCurrentCollection(collection);
setIsOpen(true);
setIsPlaying(true);
}, [])
useEffect(() => {
isPlaying ? audioRef.current?.play() : audioRef.current?.pause();
}, [isPlaying, audioRef])
return (
<AudioContext.Provider value={{
currentTrack, isOpen, isPlaying, currentCollection, thumbnailSrc,
setIsOpen, handleTrackChange, setIsPlaying, setCurrentCollection, setThumbnailSrc
}}>
{children}
<audio className="hidden" style={{ display: "none" }} ref={audioRef} controls autoPlay={isPlaying} src={audioSource} />
</AudioContext.Provider>
)
}
export default function useAudio() {
return useContext(AudioContext);
}
export { AudioProvider, AudioContext }
export type { AudioContextType }

View File

@@ -1,17 +0,0 @@
import type { MDXComponents } from "mdx/types";
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
h1: (props) =>
<h1 className="text-3xl text-black font-semi-bold" {...props} />,
h2: (props) =>
<h2 className="text-2xl text-black font-semi-bold" {...props} />,
h3: (props) =>
<h3 className="text-xl text-black font-semi-bold" {...props} />,
p: (props) => <p className="text-black" {...props} />,
a: (props) => <a className="text-violet-900" {...props} />,
ul: (props) => <ul className="list-disc" {...props} />,
li: (props) => <li className="text-black" {...props} />,
}
}

View File

View File

@@ -1,9 +1,10 @@
import createMDX from '@next/mdx'
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'mdx'],
pageExtensions: ['js', 'jsx', 'ts', 'tsx'],
reactStrictMode: true,
experimental: {
serverActions: true,
}
}
export default createMDX()(nextConfig);
module.exports = nextConfig;

View File

@@ -2,49 +2,37 @@
"name": "mikayla-dobson-dev",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db-seed": "node --env-file=.env ./server/db/seed.js",
"s3-seed": "ts-node --project ./tsconfig.json ./server/s3/seed.ts"
"lint": "next lint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.367.0",
"@aws-sdk/s3-request-presigner": "^3.445.0",
"@mdx-js/loader": "^3.0.0",
"@mdx-js/react": "^3.0.0",
"@next/mdx": "^14.0.3",
"@smithy/node-http-handler": "^2.1.8",
"@t3-oss/env-nextjs": "^0.7.0",
"@vercel/style-guide": "^5.0.1",
"autoprefixer": "10.4.14",
"framer-motion": "^11.0.3",
"ioredis": "^5.3.2",
"mongodb": "^6.3.0",
"next": "^14.0.1",
"next-mdx-remote": "^4.4.1",
"pg": "^8.11.3",
"eslint": "^8.46.0",
"eslint-config-next": "^13.4.12",
"next": "^13.4.12",
"nodemailer": "^6.9.13",
"postcss": "^8.4.31",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.9.0",
"tailwindcss": "3.3.2",
"typescript": "5.0.4",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/mdx": "^2.0.10",
"@types/node": "^20.10.0",
"@types/node": "20.2.5",
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.10.3",
"@types/react": "18.2.7",
"@types/react-dom": "18.2.4",
"@types/uuid": "^9.0.1",
"eslint": "^8.46.0",
"eslint-config-next": "^13.4.12",
"postcss": "^8.4.31",
"tailwindcss": "3.3.2",
"@vercel/style-guide": "^5.0.1",
"ts-node": "^10.9.1",
"typescript": "5.0.4",
"zod": "^3.22.4"
}
}

20
pkg/.eslintrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
}
}

1
pkg/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
lib

2
pkg/.npmignore Normal file
View File

@@ -0,0 +1,2 @@
src
eslintrc.json

2
pkg/README.md Normal file
View File

@@ -0,0 +1,2 @@
# Helpers for mikayla.dev

29
pkg/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@mkladotdev/utils",
"version": "0.0.1",
"description": "Utilities for mikayla.dev",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"files": [
"./lib/**/*"
],
"scripts": {
"build": "tsc",
"lint": "eslint . --ext .ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Mikayla Dobson",
"license": "ISC",
"dependencies": {},
"devDependencies": {
"@types/node": "^20.5.9",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"eslint": "^8.49.0",
"typescript": "^5.2.2"
},
"publishConfig": {
"registry": "http://localhost:4873"
}
}

View File

@@ -0,0 +1,12 @@
export type Track = {
name: string;
date: Date;
description: string;
}
export type AudioCollection = {
name: string;
date: Date;
tracklist: Track[];
directory: string;
}

9
pkg/src/entities/post.ts Normal file
View File

@@ -0,0 +1,9 @@
export type Post = {
name: string;
date: Date;
author: string;
description: string;
body: string;
tagIDs: string[];
media?: string[]; // array of URLs
}

View File

@@ -0,0 +1,49 @@
import { z } from "zod";
export type Project = {
name: string;
description: string;
created: Date;
updated?: Date;
tagIDs?: string[];
media?: string[]; // array of URLs
}
export class ProjectCreationError extends Error {
constructor(message: string) {
super(message);
this.name = "ProjectCreationError";
}
}
export const ZProject = z.object({
name: z.string(),
description: z.string(),
created: z.date(),
updated: z.date().optional(),
tagIDs: z.array(z.string()).optional(),
media: z.array(z.string()).optional(),
})
export function createProject(data: Partial<Project>) {
if (!data.name) throw new ProjectCreationError("Project name is required");
if (!data.description) throw new ProjectCreationError("Project description is required");
const today = new Date();
const completeInput = {
name: data.name,
description: data.description,
created: data.created || today,
updated: today,
} satisfies Partial<Project>;
const parsedProject = ZProject.safeParse(completeInput);
if (!parsedProject.success) throw new ProjectCreationError("Invalid project data");
return parsedProject.data satisfies Project;
}
export function isProject(data: unknown): data is Project {
return ZProject.safeParse(data).success;
}

28
pkg/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
/* Projects */
"incremental": true,
/* Language and Environment */
"target": "es2016",
/* Modules */
"module": "commonjs",
"rootDir": "./src",
/* Emit */
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./lib",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
/* Type Checking */
"strict": true,
"noImplicitAny": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["lib", "node_modules"]
}

View File

@@ -0,0 +1,32 @@
'use server';
import { createTransport } from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport";
import { env } from "../../env.mjs"
export async function submitMessage({ from, text }: { from: string, text: string }) {
const options = {
host: env.SMTP_HOST,
port: 587,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
} as SMTPTransport.Options;
// console.log({ options });
// const transport = createTransport(options);
// const result = await transport.sendMail({
// subject: "Contact Form Submission | mikayla.dev",
// to: env.SMTP_TO,
// from,
// text,
// });
// const failed = result.rejected.concat(result.pending).filter(Boolean);
// if (failed.length) {
// throw new Error("Failed to send email verification");
// }
}

View File

@@ -1,10 +0,0 @@
import Redis, { type RedisOptions } from "ioredis"
import { env } from "@/env.mjs";
export default function createRedisClient() {
const config: RedisOptions = {
host: env.KV_URL,
}
return new Redis(config);
}

View File

@@ -1,80 +0,0 @@
import { createDBClient } from '../db/createClient';
import { Maybe } from '@/util/helpers';
import { ParseParams } from 'zod';
import { MongoClient, WithId, ObjectId, InsertOneResult, Filter } from 'mongodb';
type FullParserType<T extends { [key: string]: any }> = (data: any, params?: Partial<ParseParams>) => T;
type ControllerOptions<T extends { [key: string]: any }> = {
tableName: string
parse: FullParserType<T>
}
export default abstract class BaseController<T extends { _id?: any, [key: string]: any }> {
protected client: MongoClient | null;
protected collectionName: string
protected parse: FullParserType<T>
constructor(options: ControllerOptions<T>) {
this.collectionName = options.tableName;
this.client = createDBClient();
this.parse = options.parse;
}
async getAll() {
if (!this.client) return null;
let result: Maybe<WithId<T>[]>;
try {
// we'll enable cache here later
await this.client.connect();
result = await this.client.db('mikayladotdev').collection<T>(this.collectionName)
.find()
.toArray();
return result;
} catch (error) {
console.log({ error });
result = null;
} finally {
await this.client.close();
return result;
}
}
async getByID(id: string): Promise<Maybe<WithId<T>>> {
if (!this.client) return null;
let result: Maybe<WithId<T>>;
try {
await this.client.connect();
result = await this.client.db().collection<T>(this.collectionName)
.findOne({ where: { _id: id }});
} catch (error) {
console.log({ error });
result = null;
} finally {
await this.client.close();
return result;
}
}
async post(data: T) {
if (!this.client) return null;
let result: Maybe<InsertOneResult<T>>;
this.parse(data);
try {
await this.client.connect();
result = await this.client.db().collection<T>(this.collectionName)
.insertOne(data as any);
} catch(error) {
console.log({ error });
result = null;
} finally {
await this.client.close();
return result;
}
}
}

View File

@@ -1,11 +0,0 @@
import { BlogPost, ZBlogPost } from "../db/schema";
import BaseController from "./base.controller";
export default class BlogPostController extends BaseController<BlogPost> {
constructor() {
super({
tableName: "blogposts",
parse: ZBlogPost.parse,
})
}
}

View File

@@ -1,11 +0,0 @@
import { MusicStreamingEntry, ZMusicStreamingEntry } from "../db/schema";
import BaseController from "./base.controller";
export default class MusicController extends BaseController<MusicStreamingEntry> {
constructor() {
super({
tableName: "music",
parse: ZMusicStreamingEntry.parse,
})
}
}

View File

@@ -1,14 +0,0 @@
import { env } from "@/env.mjs";
import { MongoClient } from "mongodb";
export function createDBClient() {
try {
return new MongoClient(env.MONGO_URL, {
tls: true,
tlsCertificateKeyFile: process.cwd() + "/certs/mongo_cert.pem",
});
} catch (e) {
console.log(e);
return null;
}
}

View File

@@ -1,6 +0,0 @@
export class PostgresError extends Error {
constructor(message: string) {
super(message);
this.name = "PostgresError";
}
}

View File

@@ -1,40 +0,0 @@
import { z } from 'zod';
const filePathMatcher = /^[1-9]{1,3}\.(wav|mp3|(jpe?g)|png)$/;
const ZFileName = z.string().regex(filePathMatcher);
const ObjectId = z.string().regex(/^[0-9a-fA-F]{24}$/);
export const ZMusicStreamingEntry = z.object({
_id: ObjectId.optional(),
name: z.string().max(100),
shortdescription: z.string().max(100),
longdescription: z.string().max(1000),
/** where to find the track in AWS S3 */
pathtoentry: z.string(),
// optional properties
artist: z.string().max(100).optional(),
year: z.number().min(1900).max(2100).optional(),
tags: z.array(z.string().max(100)).optional(),
});
export const ZBlogPost = z.object({
_id: ObjectId.optional(),
title: z.string().max(100),
author: z.string().max(100),
content: z.string(),
images: z.array(z.string()).optional(),
posted: z.date(),
written: z.date().optional(),
updated: z.date().optional(),
tags: z.array(z.string().max(100)).optional(),
})
export type MusicStreamingEntry = z.infer<typeof ZMusicStreamingEntry>;
export type BlogPost = z.infer<typeof ZBlogPost>;
export type ValidFileName = z.infer<typeof ZFileName>;

View File

@@ -1,85 +0,0 @@
import { MongoClient } from "mongodb";
import { readFileSync } from "fs";
import { z } from "zod";
async function main() {
console.log("Preparing seed data...")
const nodeMajorVersion = parseInt(process.version.split(".")[0].split("v")[1]);
const nodeMinorVersion = parseInt(process.version.split(".")[1]);
if (nodeMajorVersion < 20 || (nodeMajorVersion === 20 && nodeMinorVersion < 10)) {
throw new Error("Database seed depends on Node version 20.10.0 or higher");
}
if (!process.env.MONGO_URL) throw new Error("Missing required variable `MONGO_URL` for database connection");
const connectionString = z.string().url().parse(process.env.MONGO_URL);
// check if documents and collections exist
const client = new MongoClient(connectionString, {
tlsCertificateKeyFile: process.cwd() + "/certs/mongo_cert.pem",
});
/** @type {import("./schema").MusicStreamingEntry} */
const jisei = {
name: "Jisei",
shortdescription: "A song about death",
longdescription: "A song about death",
artist: "Mikayla Dobson",
pathtoentry: "/Jisei"
}
/** @type {import("./schema").MusicStreamingEntry} */
const perception = {
name: "Perception",
shortdescription: "A song about perception",
longdescription: "A song about perception",
artist: "Mikayla Dobson",
pathtoentry: "/Perception"
}
const firstDate = new Date();
firstDate.setFullYear(2023, 11, 22);
const secondDate = new Date();
secondDate.setFullYear(2023, 11, 26);
/** @type {import("./schema").BlogPost} */
const logOne = {
title: "Welcome to My Devlog",
tags: ["subsequent", "typescript", "devlog", "generative music"],
written: firstDate,
posted: secondDate,
author: "Mikayla Dobson",
content: readFileSync(process.cwd() + "/seed_data/posts/01.txt", "utf-8"),
}
try {
console.log("Connecting to MongoDB...")
const client = new MongoClient(connectionString, {
tlsCertificateKeyFile: process.cwd() + "/certs/mongo_cert.pem",
});
await client.connect();
const db = client.db("mikayladotdev");
const music = db.collection("music");
const blog = db.collection("blog");
console.log("Seeding data...")
await music.insertMany([jisei, perception]);
await blog.insertOne(logOne);
console.log("Seeding complete! Closing...")
await client.close();
} catch (error) {
console.log({ error });
}
console.log("Done!")
process.exit(0);
}
main();

View File

@@ -0,0 +1,12 @@
export type Track = {
name: string;
date: Date;
description: string;
}
export type AudioCollection = {
name: string;
date: Date;
tracklist: Track[];
directory: string;
}

9
server/entities/post.ts Normal file
View File

@@ -0,0 +1,9 @@
export type Post = {
name: string;
date: Date;
author: string;
description: string;
body: string;
tagIDs: string[];
media?: string[]; // array of URLs
}

View File

@@ -0,0 +1,49 @@
import { z } from "zod";
export type Project = {
name: string;
description: string;
created: Date;
updated?: Date;
tagIDs?: string[];
media?: string[]; // array of URLs
}
export class ProjectCreationError extends Error {
constructor(message: string) {
super(message);
this.name = "ProjectCreationError";
}
}
export const ZProject = z.object({
name: z.string(),
description: z.string(),
created: z.date(),
updated: z.date().optional(),
tagIDs: z.array(z.string()).optional(),
media: z.array(z.string()).optional(),
})
export function createProject(data: Partial<Project>) {
if (!data.name) throw new ProjectCreationError("Project name is required");
if (!data.description) throw new ProjectCreationError("Project description is required");
const today = new Date();
const completeInput = {
name: data.name,
description: data.description,
created: data.created || today,
updated: today,
} satisfies Partial<Project>;
const parsedProject = ZProject.safeParse(completeInput);
if (!parsedProject.success) throw new ProjectCreationError("Invalid project data");
return parsedProject.data satisfies Project;
}
export function isProject(data: unknown): data is Project {
return ZProject.safeParse(data).success;
}

3
server/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export default async function main() {
return await Promise.resolve(12);
}

View File

@@ -1,24 +0,0 @@
import { env } from "@/env.mjs";
import { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import https from "https";
export default function createS3Client() {
if (typeof env.S3_ACCESS_KEY !== "string") {
throw new Error("S3_ACCESS_KEY is not defined");
}
if (typeof env.S3_SECRET !== "string") {
throw new Error("S3_SECRET is not defined");
}
const config: S3ClientConfig = {
region: env.S3_REGION,
credentials: {
accessKeyId: env.S3_ACCESS_KEY,
secretAccessKey: env.S3_SECRET,
}
}
return new S3Client(config);
}

View File

@@ -1,2 +0,0 @@
export { default as S3Service } from "./service";
export { default as createS3Client } from "./createClient";

View File

@@ -1,105 +0,0 @@
import { GetObjectCommand, ListObjectsV2Command, S3Client, _Object } from "@aws-sdk/client-s3";
import { env } from "@/env.mjs";
import createS3Client from "./createClient";
import { Maybe, must } from "@/util/helpers";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export type TrackWithURL = (Required<Pick<_Object, "Key">> & _Object) & { url: string }
export default class S3Service {
static asEndpoint(key: string): string {
return `${env.S3_ENDPOINT}/${env.S3_BUCKET}/${key}`;
}
static async getURL(location: string): Promise<Maybe<string>> {
try {
const client = must(createS3Client);
const command = new GetObjectCommand({
Bucket: env.S3_BUCKET,
Key: location
});
const url = await getSignedUrl(client, command, { expiresIn: 3600 });
return url
} catch (error) {
console.log({ error });
return null;
}
}
static async listObjects(key: string): Promise<Maybe<_Object[]>> {
try {
const client: S3Client = must(createS3Client);
let remaining = 50;
// initialize list objects command
const cmd = new ListObjectsV2Command({
Bucket: env.S3_BUCKET,
Prefix: key,
});
let isTruncated = true;
const result = new Array<_Object>();
// loop through until all resources have been enumerated
while (isTruncated && remaining > 0) {
--remaining;
const res = await client.send(cmd);
const { Contents, IsTruncated, NextContinuationToken } = res;
if (Contents) result.push(...Contents);
isTruncated = IsTruncated ?? false;
cmd.input.ContinuationToken = NextContinuationToken;
}
return result;
} catch (error) {
console.log({ error });
return null;
}
}
static async getURLs(key: string): Promise<{
files: Maybe<_Object[]>,
urlList: Maybe<string[]>
}> {
try {
const files = await this.listObjects(key);
const urlList = new Array<string>();
files?.forEach(async(file) => {
if (file.Key) {
const url = await this.getURL(file.Key);
if (url) urlList.push(url);
}
});
return { files, urlList }
} catch (error) {
console.log({ error });
return { files: null, urlList: null }
}
}
static async prepareTrackList(key: string): Promise<TrackWithURL[]> {
const result = new Array<TrackWithURL>();
try {
const files = await this.listObjects(key);
files?.forEach(async(file) => {
if (file.Key) {
const url = await this.getURL(file.Key);
if (url) result.push({ ...file, url, Key: file.Key });
}
})
return result;
} catch (error) {
console.log({ error });
return new Array<TrackWithURL>();
}
}
}

19
server/services/s3.ts Normal file
View File

@@ -0,0 +1,19 @@
import { S3Client } from "@aws-sdk/client-s3";
export default function createS3Client() {
if (typeof process.env.S3_ACCESS_KEY !== "string") {
throw new Error("S3_ACCESS_KEY is not defined");
}
if (typeof process.env.S3_SECRET !== "string") {
throw new Error("S3_SECRET is not defined");
}
return new S3Client({
region: "us-east-1",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET,
},
});
}

View File

@@ -1,5 +1,5 @@
/** @type {import('tailwindcss').Config} */
export default {
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',

View File

@@ -1,7 +1,7 @@
{
"extends": "@vercel/style-guide/typescript",
"compilerOptions": {
"target": "ES6",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@@ -25,8 +25,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"ts-node": {
"esm": true
}
"exclude": ["node_modules"]
}

View File

@@ -1,14 +0,0 @@
export function must<T = any>(func: CallableFunction): T {
try {
return func();
} catch(e) {
console.log('error', e);
throw e;
}
}
export function prettyFileName(key: string): string {
return key.split('/').pop()?.split(".").shift() ?? "INVALID KEY";
}
export type Maybe<T> = T | null | undefined;

View File

@@ -1,16 +0,0 @@
export default function rateLimiter(callsPerSecond: number, func: (...args: any[]) => any) {
let lastFetch = 0;
let cachedValue: any = null;
return new Proxy(func, {
apply(target, thisArg, args) {
const newTime = new Date().getTime();
if (newTime - lastFetch > (1000 / callsPerSecond)) {
cachedValue = target.apply(thisArg, args);
lastFetch = newTime;
}
return cachedValue
}
});
}