Basic listing pages added

This commit is contained in:
Fergal Moran
2023-07-04 19:50:52 +01:00
parent e4f40deab8
commit cfab005532
31 changed files with 11645 additions and 69 deletions

12
drizzle.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
dotenv?.config();
export default {
schema: "./src/schema.ts",
out: "./drizzle",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL as string,
},
} satisfies Config;

View File

@@ -0,0 +1,99 @@
DO $$ BEGIN
CREATE TYPE "LiveShowStatus" AS ENUM('SETUP', 'AWAITING', 'STREAMING', 'FINISHED');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "live_shows" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(50) NOT NULL,
"full_name" text,
"description" varchar(256),
"status" "LiveShowStatus",
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
"user_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "live_show_tags" (
"live_show_id" uuid NOT NULL,
"tag_id" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "live_show_tags" ADD CONSTRAINT "live_show_tags_live_show_id_tag_id" PRIMARY KEY("live_show_id","tag_id");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "mixes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(50) NOT NULL,
"full_name" text,
"description" varchar(2048),
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"user_id" uuid NOT NULL,
"is_processed" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "mix_tags" (
"mix_id" uuid NOT NULL,
"tag_id" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mix_tags" ADD CONSTRAINT "mix_tags_mix_id_tag_id" PRIMARY KEY("mix_id","tag_id");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"username" text NOT NULL,
"email" text NOT NULL,
"emailVerified" timestamp,
"name" varchar(2048),
"profileImage" text,
"headerImage" text,
"password" varchar(1024),
"stream_key" varchar(64),
"urls" text[]
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "title_idx" ON "tags" ("title");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "username_idx" ON "users" ("username");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "live_shows" ADD CONSTRAINT "live_shows_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "live_show_tags" ADD CONSTRAINT "live_show_tags_live_show_id_mixes_id_fk" FOREIGN KEY ("live_show_id") REFERENCES "mixes"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "live_show_tags" ADD CONSTRAINT "live_show_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "mixes" ADD CONSTRAINT "mixes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "mix_tags" ADD CONSTRAINT "mix_tags_mix_id_mixes_id_fk" FOREIGN KEY ("mix_id") REFERENCES "mixes"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "mix_tags" ADD CONSTRAINT "mix_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,408 @@
{
"version": "5",
"dialect": "pg",
"id": "3aa3a733-36d0-4095-a8a5-f3436102791a",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"live_shows": {
"name": "live_shows",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"full_name": {
"name": "full_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "LiveShowStatus",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"live_shows_user_id_users_id_fk": {
"name": "live_shows_user_id_users_id_fk",
"tableFrom": "live_shows",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {}
},
"live_show_tags": {
"name": "live_show_tags",
"schema": "",
"columns": {
"live_show_id": {
"name": "live_show_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"live_show_tags_live_show_id_mixes_id_fk": {
"name": "live_show_tags_live_show_id_mixes_id_fk",
"tableFrom": "live_show_tags",
"tableTo": "mixes",
"columnsFrom": [
"live_show_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"live_show_tags_tag_id_tags_id_fk": {
"name": "live_show_tags_tag_id_tags_id_fk",
"tableFrom": "live_show_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"live_show_tags_live_show_id_tag_id": {
"name": "live_show_tags_live_show_id_tag_id",
"columns": [
"live_show_id",
"tag_id"
]
}
}
},
"mixes": {
"name": "mixes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"full_name": {
"name": "full_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"is_processed": {
"name": "is_processed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"mixes_user_id_users_id_fk": {
"name": "mixes_user_id_users_id_fk",
"tableFrom": "mixes",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {}
},
"mix_tags": {
"name": "mix_tags",
"schema": "",
"columns": {
"mix_id": {
"name": "mix_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"mix_tags_mix_id_mixes_id_fk": {
"name": "mix_tags_mix_id_mixes_id_fk",
"tableFrom": "mix_tags",
"tableTo": "mixes",
"columnsFrom": [
"mix_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"mix_tags_tag_id_tags_id_fk": {
"name": "mix_tags_tag_id_tags_id_fk",
"tableFrom": "mix_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"mix_tags_mix_id_tag_id": {
"name": "mix_tags_mix_id_tag_id",
"columns": [
"mix_id",
"tag_id"
]
}
}
},
"tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"title_idx": {
"name": "title_idx",
"columns": [
"title"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": false
},
"profileImage": {
"name": "profileImage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"headerImage": {
"name": "headerImage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "varchar(1024)",
"primaryKey": false,
"notNull": false
},
"stream_key": {
"name": "stream_key",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"urls": {
"name": "urls",
"type": "text[]",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"username_idx": {
"name": "username_idx",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {}
}
},
"enums": {
"LiveShowStatus": {
"name": "LiveShowStatus",
"values": {
"SETUP": "SETUP",
"AWAITING": "AWAITING",
"STREAMING": "STREAMING",
"FINISHED": "FINISHED"
}
}
},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "5",
"dialect": "pg",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1688336894767,
"tag": "0000_mysterious_sentinels",
"breakpoints": true
}
]
}

53
jest.config.js Normal file
View File

@@ -0,0 +1,53 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.ts?$': 'ts-jest',
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^image![a-zA-Z0-9$_-]+$": "GlobalImageStub",
"^[./a-zA-Z0-9$_-]+\\.png$": "<rootDir>/RelativeImageStub.js",
"module_name_(.*)": "<rootDir>/substituted_module_$1.js",
"assets/(.*)": [
"<rootDir>/images/$1",
"<rootDir>/photos/$1",
"<rootDir>/recipes/$1",
],
},
transformIgnorePatterns: ['<rootDir>/node_modules/'],
};
// import type {Config} from "jest";
//
// const config: Config = {
// preset: "ts-jest",
// testEnvironment: "node",
// transform: {
// "^.+\\.ts?$": "ts-jest",
// },
// transformIgnorePatterns: ["<rootDir>/node_modules/"],
// moduleNameMapper: {
// "^@/(.*)$": "<rootDir>/src/$1",
// "^image![a-zA-Z0-9$_-]+$": "GlobalImageStub",
// "^[./a-zA-Z0-9$_-]+\\.png$": "<rootDir>/RelativeImageStub.js",
// "module_name_(.*)": "<rootDir>/substituted_module_$1.js",
// "assets/(.*)": [
// "<rootDir>/images/$1",
// "<rootDir>/photos/$1",
// "<rootDir>/recipes/$1",
// ],
// },
// setupFilesAfterEnv: ['./jest.setup.js']
//
// };
//
// export default config;
//
// // module.exports = {
// // preset: "ts-jest",
// // testEnvironment: "node",
// // transform: {
// // "^.+\\.ts?$": "ts-jest",
// // },
// // transformIgnorePatterns: ["<rootDir>/node_modules/"],
// // };

View File

@@ -21,19 +21,20 @@
"@sindresorhus/slugify": "^2.2.1",
"@t3-oss/env-nextjs": "^0.6.0",
"@tanstack/react-query": "^4.29.19",
"@trpc/client": "^10.32.0",
"@trpc/next": "^10.32.0",
"@trpc/react-query": "^10.32.0",
"@trpc/server": "^10.32.0",
"@trpc/client": "^10.33.0",
"@trpc/next": "^10.33.0",
"@trpc/react-query": "^10.33.0",
"@trpc/server": "^10.33.0",
"argon2": "^0.30.3",
"axios": "^1.4.0",
"classnames": "^2.3.2",
"commander": "^11.0.0",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.27.0",
"http-status-codes": "^2.2.0",
"import-local": "^3.1.0",
"install": "^0.13.0",
"next": "13.4.7",
"next": "13.4.8",
"next-auth": "^4.22.1",
"pg": "^8.11.1",
"postgres": "^3.3.5",
@@ -44,7 +45,8 @@
"react-icons": "^4.10.1",
"superjson": "1.12.4",
"yup": "^1.2.0",
"zod": "^3.21.4"
"zod": "^3.21.4",
"zustand": "^4.3.9"
},
"devDependencies": {
"@azure/identity": "^3.2.3",
@@ -52,7 +54,7 @@
"@faker-js/faker": "^8.0.2",
"@hookform/resolvers": "^3.1.1",
"@ianvs/prettier-plugin-sort-imports": "^4.0.2",
"@next/env": "^13.4.7",
"@next/env": "^13.4.8",
"@radix-ui/react-avatar": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-label": "^2.0.2",
@@ -62,26 +64,26 @@
"@types/eslint": "^8.40.2",
"@types/jest": "^29.5.2",
"@types/mime-types": "^2.1.1",
"@types/node": "^20.3.2",
"@types/node": "^20.3.3",
"@types/pg": "^8.10.2",
"@types/prettier": "^2.7.3",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@types/superagent": "^4.1.18",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^5.60.1",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"autoprefixer": "^10.4.14",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"concurrently": "^8.2.0",
"dayjs": "^1.11.8",
"dayjs": "^1.11.9",
"drizzle-kit": "^0.19.3",
"eslint": "^8.43.0",
"eslint-config-next": "^13.4.7",
"hls.js": "^1.4.6",
"jest": "^29.5.0",
"lucide-react": "^0.252.0",
"eslint": "^8.44.0",
"eslint-config-next": "^13.4.8",
"hls.js": "^1.4.8",
"jest": "^29.6.0",
"lucide-react": "^0.258.0",
"next-themes": "^0.2.1",
"node-mocks-http": "^1.12.2",
"postcss": "^8.4.24",
@@ -91,18 +93,18 @@
"pusher-js": "^8.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.45.0",
"react-hook-form": "^7.45.1",
"recharts": "^2.7.2",
"retry": "^0.13.1",
"superagent": "^8.0.9",
"tailwind-merge": "^1.13.2",
"tailwindcss": "^3.3.2",
"tailwindcss-animate": "^1.0.6",
"ts-jest": "^29.1.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.1.3",
"typescript": "^5.1.6",
"uuid": "^9.0.0",
"vitest": "^0.32.2"
"vitest": "^0.32.4"
},
"ct3aMetadata": {
"initVersion": "7.12.2"

9911
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

30
scripts/reset_db.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
PROJECT_ROOT=/srv/dev/mixyboos/mixyboos
export PGPASSWORD='hackme'
echo Dropping exiting db
dropdb -f --if-exists -h localhost -U postgres mixyboos
echo Creating new db
createdb -h localhost -U postgres mixyboos
#Don't remove migrations now that we have an actual live deployment
rm -rf $PROJECT_ROOT/drizzle/*
npx drizzle-kit generate:pg --schema=./src/db/schema.ts
curl --location 'https://mixyboos.dev.fergl.ie:3000/api/trpc/auth.signUp?batch=1' \
--header 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/114.0' \
--header 'Accept: */*' \
--header 'Accept-Language: en-GB,en;q=0.5' \
--header 'Accept-Encoding: gzip, deflate, br' \
--header 'Referer: https://mixyboos.dev.fergl.ie:3000/auth/register' \
--header 'content-type: application/json' \
--header 'Origin: https://mixyboos.dev.fergl.ie:3000' \
--header 'DNT: 1' \
--header 'Connection: keep-alive' \
--header 'Cookie: next-auth.csrf-token=b868ea55f205fbbfb1ff84e3fce9b46a62f5341f87e215b30745426a8240b7f2%7Cc7f23dceb444713353a7354b35377467ed0267c5fc7512b73784810d77b1ce91; next-auth.callback-url=http%3A%2F%2Flocalhost%3A3000%2Flive%2Fcreate' \
--header 'Sec-Fetch-Dest: empty' \
--header 'Sec-Fetch-Mode: cors' \
--header 'Sec-Fetch-Site: same-origin' \
--data-raw '{"0":{"json":{"email":"fergal.moran+mixyboos@gmail.com","username":"fergalmoran","password":"secret"}}}'

View File

@@ -0,0 +1,30 @@
import fs from "fs";
import os from "os";
import { uploadFolder } from "@/lib/services/azure/serverUploader";
import { getFilesizeInBytes } from "@/lib/utils/bufferUtils";
import { generateRandomBytes } from "@/lib/utils/crypt";
import { getFilename } from "@/lib/utils/fileUtils";
import { downloadFile } from "@/lib/utils/httpUtils";
import { v4 as uuid } from "uuid";
const TEST_FILE_SIZE = 15000000;
test("test upload blob", async () => {
//create temp directory
const dir = `${os.tmpdir()}/tests/${uuid()}`;
const file = `${dir}/${uuid()}.junk`;
const testFile = `${dir}/${uuid()}.junk`;
fs.mkdirSync(dir);
const data = await generateRandomBytes(TEST_FILE_SIZE);
fs.writeFileSync(file, data);
if (!(await uploadFolder(dir, "debug", "images"))) {
throw new Error("Failed to upload initial file");
}
const remoteFileUrl = `${
process.env.AZURE_ACCOUNT_URL as string
}/debug/images/${getFilename(file)}`;
const newFile = await downloadFile(remoteFileUrl, testFile);
expect(getFilesizeInBytes(newFile)).toEqual(getFilesizeInBytes(testFile));
});

View File

@@ -0,0 +1,21 @@
import { readFile } from "node:fs/promises";
import { getTestFixtureAudioBuffer } from "@/lib/utils/bufferUtils";
import { StatusCodes } from "http-status-codes";
import { v4 as uuid } from "uuid";
test("test file upload", async () => {
const form = new FormData();
const fixture = getTestFixtureAudioBuffer();
const mixId = uuid();
form.append("Content-Type", "application/octet-stream");
// form.append("file", new Blob([getTestFixtureAudioBuffer()]));
form.append("file", new Blob([await readFile(fixture)]));
form.append("mixId", mixId);
const result = await fetch(`${process.env.BASE_URL as string}/api/upload`, {
method: "POST",
body: form,
});
expect(result.status).toBe(StatusCodes.OK);
}, 50000);

View File

@@ -0,0 +1,25 @@
"use client";
import LargeAudioPlayer from "@/components/widgets/audio/large-audio-player";
import React from "react";
import { api } from "@/lib/utils/api";
export default function Page({
params,
}: {
params: { userName: string; mixSlug: string };
}) {
const mixQuery = api.mix.getByUserAndSlug.useQuery({
userName: params.userName,
mixSlug: params.mixSlug,
});
const mix = mixQuery?.data;
return (
<div>
{mix ? (
<LargeAudioPlayer mix={mix} />
) : (
<div>Unable to find this mix</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import {db} from "@/server/db";
import raise from "@/lib/utils/errors";
import {mapMixToMixModel} from "@/lib/utils/mappers/mixMapper";
async function getData(userName: string) {
const user = await db.query.users.findFirst({
where: (users, {eq}) => eq(users.username, userName)
})
if (!user) {
return raise("User not found")
}
const data = await db.query.mixes.findMany({
where: (mixes, {eq}) => eq(mixes.userId, user.id),
with: {
user: {}
}
})
return {user, mixes: data.map(m => mapMixToMixModel(m))};
}
export default getData;

View File

@@ -0,0 +1,49 @@
import React from "react";
import getData from './data'
import MainPlayer from "@/components/widgets/audio/large-audio-player";
import {getSession} from "next-auth/react";
interface MixesPageProps {
params: {
userName: string;
};
}
const MixesPage: React.FC<MixesPageProps> = async ({
params,
}: MixesPageProps) => {
const {user, mixes} = await getData(params.userName)
return (<div>
<div className="flex items-center justify-between space-y-2"><h2
className="text-3xl font-bold tracking-tight">Mixes for {user.name || user.username}</h2>
<div className="flex items-center space-x-2">
<div className="grid gap-2">
<button
className="inline-flex items-center rounded-md text-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2 w-[260px] justify-start text-left font-normal"
id="date" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="radix-:r7:"
data-state="closed">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"
className="mr-2 h-4 w-4">
<path
d="M4.5 1C4.77614 1 5 1.22386 5 1.5V2H10V1.5C10 1.22386 10.2239 1 10.5 1C10.7761 1 11 1.22386 11 1.5V2H12.5C13.3284 2 14 2.67157 14 3.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V3.5C1 2.67157 1.67157 2 2.5 2H4V1.5C4 1.22386 4.22386 1 4.5 1ZM10 3V3.5C10 3.77614 10.2239 4 10.5 4C10.7761 4 11 3.77614 11 3.5V3H12.5C12.7761 3 13 3.22386 13 3.5V5H2V3.5C2 3.22386 2.22386 3 2.5 3H4V3.5C4 3.77614 4.22386 4 4.5 4C4.77614 4 5 3.77614 5 3.5V3H10ZM2 6V12.5C2 12.7761 2.22386 13 2.5 13H12.5C12.7761 13 13 12.7761 13 12.5V6H2ZM7 7.5C7 7.22386 7.22386 7 7.5 7C7.77614 7 8 7.22386 8 7.5C8 7.77614 7.77614 8 7.5 8C7.22386 8 7 7.77614 7 7.5ZM9.5 7C9.22386 7 9 7.22386 9 7.5C9 7.77614 9.22386 8 9.5 8C9.77614 8 10 7.77614 10 7.5C10 7.22386 9.77614 7 9.5 7ZM11 7.5C11 7.22386 11.2239 7 11.5 7C11.7761 7 12 7.22386 12 7.5C12 7.77614 11.7761 8 11.5 8C11.2239 8 11 7.77614 11 7.5ZM11.5 9C11.2239 9 11 9.22386 11 9.5C11 9.77614 11.2239 10 11.5 10C11.7761 10 12 9.77614 12 9.5C12 9.22386 11.7761 9 11.5 9ZM9 9.5C9 9.22386 9.22386 9 9.5 9C9.77614 9 10 9.22386 10 9.5C10 9.77614 9.77614 10 9.5 10C9.22386 10 9 9.77614 9 9.5ZM7.5 9C7.22386 9 7 9.22386 7 9.5C7 9.77614 7.22386 10 7.5 10C7.77614 10 8 9.77614 8 9.5C8 9.22386 7.77614 9 7.5 9ZM5 9.5C5 9.22386 5.22386 9 5.5 9C5.77614 9 6 9.22386 6 9.5C6 9.77614 5.77614 10 5.5 10C5.22386 10 5 9.77614 5 9.5ZM3.5 9C3.22386 9 3 9.22386 3 9.5C3 9.77614 3.22386 10 3.5 10C3.77614 10 4 9.77614 4 9.5C4 9.22386 3.77614 9 3.5 9ZM3 11.5C3 11.2239 3.22386 11 3.5 11C3.77614 11 4 11.2239 4 11.5C4 11.7761 3.77614 12 3.5 12C3.22386 12 3 11.7761 3 11.5ZM5.5 11C5.22386 11 5 11.2239 5 11.5C5 11.7761 5.22386 12 5.5 12C5.77614 12 6 11.7761 6 11.5C6 11.2239 5.77614 11 5.5 11ZM7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5ZM9.5 11C9.22386 11 9 11.2239 9 11.5C9 11.7761 9.22386 12 9.5 12C9.77614 12 10 11.7761 10 11.5C10 11.2239 9.77614 11 9.5 11Z"
fill="currentColor" fill-rule="evenodd" clipRule="evenodd"></path>
</svg>
Jan 20, 2023 - Feb 09, 2023
</button>
</div>
<button
className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">Download
</button>
</div>
</div>
<div className="space-y-4">
{mixes.map(mix => (<div key={mix.id}>
<div className="border-b-2">
<MainPlayer mix={mix}/>
</div>
</div>))}
</div>
</div>);
};
export default MixesPage;

View File

@@ -1,46 +1,46 @@
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import {fontSans} from "@/config/fonts";
import {siteConfig} from "@/config/site";
import Navbar from "@/lib/components/layout/Navbar";
import { cn } from "@/lib/utils";
import { type Metadata } from "next";
import {cn} from "@/lib/utils";
import {type Metadata} from "next";
import Image from "next/image";
import Providers from "./providers";
import "@/app/globals.css";
const RootLayout = ({ children }: { children: React.ReactNode }) => {
const RootLayout = ({children}: { children: React.ReactNode }) => {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-screen overflow-y-hidden bg-background font-sans antialiased",
fontSans.variable
)}
>
<Providers>
<div className="md:hidden">
<Image
src="/img/dashboard-light.png"
width={1280}
height={866}
alt="Dashboard"
className="block dark:hidden"
/>
<Image
src="/img/dashboard-dark.png"
width={1280}
height={866}
alt="Dashboard"
className="hidden dark:block"
/>
</div>
<div className="hidden flex-col md:flex">
<div className="border-b bg-background/95 backdrop-blur">
<Navbar className="mx-6" />
</div>
<div className="flex-1 mt-12 mx-8">{children}</div>
</div>
</Providers>
</body>
<body
className={cn(
"min-h-screen overflow-y-hidden bg-background font-sans antialiased",
fontSans.variable
)}
>
<Providers>
<div className="md:hidden">
<Image
src="/img/dashboard-light.png"
width={1280}
height={866}
alt="Dashboard"
className="block dark:hidden"
/>
<Image
src="/img/dashboard-dark.png"
width={1280}
height={866}
alt="Dashboard"
className="hidden dark:block"
/>
</div>
<div className="hidden flex-col md:flex">
<div className="border-b bg-background/95 backdrop-blur">
<Navbar className="mx-6"/>
</div>
<div className="flex-1 space-y-4 p-8 pt-6">{children}</div>
</div>
</Providers>
</body>
</html>
);
};
@@ -52,8 +52,8 @@ export const metadata: Metadata = {
},
description: siteConfig.description,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
{media: "(prefers-color-scheme: light)", color: "white"},
{media: "(prefers-color-scheme: dark)", color: "black"},
],
icons: {
icon: "/favicon.ico",

View File

@@ -1,3 +1,4 @@
import { PauseCircleIcon, PlayCircleIcon } from "lucide-react";
import {
Activity,
AlertCircle,
@@ -18,6 +19,7 @@ import {
FileText,
HelpCircle,
Image,
Heart,
Laptop,
Loader2,
LocateFixed,
@@ -42,6 +44,9 @@ import {
X,
type Icon as LucideIcon,
type LucideProps,
Repeat2,
Play,
PlayCircle,
} from "lucide-react";
export type Icon = LucideIcon;
@@ -62,6 +67,8 @@ export const Icons = {
),
activity: Activity,
live: RadioTower,
heart: Heart,
retweet: Repeat2,
mix: Disc2,
schedule: CalendarHeart,
close: X,
@@ -83,6 +90,8 @@ export const Icons = {
billing: CreditCard,
ellipsis: MoreVertical,
add: Plus,
play: PlayCircleIcon,
pause: PauseCircleIcon,
warning: AlertTriangle,
error: AlertCircle,
user: User,

View File

@@ -0,0 +1,156 @@
"use client";
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { type MixModel } from "@/lib/models";
import useAudioStore from "@/lib/services/stores/audio/audioStore";
import ActionButton from "../buttons/action-button";
import Loading from "@/lib/components/widgets/Loading";
import { Icons } from "@/components/icons";
import MixProcessingStatus from "./mix-processing-status";
import PlayPauseButton from "../buttons/play-pause-button";
interface IMainPlayerProps {
mix: MixModel;
}
const MainPlayer = ({ mix }: IMainPlayerProps) => {
const [likeCount, setLikeCount] = React.useState(mix.likeCount);
const [playCount, setPlayCount] = React.useState(mix.playCount);
const [shareCount, setShareCount] = React.useState(mix.shareCount);
const [downloadCount, setDownloadCount] = React.useState(mix.downloadCount);
const setNowPlaying = useAudioStore((state) => state.setNowPlaying);
const nowPlaying = useAudioStore((state) => state.nowPlaying);
const togglePlayState = useAudioStore((state) => state.togglePlayState);
return mix ? (
<div id="player-body" className="mx-auto mb-3 overflow-hidden p-1 ">
<div className="md:flex">
<div className="p-1 md:flex-shrink-0">
<Image
className="h-36 w-full rounded-md object-cover md:w-48"
src={`${mix.image ?? ""}`}
alt={`image for ${mix.title}`}
width={192}
height={144}
/>
</div>
<div className="flex w-full flex-col justify-between p-4">
<div>
{false && (
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-500">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div>
{mix.user?.profileImage && (
<Image
className="border-g h-4 w-4 rounded-full"
src={
mix.user?.profileImage ||
"/img/streaming-placeholder.jpg"
}
alt="Mix"
/>
)}
</div>
<div className="px-2 text-gray-600">
<span className="font-bold">
{mix.user.name || mix.user.username}
</span>
<span className="text-gray-400"> listened</span>
</div>
</div>
<div className="text-gray-400">1 hour ago</div>
</div>
</div>
)}
<div className="mt-0">
<div className="flex">
{mix.isProcessed && (
<PlayPauseButton
mix={mix}
onPlayStart={() =>
setPlayCount(playCount ? playCount + 1 : 1)
}
classes="w-16 h-16"
/>
)}
<div className="mt-2">
<Link
href={`/${mix.user.username}/${mix.slug}`}
className="block text-lg font-medium leading-tight text-gray-900 hover:underline"
>
<div className="flex flex-row space-x-1">
<div className="line-clamp-1 text-gray-500 dark:text-white">
{mix.title}
</div>
<div>
{!mix.isProcessed && (
<MixProcessingStatus
mix={mix}
title="Processing mix"
onProcessingFinished={() => {
console.log(
"large-audio-player",
"onProcessingFinished"
);
}}
/>
)}
</div>
</div>
</Link>
<p className="leading-2 mx-1 line-clamp-1 text-sm text-gray-500 dark:text-gray-100">
by {mix.user?.name}
</p>
</div>
</div>
<p className="ml-1 mt-2 line-clamp-2 text-sm text-gray-500 dark:text-gray-100">
{mix.description}
</p>
</div>
</div>
<div className="mt-2">
<div className="flex items-center justify-between">
<div className="flex space-x-3">
<ActionButton
count={likeCount}
size="lg"
onClick={() => {
// const mixService = new MixService();
// mixService
// .addLike(mix)
// .then((r) => r && setLikeCount(likeCount ?? 0 + 1));
}}
>
<Icons.heart />
</ActionButton>
<ActionButton count={shareCount}>
<Icons.retweet />
</ActionButton>
<ActionButton count={downloadCount}>
<Icons.download />
</ActionButton>
</div>
<div className="flex items-center space-x-3">
<div className="flex space-x-0">
<Icons.play />
<div className="text-xs">{playCount}</div>
</div>
<div className="mr-2 space-x-1 text-gray-400">
<a href="/">#house</a>
<a href="/">#deephouse</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<Loading message="Loading mix" />
);
};
export default MainPlayer;

View File

@@ -0,0 +1,26 @@
import React from "react";
import { useSession } from "next-auth/react";
import { type MixModel } from "@/lib/models";
import Loading from "@/lib/components/widgets/Loading";
interface IMixProcessingStatusProps {
mix: MixModel;
title?: string;
onProcessingFinished: () => void;
}
const MixProcessingStatus = ({
mix,
title = "",
onProcessingFinished,
}: IMixProcessingStatusProps) => {
const { data: session, status } = useSession();
const [message, setMessage] = React.useState<string>(title);
return (
<div className="p-1">
<Loading message={message} />
</div>
);
};
export default MixProcessingStatus;

View File

@@ -0,0 +1,42 @@
"use client";
import React, { PropsWithChildren } from "react";
import clsx from "clsx";
const sizes = {
xl: "w-12",
lg: "w-10",
md: "w-8",
sm: "w-6",
};
const colors = {
default: "text-gray-600 hover:text-gray-900",
danger: "text-red-600 hover:text-red-900",
};
interface IActionButtonProps extends PropsWithChildren {
count?: number;
onClick?: () => void;
size?: "sm" | "md" | "lg" | "xl";
color?: "default" | "danger";
}
const ActionButton: React.FC<IActionButtonProps> = ({
children,
count,
size = "sm",
color = "default",
onClick,
}) => {
return (
<button
className={clsx(
"py-0.2 inline-flex items-center rounded-md border border-gray-500 px-2 text-center text-sm font-medium text-gray-900 hover:bg-gray-900 hover:text-white focus:outline-none focus:ring-4 focus:ring-gray-300 dark:border-gray-200 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-white dark:focus:ring-gray-800",
colors[color]
)}
onClick={onClick}
>
{children}
<div className="mx-1 text-sm">{count}</div>
</button>
);
};
export default ActionButton;

View File

@@ -0,0 +1,63 @@
"use client";
import React from "react";
import classnames from "classnames";
import { type MixModel } from "@/lib/models";
import useAudioStore, {
PlayState,
} from "@/lib/services/stores/audio/audioStore";
import { Icons } from "@/components/icons";
interface IPlayPauseButtonProps {
mix: MixModel;
onPlayStart: () => void;
classes?: string;
}
const PlayPauseButton = ({
mix,
onPlayStart,
classes = "w-6",
}: IPlayPauseButtonProps) => {
const {
playState,
togglePlayState,
nowPlaying,
setNowPlaying,
setNowPlayingUrl,
nowPlayingUrl,
} = useAudioStore();
return (
<div
className={classnames(classes)}
onClick={() => {
if (
playState === PlayState.stopped ||
(mix.id !== nowPlaying?.id && !nowPlayingUrl)
) {
// mixService.getMixAudioUrl(mix).then((url) => {
// setNowPlaying(mix);
// setNowPlayingUrl(url);
// onPlayStart();
// });
} else {
togglePlayState();
}
}}
>
<div
className={
"cursor-pointer text-gray-500 transition duration-200 hover:text-gray-800 dark:text-gray-200 dark:hover:text-gray-700"
}
>
{nowPlaying?.id === mix.id && playState === PlayState.playing ? (
<Icons.pause className="h-full w-full" />
) : (
<Icons.play className="h-full w-full" />
)}
</div>
</div>
);
};
export default PlayPauseButton;

59
src/db/auth.ts Normal file
View File

@@ -0,0 +1,59 @@
import { users } from "@/db/schema";
import {
index,
integer,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
export const accounts = pgTable(
"accounts",
{
id: uuid("id").notNull().primaryKey().defaultRandom(),
userId: uuid("userId").notNull(),
type: varchar("type", { length: 191 }).notNull(),
provider: varchar("provider", { length: 191 }).notNull(),
providerAccountId: varchar("providerAccountId", { length: 191 }).notNull(),
access_token: text("access_token"),
expires_in: integer("expires_in"),
id_token: text("id_token"),
refresh_token: text("refresh_token"),
refresh_token_expires_in: integer("refresh_token_expires_in"),
scope: varchar("scope", { length: 191 }),
token_type: varchar("token_type", { length: 191 }),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
},
(account) => ({
providerProviderAccountIdIndex: uniqueIndex(
"accounts__provider__providerAccountId__idx"
).on(account.provider, account.providerAccountId),
userIdIndex: index("accounts__userId__idx").on(account.userId),
})
);
export const sessions = pgTable("sessions", {
id: uuid("id").notNull().primaryKey().defaultRandom(),
sessionToken: text("sessionToken").notNull().primaryKey(),
userId: uuid("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires"),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires"),
},
(vt) => ({
id: primaryKey(vt.identifier, vt.token),
})
);

134
src/db/schema.ts Normal file
View File

@@ -0,0 +1,134 @@
import { type InferModel, relations } from "drizzle-orm";
import {
boolean,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
export const LiveShowStatus = pgEnum("LiveShowStatus", [
"SETUP",
"AWAITING",
"STREAMING",
"FINISHED",
]);
export const tags = pgTable(
"tags",
{
id: uuid("id").primaryKey().defaultRandom(),
title: text("title"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
},
(table) => {
return {
titleIdx: uniqueIndex("title_idx").on(table.title),
};
}
);
export const mixes = pgTable("mixes", {
id: uuid("id").primaryKey().defaultRandom(),
slug: varchar("slug", { length: 50 }).notNull(),
title: varchar("full_name", { length: 50 }).notNull(),
description: varchar("description", { length: 2048 }),
audioUrl: text("full_name"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
userId: uuid("user_id")
.notNull()
.references(() => users.id),
isProcessed: boolean("is_processed").notNull().default(false),
});
export const liveShows = pgTable("live_shows", {
id: uuid("id").primaryKey().defaultRandom(),
slug: varchar("slug", { length: 50 }).notNull(),
title: text("full_name"),
description: varchar("description", { length: 256 }),
status: LiveShowStatus("status"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
userId: uuid("user_id")
.notNull()
.references(() => users.id),
});
export const mixesToTags = pgTable(
"mix_tags",
{
mixId: uuid("mix_id")
.notNull()
.references(() => mixes.id),
tagId: uuid("tag_id")
.notNull()
.references(() => tags.id),
},
(t) => ({
pk: primaryKey(t.mixId, t.tagId),
})
);
export const liveShowsToTags = pgTable(
"live_show_tags",
{
liveShowId: uuid("live_show_id")
.notNull()
.references(() => mixes.id),
tagId: uuid("tag_id")
.notNull()
.references(() => tags.id),
},
(t) => ({
pk: primaryKey(t.liveShowId, t.tagId),
})
);
export const users = pgTable(
"users",
{
id: uuid("id").notNull().primaryKey().defaultRandom(),
username: text("username").notNull(),
email: text("email").notNull(),
emailVerified: timestamp("emailVerified"),
name: text("name"),
bio: varchar("name", { length: 2048 }),
profileImage: text("profileImage"),
headerImage: text("headerImage"),
password: varchar("password", { length: 1024 }),
streamKey: varchar("stream_key", { length: 64 }),
urls: text("urls").array(),
},
(table) => {
return {
titleIdx: uniqueIndex("username_idx").on(table.username),
};
}
);
export const mixRelations = relations(mixes, ({ one, many }) => ({
user: one(users, {
fields: [mixes.userId],
references: [users.id],
}),
mixesToTags: many(mixesToTags),
}));
export const userRelations = relations(users, ({ many }) => ({
mixes: many(mixes),
}));
export const liveShowRelations = relations(liveShows, ({ many }) => ({
liveShowsToTags: many(liveShowsToTags),
}));
export type User = InferModel<typeof users, "select">;
export type Mix = InferModel<typeof mixes, "select">;
export type LiveShow = InferModel<typeof liveShows, "select">;

View File

@@ -0,0 +1,69 @@
import { type MixModel } from "@/lib/models";
import { create } from "zustand";
enum PlayState {
stopped = 1,
playing = 2,
paused = 3,
}
interface IAudioState {
nowPlaying?: MixModel;
nowPlayingUrl?: string;
position: number;
seekPosition: number;
duration: number;
playState: PlayState;
currentVolume: number;
muted: boolean;
setNowPlaying: (mix: MixModel) => void;
setNowPlayingUrl: (url: string) => void;
setPosition: (position: number) => void;
setDuration: (duration: number) => void;
setSeekPosition: (duration: number) => void;
setPlayState: (playState: PlayState) => void;
togglePlayState: () => void;
setVolume: (volume: number) => void;
setMuted: (muted: boolean) => void;
toggleMuted: () => void;
}
const useAudioStore = create<IAudioState>()((set, get) => ({
id: "",
url: "",
nowPlaying: undefined,
nowPlayingUrl: "",
position: -1,
seekPosition: -1,
duration: 0,
playState: PlayState.stopped,
currentVolume: 50,
muted: false,
setPosition: (position: number) => set((state) => ({ position })),
setSeekPosition: (seekPosition: number) => set((state) => ({ seekPosition })),
setDuration: (duration: number) => set((state) => ({ duration })),
setNowPlaying: (mix: MixModel) => set((state) => ({ nowPlaying: mix })),
setNowPlayingUrl: (url: string) => set((state) => ({ nowPlayingUrl: url })),
setPlayState: (playState: PlayState) => {
if (get().playState !== playState) {
set({ playState });
}
},
togglePlayState: () =>
set((state) => {
return {
playState:
state.playState === PlayState.playing
? PlayState.paused
: PlayState.playing,
};
}),
setVolume: (volume: number) => set({ currentVolume: volume }),
setMuted: (muted: boolean) => set({ muted }),
toggleMuted: () => set((state) => ({ muted: !state.muted })),
}));
export type { IAudioState };
export { PlayState };
export default useAudioStore;

View File

@@ -0,0 +1,47 @@
import crypto from "crypto";
import fs from "fs";
export const createTempFile = (file: string, size: number) => {
console.log(`Writing ${size} bytes`);
const writer = fs.createWriteStream(file);
writetoStream(size, () => console.log(`File created: ${file}`));
function writetoStream(bytesToWrite: number, callback: () => void) {
const step = 1000;
let i = bytesToWrite;
write();
function write() {
let ok = true;
do {
const chunkSize = i > step ? step : i;
const buffer = crypto.randomBytes(chunkSize);
i -= chunkSize;
if (i === 0) {
// Last time!
writer.write(buffer, callback);
} else {
// See if we should continue, or wait.
// Don't pass the callback, because we're not done yet.
ok = writer.write(buffer);
}
} while (i > 0 && ok);
if (i > 0) {
// Had to stop early!
// Write some more once it drains.
writer.once("drain", write);
}
}
}
};
export const getFilesizeInBytes = (filename: string) => {
const stats = fs.statSync(filename);
const fileSizeInBytes = stats.size;
return fileSizeInBytes;
};
export const getTestFixtureAudioBuffer = () =>
"/srv/dev/mixyboos/working/media/15 minute sine.mp3";

4
src/lib/utils/errors.ts Normal file
View File

@@ -0,0 +1,4 @@
const raise = (err: string): never => {
throw new Error(err);
};
export default raise;

View File

@@ -0,0 +1,8 @@
import path from "path";
const getFileExtension = (fileName: string): string =>
fileName.split(".").pop() as string;
const getFilename = (fullPath: string): string => path.basename(fullPath);
export { getFileExtension, getFilename };

View File

@@ -0,0 +1,15 @@
///
// synchronous file downloader for use in tests
///
import fs from "fs";
import fetch from "node-fetch";
const downloadFile = async (url: string, path: string) => {
const response = await fetch(url);
const bytes = await response.arrayBuffer();
fs.writeFileSync(path, Buffer.from(bytes));
return path;
};
export { downloadFile };

View File

@@ -0,0 +1,20 @@
import {type Mix, type User} from "@/db/schema";
import {type MixModel} from "@/lib/models";
import {mapDbAuthUserToUserModel} from "@/lib/utils/mappers/userMapper";
const mapMixToMixModel = (mix: Mix & { user: User }): MixModel => ({
id: mix.id,
slug: mix.slug,
title: mix.title,
description: mix.description,
dateUploaded: mix.createdAt.toISOString(),
image: '/img/streaming-placeholder.jpg', //TODO: "need to do this",
likeCount: 15,
playCount: 10,
shareCount: 6,
downloadCount: 2,
user: mapDbAuthUserToUserModel(mix.user),
isProcessed: mix.isProcessed,
});
export {mapMixToMixModel};

View File

@@ -0,0 +1,203 @@
import { accounts, sessions, verificationTokens } from "@/db/auth";
import { users } from "@/db/schema";
import { and, eq } from "drizzle-orm";
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import type { Adapter } from "next-auth/adapters";
import { v4 as uuidv4 } from "uuid";
export function DrizzleAdapter(db: PostgresJsDatabase): Adapter {
return {
async createUser(userData) {
await db.insert(users).values({
id: uuidv4(),
username: userData.username,
email: userData.email,
emailVerified: userData.emailVerified,
name: userData.name,
profileImage: userData.profileImage,
headerImage: userData.headerImage,
});
const rows = await db
.select()
.from(users)
.where(eq(users.email, userData.email))
.limit(1);
const row = rows[0];
if (!row) throw new Error("User not found");
return row;
},
async getUser(id) {
const rows = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
const row = rows[0];
return row ?? null;
},
async getUserByEmail(email) {
const rows = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
const row = rows[0];
return row ?? null;
},
async getUserByAccount({ providerAccountId, provider }) {
const rows = await db
.select()
.from(users)
.innerJoin(accounts, eq(users.id, accounts.userId))
.where(
and(
eq(accounts.providerAccountId, providerAccountId),
eq(accounts.provider, provider)
)
)
.limit(1);
const row = rows[0];
return row?.users ?? null;
},
async updateUser({ id, ...userData }) {
if (!id) throw new Error("User not found");
await db.update(users).set(userData).where(eq(users.id, id));
const rows = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
const row = rows[0];
if (!row) throw new Error("User not found");
return row;
},
async deleteUser(userId) {
await db.delete(users).where(eq(users.id, userId));
},
async linkAccount(account) {
await db.insert(accounts).values({
id: uuidv4(),
provider: account.provider,
providerAccountId: account.providerAccountId,
type: account.type,
userId: account.userId,
// OpenIDTokenEndpointResponse properties
access_token: account.access_token,
expires_in: account.expires_in as number,
id_token: account.id_token,
refresh_token: account.refresh_token,
refresh_token_expires_in: account.refresh_token_expires_in as number, // TODO: why doesn't the account type have this property?
scope: account.scope,
token_type: account.token_type,
});
},
async unlinkAccount({ providerAccountId, provider }) {
await db
.delete(accounts)
.where(
and(
eq(accounts.providerAccountId, providerAccountId),
eq(accounts.provider, provider)
)
);
},
async createSession(data) {
await db.insert(sessions).values({
id: uuidv4(),
expires: data.expires,
sessionToken: data.sessionToken,
userId: data.userId,
});
const rows = await db
.select()
.from(sessions)
.where(eq(sessions.sessionToken, data.sessionToken))
.limit(1);
const row = rows[0];
if (!row) throw new Error("User not found");
return row;
},
async getSessionAndUser(sessionToken) {
const rows = await db
.select({
user: users,
session: {
id: sessions.id,
userId: sessions.userId,
sessionToken: sessions.sessionToken,
expires: sessions.expires,
},
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(eq(sessions.sessionToken, sessionToken))
.limit(1);
const row = rows[0];
if (!row) return null;
const { user, session } = row;
return {
user,
session: {
id: session.id,
userId: session.userId,
sessionToken: session.sessionToken,
expires: session.expires,
},
};
},
async updateSession(session) {
await db
.update(sessions)
.set(session)
.where(eq(sessions.sessionToken, session.sessionToken));
const rows = await db
.select()
.from(sessions)
.where(eq(sessions.sessionToken, session.sessionToken))
.limit(1);
const row = rows[0];
if (!row) throw new Error("Coding bug: updated session not found");
return row;
},
async deleteSession(sessionToken) {
await db.delete(sessions).where(eq(sessions.sessionToken, sessionToken));
},
async createVerificationToken(verificationToken) {
await db.insert(verificationTokens).values({
expires: verificationToken.expires,
identifier: verificationToken.identifier,
token: verificationToken.token,
});
const rows = await db
.select()
.from(verificationTokens)
.where(eq(verificationTokens.token, verificationToken.token))
.limit(1);
const row = rows[0];
if (!row)
throw new Error("Coding bug: inserted verification token not found");
return row;
},
async useVerificationToken({ identifier, token }) {
// First get the token while it still exists. TODO: need to add identifier to where clause?
const rows = await db
.select()
.from(verificationTokens)
.where(eq(verificationTokens.token, token))
.limit(1);
const row = rows[0];
if (!row) return null;
// Then delete it.
await db
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.token, token),
eq(verificationTokens.identifier, identifier)
)
);
// Then return it.
return row;
},
};
}

View File

@@ -8,7 +8,7 @@ import {
import { slugifyWithCounter } from "@sindresorhus/slugify";
const slugify = slugifyWithCounter();
import { eq, sql } from "drizzle-orm";
import { eq, sql, and } from "drizzle-orm";
import { db } from "@/server/db";
import { desc } from "drizzle-orm";
import * as z from "zod";
@@ -24,6 +24,41 @@ export const mixRouter = createTRPCRouter({
return results;
}),
getByUserAndSlug: publicProcedure
.input(
z.object({
userName: z.string(),
mixSlug: z.string(),
})
)
.query(async ({ input: { userName, mixSlug }, ctx }) => {
//don't like this but it appears we can't query on foreign key columns
//in drizzle
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.username, userName),
});
if (!user) {
throw new trpc.TRPCError({
code: "NOT_FOUND",
message: "Invalid user.",
});
}
const mix = await db.query.mixes.findFirst({
where: (mixes, { eq }) =>
and(eq(mixes.slug, mixSlug), eq(mixes.userId, user.id)),
with: {
user: {},
},
});
if (!mix) {
throw new trpc.TRPCError({
code: "NOT_FOUND",
message: "Invalid mix.",
});
}
return mapMixToMixModel(mix);
}),
createMix: protectedProcedure
.input(
z.object({
@@ -54,11 +89,18 @@ export const mixRouter = createTRPCRouter({
.where(eq(mixes.slug, slug));
} while (checkSlugResult[0]?.count !== 0);
const result = await db
const newMixQuery = await db
.insert(mixes)
.values({ title, slug, description, userId: ctx.session.id })
.returning();
return mapMixToMixModel(result[0], user);
const newMix = await db.query.mixes.findFirst({
where: eq(mixes.id, newMixQuery.id),
with: { user: true },
});
if (newMix && newMix[0]) {
const mix = newMix[0];
return mapMixToMixModel(mix);
}
}),
});

View File

@@ -1,18 +1,20 @@
import { env } from "@/env.mjs";
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import * as appSchema from "@/db/schema";
import * as authSchema from "@/db/auth";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
if (!env.DATABASE_URL) {
throw new Error("DATABASE_URL cannot be found");
}
// for migrations
const migrationClient = postgres(env.DATABASE_URL, { max: 1 });
void (async () => {
await migrate(drizzle(migrationClient), { migrationsFolder: "./drizzle" });
})();
// const migrationClient = postgres(env.DATABASE_URL, { max: 1 });
// void (async () => {
// await migrate(drizzle(migrationClient), { migrationsFolder: "./drizzle" });
// })();
// for query purposes
const queryClient = postgres(env.DATABASE_URL);
export const db: PostgresJsDatabase = drizzle(queryClient);
export const db = drizzle(queryClient, {
schema: { ...appSchema, ...authSchema },
});