17 Commits

Author SHA1 Message Date
1ad1cc509e better mobile support, error handling 2024-02-04 11:55:19 -06:00
4549c0e696 build fixes and style updates 2024-02-04 11:17:21 -06:00
b32d8774e6 remove old requirements; build fixes 2023-11-28 12:54:34 -06:00
368a38c1b0 enabled connection to mongodb 2023-11-27 10:56:38 -06:00
2fd3eae66a db seed logic 2023-11-26 22:16:42 -06:00
7956266888 corrections to schemas 2023-11-26 21:43:03 -06:00
86e89a7183 include new views for blog post entries 2023-11-26 21:28:53 -06:00
4eaeb3cb8f Merge branch 'music-streaming' into mongodb 2023-11-26 21:13:30 -06:00
747b6d3f2d check in audio player changes 2023-11-26 21:13:14 -06:00
5c61014632 migrate to mongodb 2023-11-26 21:11:58 -06:00
f05f2c59e8 fixes for some play states, mapping track metadata 2023-11-08 16:56:17 -06:00
8c862342de building out audio playback component 2023-11-08 12:56:10 -06:00
a01afd09f6 slightly improved browsing experience 2023-11-08 11:27:40 -06:00
8fb7221e26 enabled rudimentary audio streaming 2023-11-07 18:15:09 -06:00
8c8ca802aa in progress: configuring s3 to allow public get 2023-11-07 15:19:26 -06:00
5fc7a9f983 more fun backend stuff, ecmascript wrangling 2023-11-07 13:43:24 -06:00
46e6a497a2 clean up some unused code, structuring server-side 2023-11-07 09:38:39 -06:00
58 changed files with 1045 additions and 435 deletions

View File

@@ -8,10 +8,23 @@ env:
on: on:
push: push:
branches: branches:
- master # 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
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: self-hosted
steps: steps:
- name: checkout - name: checkout
@@ -22,3 +35,10 @@ jobs:
run: npm install run: npm install
- name: build check - name: build check
run: npm run build run: npm run build
deploy:
runs-on: self-hosted
steps:
- name: deploy service
run: ""

9
.gitignore vendored
View File

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

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
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,5 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from "react"; import { motion } from 'framer-motion';
import { useEffect, useMemo, useState } from "react";
export default function ContactPage() { export default function ContactPage() {
const MESSAGE_LIMIT = 600; const MESSAGE_LIMIT = 600;
@@ -11,32 +12,32 @@ export default function ContactPage() {
const characterCount = useMemo(() => message.length.toString(), [message]); const characterCount = useMemo(() => message.length.toString(), [message]);
return ( 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"> <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">
<div className="flex flex-col mx-24 items-center dark:text-white "> <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">
<h1 className="text-3xl my-8 place-self-start">Thanks for your interest! I&apos;m looking forward to hearing from you.</h1> <h1 className="text-3xl my-8 place-self-start">Thanks for your interest! I&apos;m looking forward to hearing from you.</h1>
<form className="w-full"> <motion.form /* initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.25, delay: 0.5 }} */ className="w-full">
<div className="flex w-full"> <div className="flex flex-col lg:flex-row w-full">
<div className="flex flex-col w-1/2 mr-2"> <div className="flex flex-col w-full lg:w-1/2 lg:mr-2">
<label htmlFor="name">Name</label> <label htmlFor="name">Name</label>
<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" /> <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" />
</div> </div>
<div className="flex flex-col w-1/2 ml-2"> <div className="flex flex-col w-full lg:w-1/2 lg:ml-2">
<label htmlFor="email">Email</label> <label htmlFor="email">Email</label>
<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" /> <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" />
</div> </div>
</div> </div>
<div className="flex flex-col w-full mt-4"> <div className="flex flex-col w-full mt-4">
<label htmlFor="message">Message</label> <label htmlFor="message">Message</label>
<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> <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>
<p className={"text-sm " + (parseInt(characterCount) > MESSAGE_LIMIT ? "text-red-500" : "text-black dark:text-white")}>{characterCount}/{MESSAGE_LIMIT}</p> <p className={"text-sm " + (parseInt(characterCount) > MESSAGE_LIMIT ? "text-red-500" : "text-black dark:text-white")}>{characterCount}/{MESSAGE_LIMIT}</p>
</div> </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> <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>
</form> </motion.form>
</div> </motion.div>
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ import './globals.css'
import Head from 'next/head' import Head from 'next/head'
import Navbar from '@/components/Navbar' import Navbar from '@/components/Navbar'
import { Inter, Besley, Cabin } from 'next/font/google' import { Inter, Besley, Cabin } from 'next/font/google'
import { AudioProvider } from '@/hooks/useAudio'
export const inter = Inter({ subsets: ['latin'] }) export const inter = Inter({ subsets: ['latin'] })
export const besley = Besley({ subsets: ['latin'] }) export const besley = Besley({ subsets: ['latin'] })
@@ -18,11 +19,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<body className={inter.className}> <body className={inter.className}>
<AudioProvider>
<Navbar /> <Navbar />
<div> <div>
<div id="navbar-spacer" className="h-[6rem] w-full bg-slate-300 dark:bg-black " /> <div id="navbar-spacer" className="h-[6rem] w-full bg-slate-300 dark:bg-black " />
{children} {children}
</div> </div>
</AudioProvider>
</body> </body>
</html> </html>
) )

View File

@@ -1,5 +1,41 @@
import InProgress from "@/components/InProgress"; 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";
export default async function ListenByCollectionID() { export default async function ListenByCollectionID({ params }: { params: { collectionid?: string }}) {
return <InProgress /> 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>
)
} }

View File

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

35
app/log/[postid]/page.tsx Normal file
View File

@@ -0,0 +1,35 @@
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>
)
}

29
app/log/page.tsx Normal file
View File

@@ -0,0 +1,29 @@
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,34 +0,0 @@
// 'use client';
import ProjectRepository from "@/server/actions/project.actions";
import Image from "next/image";
export default async function ProjectById(req: { params: any, searchParams: any }) {
const projects = new ProjectRepository();
const project = await projects.getProjectById(req.params.id);
if (!project) {
return (
<div>
<h1>Project not found!</h1>
</div>
)
}
return (
<article id="project-entry-body">
<header>
<h1 className="text-4xl font-bold">{project.name}</h1>
<p>Started: {project.created.toLocaleString()}</p>
<p>{project.updated ? `Finished: ${project.updated.toLocaleDateString()}` : "(In progress)"}</p>
</header>
<div id="project-entry-content" className="flex flex-col">
<p>{project.description}</p>
</div>
{ project.media && project.media.map((link, idx) => {
return <Image src={link} key={idx} alt={`Media for ${project.name}`} width={80} height={80} />
})}
</article>
)
}

View File

@@ -0,0 +1,8 @@
// 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

@@ -0,0 +1,19 @@
'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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,70 @@
'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

@@ -0,0 +1,49 @@
'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

@@ -0,0 +1,20 @@
'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

@@ -0,0 +1,54 @@
'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,6 +4,9 @@ import { InlineLogo, useColorShift } from './logo'
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { RxActivityLog } from "react-icons/rx"; import { RxActivityLog } from "react-icons/rx";
import { NavbarButton } from '../ui/Button'; import { NavbarButton } from '../ui/Button';
import useAudio from '@/hooks/useAudio';
import AudioPlayer from '../Music/AudioPlayer/index';
import MobileMenu from './MobileMenu';
const SHIFT_INTERVAL = 3000; const SHIFT_INTERVAL = 3000;
@@ -50,24 +53,9 @@ export default function Navbar() {
</button> </button>
</div> </div>
</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>
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/about" className="w-auto px-2"> <MobileMenu mobileMenuOpen={mobileMenuOpen} setMobileMenuOpen={setMobileMenuOpen} />
<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> <AudioPlayer />
</Link>
<Link onClick={() => setMobileMenuOpen(false)} passHref href="/projects" 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'>Projects</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>
</> </>
) )
} }

8
components/NotFound.tsx Normal file
View File

@@ -0,0 +1,8 @@
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>
)
}

5
components/ui/Link.tsx Normal file
View File

@@ -0,0 +1,5 @@
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>
}

51
env.mjs
View File

@@ -1,17 +1,44 @@
import { createEnv } from "@t3-oss/env-nextjs"; import { createEnv } from "@t3-oss/env-nextjs";
import { z } from 'zod'; import { z } from 'zod';
const env = createEnv({ export const server = {
server: { NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
POSTGRES_URL: z.string().url(),
POSTGRES_USER: z.string(), MONGO_URL: z.string().url(),
POSTGRES_PASSWORD: z.string(), MONGO_USER: z.string().optional(),
}, MONGO_PASSWORD: z.string().optional(),
runtimeEnv: {
POSTGRES_URL: process.env.POSTGRES_URL, S3_ENDPOINT: z.string().url(),
POSTGRES_USER: process.env.POSTGRES_USER, S3_REGION: z.string(),
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD, 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 });
}
export { env } export { env }

94
hooks/useAudio.tsx Normal file
View File

@@ -0,0 +1,94 @@
'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 }

17
mdx-components.tsx Normal file
View File

@@ -0,0 +1,17 @@
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} />,
}
}

0
mgmt/index.ts Normal file
View File

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
{
"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
View File

@@ -1 +0,0 @@
lib

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
{
"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

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

View File

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

View File

@@ -1,49 +0,0 @@
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;
}

View File

@@ -1,28 +0,0 @@
{
"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

@@ -1,56 +0,0 @@
import { Project, isProject } from "../entities/project";
import createClient from "../services/pg";
export default class ProjectRepository {
async createProject(data: Project) {
const client = await createClient();
if (!client) return null;
await client.connect();
const { rows } = await client.query(
"INSERT INTO project (name, description, created, updated) VALUES ($1, $2, $3, $4) RETURNING *"
, [
data.name,
data.description,
data.created,
data.updated,
]);
await client.end();
if (rows.every(row => isProject(row))) {
return rows[0] as Project;
}
return null;
}
async getProjects() {
const client = await createClient();
if (!client) return null;
const { rows } = await client.query("SELECT * FROM project");
await client.end();
if (rows.every(row => isProject(row))) {
return rows as Project[];
}
return null;
}
async getProjectById(id: string) {
const client = await createClient();
if (!client) return null;
await client.connect();
const { rows } = await client.query("SELECT * FROM project WHERE id = $1", [id]);
await client.end();
if (rows.every(row => isProject(row))) {
return rows[0] as Project;
}
return null;
}
}

10
server/cache/createClient.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,80 @@
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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,11 @@
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,
})
}
}

14
server/db/createClient.ts Normal file
View File

@@ -0,0 +1,14 @@
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;
}
}

6
server/db/index.ts Normal file
View File

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

40
server/db/schema.ts Normal file
View File

@@ -0,0 +1,40 @@
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>;

85
server/db/seed.js Normal file
View File

@@ -0,0 +1,85 @@
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

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

View File

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

View File

@@ -1,49 +0,0 @@
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;
}

View File

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

24
server/s3/createClient.ts Normal file
View File

@@ -0,0 +1,24 @@
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);
}

2
server/s3/index.ts Normal file
View File

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

105
server/s3/service.ts Normal file
View File

@@ -0,0 +1,105 @@
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>();
}
}
}

View File

@@ -1,24 +0,0 @@
import { env } from "@/env.mjs";
import pg from 'pg';
const { Client } = pg;
export class PostgresError extends Error {
constructor(message: string) {
super(message);
this.name = "PostgresError";
}
}
export default async function createClient() {
try {
return new Client({
connectionString: env.POSTGRES_URL,
user: env.POSTGRES_USER,
password: env.POSTGRES_PASSWORD,
ssl: { rejectUnauthorized: false }
});
} catch(e) {
console.log('error creating client', e);
return null;
}
}

View File

@@ -1,19 +0,0 @@
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} */ /** @type {import('tailwindcss').Config} */
module.exports = { export default {
content: [ content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}', './pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}',

View File

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

14
util/helpers.ts Normal file
View File

@@ -0,0 +1,14 @@
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;

16
util/limiter.ts Normal file
View File

@@ -0,0 +1,16 @@
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
}
});
}