fixes for some play states, mapping track metadata
This commit is contained in:
@@ -9,22 +9,21 @@ export default async function ListenByCollectionID({ params }: { params: { colle
|
|||||||
if (!id) return <NotFound />
|
if (!id) return <NotFound />
|
||||||
|
|
||||||
const controller = new MusicController();
|
const controller = new MusicController();
|
||||||
const result = await controller.getByID(id);
|
const collection = await controller.getByID(id);
|
||||||
if (!result) return <NotFound />
|
if (!collection) return <NotFound />
|
||||||
|
|
||||||
// const path = S3Service.asEndpoint(result.pathtoentry);
|
const trackList = await S3Service.prepareTrackList(collection.pathtoentry);
|
||||||
const entries = await S3Service.getURLs(result.pathtoentry);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<header>
|
<header>
|
||||||
<h1>{result.name}</h1>
|
<h1>{collection.name}</h1>
|
||||||
<p>{result.shortdescription}</p>
|
<p>{collection.shortdescription}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p>{result.longdescription}</p>
|
<p>{collection.longdescription}</p>
|
||||||
<Suspense fallback={<p>Loading...</p>}>
|
<Suspense fallback={<p>Loading...</p>}>
|
||||||
<AudioGallery urlList={entries} />
|
<AudioGallery trackList={trackList} collection={collection} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { MusicStreamingEntry } from "@/server/db/schema";
|
||||||
import { AudioTrack } from "./AudioTrack"
|
import { AudioTrack } from "./AudioTrack"
|
||||||
import { Maybe } from "@/util/helpers";
|
import { _Object } from "@aws-sdk/client-s3";
|
||||||
|
import { TrackWithURL } from "@/server/s3/service";
|
||||||
|
|
||||||
export function AudioGallery({ urlList }: { urlList: Maybe<string[]> }) {
|
export function AudioGallery({ trackList, collection }: { trackList: TrackWithURL[], collection: MusicStreamingEntry }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="p-8 m-8 rounded-lg bg-neutral-600">
|
||||||
{ urlList
|
{ trackList.length
|
||||||
? urlList.map((each, idx) => {
|
? trackList.map((each, idx) => {
|
||||||
return <AudioTrack src={each} key={idx} />
|
if (!(each.Key.includes(".wav") || each.Key.includes(".mp3"))) return <div key={idx} />
|
||||||
}) : <p>No audio results found for this work.</p>
|
return <AudioTrack metadata={each} collection={collection} key={idx} />
|
||||||
|
})
|
||||||
|
: <p>No audio results found for this work.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
conditionalExp: string;
|
|
||||||
onClick: (...args: []) => void;
|
onClick: (...args: []) => void;
|
||||||
|
extraClasses?: string;
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function _Button({ children, conditionalExp, onClick, disabled=false }: ButtonProps) {
|
export function _Button({ children, extraClasses="", onClick, disabled=false }: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button disabled={disabled} onClick={onClick} className={
|
<button disabled={disabled} onClick={onClick} className={
|
||||||
conditionalExp + " transition-all duration-700 rounded-full bg-stone-400 border-black h-16 w-16 flex flex-col items-center justify-center"
|
extraClasses + " transition-all duration-700 rounded-full bg-stone-400 border-black h-16 w-16 flex flex-col items-center justify-center"
|
||||||
}>
|
}>
|
||||||
{ children }
|
{ children }
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { FaArrowDown, FaArrowUp, FaPause, FaPlay } from "react-icons/fa";
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { _Button } from "./_Button";
|
import { _Button } from "./_Button";
|
||||||
import Link from "@/components/ui/Link";
|
import Link from "@/components/ui/Link";
|
||||||
|
import { prettyFileName } from "@/util/helpers";
|
||||||
|
|
||||||
export default function AudioPlayer() {
|
export default function AudioPlayer() {
|
||||||
const { isPlaying, isOpen, currentTrack, setIsPlaying, setIsOpen } = useAudio();
|
const { isPlaying, isOpen, currentTrack, currentCollection, setIsPlaying, setIsOpen } = useAudio();
|
||||||
|
|
||||||
const changePlayState = useCallback(() => {
|
const changePlayState = useCallback(() => {
|
||||||
if (!currentTrack) {
|
if (!currentTrack) {
|
||||||
@@ -19,13 +20,14 @@ export default function AudioPlayer() {
|
|||||||
}, [currentTrack, isPlaying, setIsPlaying])
|
}, [currentTrack, isPlaying, setIsPlaying])
|
||||||
|
|
||||||
return (
|
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 " : "bg-opacity-0 "} 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`}>
|
<>
|
||||||
|
<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 info, if it is set */}
|
{/* track info, if it is set */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{ !isOpen ? null : currentTrack ? (
|
{ !isOpen ? null : currentTrack ? (
|
||||||
<>
|
<>
|
||||||
<h3>{currentTrack.name}</h3>
|
<h3>{prettyFileName(currentTrack.Key)}</h3>
|
||||||
<p>{currentTrack.artist ?? "(no artist listed)"} - {currentTrack.year ?? "(no year listed)"}</p>
|
<p>{currentCollection?.artist ?? "(no artist listed)"} - {currentCollection?.year ?? "(no year listed)"}</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -35,23 +37,24 @@ export default function AudioPlayer() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="audio-panel-actions" className="flex items-center justify-end">
|
<div id="audio-panel-actions" className="flex items-center justify-end pr-16">
|
||||||
{/* conditionally-rendered button to close audio player once it's open */}
|
{/* conditionally-rendered button to close audio player once it's open */}
|
||||||
<_Button disabled={!currentTrack} conditionalExp={isOpen ? "opacity-100 translate-y-0 mr-4 " : "opacity-0 translate-y-32 mr-4 "} onClick={changePlayState}>
|
<_Button extraClasses={"fixed right-28 " + (isOpen ? "-bottom-30" : "bottom-10")} disabled={!currentTrack} onClick={changePlayState}>
|
||||||
{ isPlaying
|
{ isPlaying
|
||||||
? <FaPause className="h-8 w-8 text-black" />
|
? <FaPause className="h-8 w-8 text-black" />
|
||||||
: <FaPlay className="h-8 w-8 text-black" />
|
: <FaPlay className="h-8 w-8 text-black" />
|
||||||
}
|
}
|
||||||
</_Button>
|
</_Button>
|
||||||
|
|
||||||
<_Button onClick={() => setIsOpen(!isOpen)} conditionalExp={isOpen ? "right-36" : "right-12"}>
|
|
||||||
{ isOpen
|
|
||||||
? <FaArrowDown className="h-8 w-8 text-black" />
|
|
||||||
: <FaArrowUp className="h-8 w-8 text-black" />
|
|
||||||
}
|
|
||||||
</_Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,49 @@
|
|||||||
'use client';
|
'use client';
|
||||||
export function AudioTrack({ src }: { src: string }) {
|
import useAudio from "@/hooks/useAudio";
|
||||||
return <audio src={src} controls />
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { MusicStreamingEntry } from "@/server/db/schema";
|
import { MusicStreamingEntry } from "@/server/db/schema";
|
||||||
import { createContext, createRef, useCallback, useContext, useRef, useState } from "react";
|
import { TrackWithURL } from "@/server/s3/service";
|
||||||
|
import { createContext, createRef, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
type AudioContextType = {
|
type AudioContextType = {
|
||||||
// boolean properties
|
// boolean properties
|
||||||
@@ -10,16 +11,17 @@ type AudioContextType = {
|
|||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
|
||||||
// track properties
|
// track properties
|
||||||
/** @deprecated */
|
// duration: number;
|
||||||
duration: number;
|
// volume: number;
|
||||||
volume: number;
|
// setVolume: (volume: number) => void;
|
||||||
setVolume: (volume: number) => void;
|
// currentTime: number;
|
||||||
currentTime: number;
|
// setCurrentTime: (currentTime: number) => void;
|
||||||
setCurrentTime: (currentTime: number) => void;
|
handleTrackChange: (metadata: TrackWithURL, collection: MusicStreamingEntry) => void;
|
||||||
handleTrackChange: (audioSource: string, track: MusicStreamingEntry) => void;
|
|
||||||
|
|
||||||
// identifiers
|
// identifiers
|
||||||
currentTrack?: MusicStreamingEntry;
|
currentTrack?: TrackWithURL;
|
||||||
|
currentCollection?: MusicStreamingEntry;
|
||||||
|
setCurrentCollection: (collection: MusicStreamingEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialContext: AudioContextType = {
|
const initialContext: AudioContextType = {
|
||||||
@@ -27,38 +29,48 @@ const initialContext: AudioContextType = {
|
|||||||
setIsPlaying: () => {},
|
setIsPlaying: () => {},
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
setIsOpen: () => {},
|
setIsOpen: () => {},
|
||||||
volume: 0.5,
|
// volume: 0.5,
|
||||||
setVolume: () => {},
|
// setVolume: () => {},
|
||||||
duration: 0,
|
// duration: 0,
|
||||||
currentTime: 0,
|
// currentTime: 0,
|
||||||
setCurrentTime: () => {},
|
// setCurrentTime: () => {},
|
||||||
currentTrack: undefined,
|
|
||||||
handleTrackChange: () => {},
|
handleTrackChange: () => {},
|
||||||
|
currentTrack: undefined,
|
||||||
|
|
||||||
|
currentCollection: undefined,
|
||||||
|
setCurrentCollection: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
const AudioContext = createContext<AudioContextType>(initialContext);
|
const AudioContext = createContext<AudioContextType>(initialContext);
|
||||||
|
|
||||||
const AudioProvider = ({ children }: { children: React.ReactNode }) => {
|
const AudioProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const [currentTrack, setCurrentTrack] = useState<MusicStreamingEntry>();
|
const [currentCollection, setCurrentCollection] = useState<MusicStreamingEntry>();
|
||||||
|
const [currentTrack, setCurrentTrack] = useState<TrackWithURL>();
|
||||||
const [audioSource, setAudioSource] = useState<string>();
|
const [audioSource, setAudioSource] = useState<string>();
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [volume, setVolume] = useState(0.5);
|
// const [volume, setVolume] = useState(0.5);
|
||||||
const [duration, setDuration] = useState(0);
|
// const [duration, setDuration] = useState(0);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
// const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
const audioRef = createRef<HTMLAudioElement>();
|
const audioRef = createRef<HTMLAudioElement>();
|
||||||
|
|
||||||
const handleTrackChange = useCallback((audioSource: string, track: MusicStreamingEntry) => {
|
const handleTrackChange = useCallback((metadata: TrackWithURL, collection: MusicStreamingEntry) => {
|
||||||
setCurrentTrack(track);
|
setCurrentTrack(metadata);
|
||||||
setAudioSource(audioSource);
|
setAudioSource(metadata.url);
|
||||||
|
setCurrentCollection(collection);
|
||||||
|
setIsOpen(true);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isPlaying ? audioRef.current?.play() : audioRef.current?.pause();
|
||||||
|
}, [isPlaying, audioRef])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AudioContext.Provider value={{
|
<AudioContext.Provider value={{
|
||||||
currentTrack, isOpen, isPlaying, volume, duration, currentTime,
|
currentTrack, isOpen, isPlaying, currentCollection,
|
||||||
setCurrentTime, setIsOpen, handleTrackChange, setIsPlaying, setVolume
|
setIsOpen, handleTrackChange, setIsPlaying, setCurrentCollection,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
<audio className="hidden" style={{ display: "none" }} ref={audioRef} controls autoPlay={isPlaying} src={audioSource} />
|
<audio className="hidden" style={{ display: "none" }} ref={audioRef} controls autoPlay={isPlaying} src={audioSource} />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type ControllerOptions<T extends { [key: string]: any }> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default abstract class BaseController<T extends { [key: string]: any }> {
|
export default abstract class BaseController<T extends { [key: string]: any }> {
|
||||||
#db: pg.Client
|
protected db: pg.Client
|
||||||
// #bucket: S3Client
|
// #bucket: S3Client
|
||||||
// #cache: Redis
|
// #cache: Redis
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export default abstract class BaseController<T extends { [key: string]: any }> {
|
|||||||
parser?: FullParserType<T>
|
parser?: FullParserType<T>
|
||||||
|
|
||||||
constructor(options: ControllerOptions<T>) {
|
constructor(options: ControllerOptions<T>) {
|
||||||
this.#db = must(createDBClient);
|
this.db = must(createDBClient);
|
||||||
// this.#bucket = must(createS3Client);
|
// this.#bucket = must(createS3Client);
|
||||||
// this.#cache = must(createRedisClient);
|
// this.#cache = must(createRedisClient);
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ export default abstract class BaseController<T extends { [key: string]: any }> {
|
|||||||
'use server';
|
'use server';
|
||||||
try {
|
try {
|
||||||
// we'll enable cache here later
|
// we'll enable cache here later
|
||||||
await this.#db.connect();
|
await this.db.connect();
|
||||||
const result = await this.#db.query(`SELECT * FROM ${this.tableName}`);
|
const result = await this.db.query(`SELECT * FROM ${this.tableName}`);
|
||||||
|
|
||||||
if (this.parser) {
|
if (this.parser) {
|
||||||
result.rows.forEach((row, idx) => {
|
result.rows.forEach((row, idx) => {
|
||||||
@@ -53,16 +53,16 @@ export default abstract class BaseController<T extends { [key: string]: any }> {
|
|||||||
console.log({ error });
|
console.log({ error });
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
await this.#db.end();
|
await this.db.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByID(id: number, projection?: (keyof T)[]): Promise<Maybe<T>> {
|
async getByID(id: number, projection?: (keyof T)[]): Promise<Maybe<T>> {
|
||||||
try {
|
try {
|
||||||
await this.#db.connect();
|
await this.db.connect();
|
||||||
const finalProjection = projection?.join(", ") ?? "*";
|
const finalProjection = projection?.join(", ") ?? "*";
|
||||||
|
|
||||||
const result = await this.#db.query(`SELECT ${finalProjection} FROM ${this.tableName} WHERE id = ${id}`);
|
const result = await this.db.query(`SELECT ${finalProjection} FROM ${this.tableName} WHERE id = ${id}`);
|
||||||
|
|
||||||
if (this.parser) {
|
if (this.parser) {
|
||||||
const parsed = this.parser(result.rows[0]);
|
const parsed = this.parser(result.rows[0]);
|
||||||
@@ -74,7 +74,7 @@ export default abstract class BaseController<T extends { [key: string]: any }> {
|
|||||||
console.log({ error });
|
console.log({ error });
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
await this.#db.end();
|
await this.db.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import createS3Client from "./createClient";
|
|||||||
import { Maybe, must } from "@/util/helpers";
|
import { Maybe, must } from "@/util/helpers";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
|
||||||
|
export type TrackWithURL = (Required<Pick<_Object, "Key">> & _Object) & { url: string }
|
||||||
|
|
||||||
export default class S3Service {
|
export default class S3Service {
|
||||||
static asEndpoint(key: string): string {
|
static asEndpoint(key: string): string {
|
||||||
return `${env.S3_ENDPOINT}/${env.S3_BUCKET}/${key}`;
|
return `${env.S3_ENDPOINT}/${env.S3_BUCKET}/${key}`;
|
||||||
@@ -59,22 +61,45 @@ export default class S3Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getURLs(key: string): Promise<Maybe<string[]>> {
|
static async getURLs(key: string): Promise<{
|
||||||
|
files: Maybe<_Object[]>,
|
||||||
|
urlList: Maybe<string[]>
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const files = await this.listObjects(key);
|
const files = await this.listObjects(key);
|
||||||
const output = new Array<string>();
|
const urlList = new Array<string>();
|
||||||
|
|
||||||
files?.forEach(async(file) => {
|
files?.forEach(async(file) => {
|
||||||
if (file.Key) {
|
if (file.Key) {
|
||||||
const url = await this.getURL(file.Key);
|
const url = await this.getURL(file.Key);
|
||||||
if (url) output.push(url);
|
if (url) urlList.push(url);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return output;
|
return { files, urlList }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log({ error });
|
console.log({ error });
|
||||||
return null;
|
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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,8 @@ export function must<T = any>(func: CallableFunction): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prettyFileName(key: string): string {
|
||||||
|
return key.split('/').pop()?.split(".").shift() ?? "INVALID KEY";
|
||||||
|
}
|
||||||
|
|
||||||
export type Maybe<T> = T | null | undefined;
|
export type Maybe<T> = T | null | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user