Compare commits
17 Commits
master
...
music-stre
| 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:
|
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
9
.gitignore
vendored
@@ -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
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';
|
'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'm looking forward to hearing from you.</h1>
|
<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">
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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 { 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
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 { 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
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} */
|
/** @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);
|
||||||
|
|||||||
31
package.json
31
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} */
|
/** @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}',
|
||||||
|
|||||||
@@ -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
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