Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ad1cc509e | |||
| 4549c0e696 | |||
| b32d8774e6 | |||
| 368a38c1b0 | |||
| 2fd3eae66a | |||
| 7956266888 | |||
| 86e89a7183 | |||
| 4eaeb3cb8f | |||
| 747b6d3f2d | |||
| 5c61014632 | |||
| f05f2c59e8 | |||
| 8c862342de | |||
| a01afd09f6 | |||
| 8fb7221e26 | |||
| 8c8ca802aa | |||
| 5fc7a9f983 | |||
| 46e6a497a2 |
24
.github/workflows/main.yml
vendored
24
.github/workflows/main.yml
vendored
@@ -8,10 +8,23 @@ env:
|
||||
on:
|
||||
push:
|
||||
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:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -22,3 +35,10 @@ jobs:
|
||||
run: npm install
|
||||
- name: build check
|
||||
run: npm run build
|
||||
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: deploy service
|
||||
run: ""
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -6,6 +6,10 @@
|
||||
.pnp.js
|
||||
package-lock.json
|
||||
|
||||
# assets
|
||||
/seed_data
|
||||
/certs
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
@@ -16,10 +20,15 @@ package-lock.json
|
||||
# production
|
||||
/build
|
||||
|
||||
# python
|
||||
mgmt-venv/
|
||||
blob_staging/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.env
|
||||
sandbox/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal 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" ]
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from "react";
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export default function ContactPage() {
|
||||
const MESSAGE_LIMIT = 600;
|
||||
@@ -11,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">
|
||||
<div className="flex flex-col mx-24 items-center dark:text-white ">
|
||||
<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">
|
||||
<h1 className="text-3xl my-8 place-self-start">Thanks for your interest! I'm looking forward to hearing from you.</h1>
|
||||
|
||||
<form className="w-full">
|
||||
<div className="flex w-full">
|
||||
<div className="flex flex-col w-1/2 mr-2">
|
||||
<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">
|
||||
<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 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>
|
||||
<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 className="flex flex-col w-full mt-4">
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</motion.form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'] })
|
||||
@@ -11,19 +12,21 @@ 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}>
|
||||
<Navbar />
|
||||
<div>
|
||||
<div id="navbar-spacer" className="h-[6rem] w-full bg-slate-300 dark:bg-black " />
|
||||
{children}
|
||||
</div>
|
||||
</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}>
|
||||
<AudioProvider>
|
||||
<Navbar />
|
||||
<div>
|
||||
<div id="navbar-spacer" className="h-[6rem] w-full bg-slate-300 dark:bg-black " />
|
||||
{children}
|
||||
</div>
|
||||
</AudioProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
return <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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
let allResults: Awaited<ReturnType<MusicController["getAll"]>>;
|
||||
|
||||
try {
|
||||
const controller = new MusicController();
|
||||
allResults = await controller.getAll();
|
||||
} catch {
|
||||
allResults = [];
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Listen</h1>
|
||||
{/* @ts-ignore server component */}
|
||||
<InProgress />
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<FullMusicList allResults={allResults} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
35
app/log/[postid]/page.tsx
Normal file
35
app/log/[postid]/page.tsx
Normal 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
29
app/log/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
8
components/Log/PostComponent.tsx
Normal file
8
components/Log/PostComponent.tsx
Normal 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 />
|
||||
// }
|
||||
19
components/Music/AudioGallery.tsx
Normal file
19
components/Music/AudioGallery.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
components/Music/AudioPlayer/_Button.tsx
Normal file
16
components/Music/AudioPlayer/_Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
components/Music/AudioPlayer/index.tsx
Normal file
70
components/Music/AudioPlayer/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
49
components/Music/AudioTrack.tsx
Normal file
49
components/Music/AudioTrack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
components/Music/FullMusicList.tsx
Normal file
20
components/Music/FullMusicList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
components/Navbar/MobileMenu.tsx
Normal file
54
components/Navbar/MobileMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,9 @@ 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;
|
||||
|
||||
@@ -50,24 +53,9 @@ 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>
|
||||
|
||||
<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="/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>
|
||||
<MobileMenu mobileMenuOpen={mobileMenuOpen} setMobileMenuOpen={setMobileMenuOpen} />
|
||||
<AudioPlayer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
8
components/NotFound.tsx
Normal file
8
components/NotFound.tsx
Normal 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
5
components/ui/Link.tsx
Normal 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
51
env.mjs
@@ -1,17 +1,44 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from 'zod';
|
||||
|
||||
const env = createEnv({
|
||||
server: {
|
||||
POSTGRES_URL: z.string().url(),
|
||||
POSTGRES_USER: z.string(),
|
||||
POSTGRES_PASSWORD: z.string(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
POSTGRES_URL: process.env.POSTGRES_URL,
|
||||
POSTGRES_USER: process.env.POSTGRES_USER,
|
||||
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD,
|
||||
}
|
||||
})
|
||||
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 });
|
||||
}
|
||||
|
||||
export { env }
|
||||
|
||||
94
hooks/useAudio.tsx
Normal file
94
hooks/useAudio.tsx
Normal 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
17
mdx-components.tsx
Normal 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
0
mgmt/index.ts
Normal file
@@ -1,7 +1,9 @@
|
||||
import createMDX from '@next/mdx'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
pageExtensions: ['js', 'jsx', 'ts', 'tsx'],
|
||||
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'mdx'],
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig;
|
||||
export default createMDX()(nextConfig);
|
||||
|
||||
31
package.json
31
package.json
@@ -2,36 +2,49 @@
|
||||
"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"
|
||||
"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": {
|
||||
"@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",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-config-next": "^13.4.12",
|
||||
"next": "^13.4.12",
|
||||
"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",
|
||||
"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/node": "20.2.5",
|
||||
"@types/mdx": "^2.0.10",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/pg": "^8.10.3",
|
||||
"@types/react": "18.2.7",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"@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",
|
||||
"typescript": "5.0.4",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1
pkg/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
lib
|
||||
@@ -1,2 +0,0 @@
|
||||
src
|
||||
eslintrc.json
|
||||
@@ -1,2 +0,0 @@
|
||||
# Helpers for mikayla.dev
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export type Track = {
|
||||
name: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type AudioCollection = {
|
||||
name: string;
|
||||
date: Date;
|
||||
tracklist: Track[];
|
||||
directory: string;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type Post = {
|
||||
name: string;
|
||||
date: Date;
|
||||
author: string;
|
||||
description: string;
|
||||
body: string;
|
||||
tagIDs: string[];
|
||||
media?: string[]; // array of URLs
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
10
server/cache/createClient.ts
vendored
Normal 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);
|
||||
}
|
||||
80
server/controllers/base.controller.ts
Normal file
80
server/controllers/base.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
server/controllers/blogpost.controller.ts
Normal file
11
server/controllers/blogpost.controller.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
11
server/controllers/music.controller.ts
Normal file
11
server/controllers/music.controller.ts
Normal 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
14
server/db/createClient.ts
Normal 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
6
server/db/index.ts
Normal 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
40
server/db/schema.ts
Normal 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
85
server/db/seed.js
Normal 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();
|
||||
@@ -1,12 +0,0 @@
|
||||
export type Track = {
|
||||
name: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type AudioCollection = {
|
||||
name: string;
|
||||
date: Date;
|
||||
tracklist: Track[];
|
||||
directory: string;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type Post = {
|
||||
name: string;
|
||||
date: Date;
|
||||
author: string;
|
||||
description: string;
|
||||
body: string;
|
||||
tagIDs: string[];
|
||||
media?: string[]; // array of URLs
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default async function main() {
|
||||
return await Promise.resolve(12);
|
||||
}
|
||||
24
server/s3/createClient.ts
Normal file
24
server/s3/createClient.ts
Normal 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
2
server/s3/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as S3Service } from "./service";
|
||||
export { default as createS3Client } from "./createClient";
|
||||
105
server/s3/service.ts
Normal file
105
server/s3/service.ts
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
export default {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "@vercel/style-guide/typescript",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "ES6",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -25,5 +25,8 @@
|
||||
}
|
||||
},
|
||||
"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
14
util/helpers.ts
Normal 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
16
util/limiter.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user