mirror of
https://github.com/fergalmoran/mixyboos.git
synced 2025-12-22 09:41:39 +00:00
Basic listing pages added
This commit is contained in:
12
drizzle.config.ts
Normal file
12
drizzle.config.ts
Normal 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;
|
||||
99
drizzle/0000_mysterious_sentinels.sql
Normal file
99
drizzle/0000_mysterious_sentinels.sql
Normal 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 $$;
|
||||
408
drizzle/meta/0000_snapshot.json
Normal file
408
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal 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
53
jest.config.js
Normal 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/"],
|
||||
// // };
|
||||
42
package.json
42
package.json
@@ -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
9911
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/img/streaming-placeholder.jpg
Normal file
BIN
public/img/streaming-placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 408 KiB |
30
scripts/reset_db.sh
Executable file
30
scripts/reset_db.sh
Executable 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"}}}'
|
||||
30
src/__tests__/azure.test.ts
Normal file
30
src/__tests__/azure.test.ts
Normal 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));
|
||||
});
|
||||
21
src/__tests__/upload.test.ts
Normal file
21
src/__tests__/upload.test.ts
Normal 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);
|
||||
25
src/app/[userName]/[mixSlug]/page.tsx
Normal file
25
src/app/[userName]/[mixSlug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/[userName]/mixes/data.ts
Normal file
24
src/app/[userName]/mixes/data.ts
Normal 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;
|
||||
49
src/app/[userName]/mixes/page.tsx
Normal file
49
src/app/[userName]/mixes/page.tsx
Normal 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;
|
||||
@@ -1,13 +1,13 @@
|
||||
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
|
||||
@@ -35,9 +35,9 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
</div>
|
||||
<div className="hidden flex-col md:flex">
|
||||
<div className="border-b bg-background/95 backdrop-blur">
|
||||
<Navbar className="mx-6" />
|
||||
<Navbar className="mx-6"/>
|
||||
</div>
|
||||
<div className="flex-1 mt-12 mx-8">{children}</div>
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">{children}</div>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
156
src/components/widgets/audio/large-audio-player.tsx
Normal file
156
src/components/widgets/audio/large-audio-player.tsx
Normal 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;
|
||||
26
src/components/widgets/audio/mix-processing-status.tsx
Normal file
26
src/components/widgets/audio/mix-processing-status.tsx
Normal 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;
|
||||
42
src/components/widgets/buttons/action-button.tsx
Normal file
42
src/components/widgets/buttons/action-button.tsx
Normal 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;
|
||||
63
src/components/widgets/buttons/play-pause-button.tsx
Normal file
63
src/components/widgets/buttons/play-pause-button.tsx
Normal 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
59
src/db/auth.ts
Normal 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
134
src/db/schema.ts
Normal 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">;
|
||||
69
src/lib/services/stores/audio/audioStore.ts
Normal file
69
src/lib/services/stores/audio/audioStore.ts
Normal 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;
|
||||
47
src/lib/utils/bufferUtils.ts
Normal file
47
src/lib/utils/bufferUtils.ts
Normal 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
4
src/lib/utils/errors.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
const raise = (err: string): never => {
|
||||
throw new Error(err);
|
||||
};
|
||||
export default raise;
|
||||
8
src/lib/utils/fileUtils.ts
Normal file
8
src/lib/utils/fileUtils.ts
Normal 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 };
|
||||
15
src/lib/utils/httpUtils.ts
Normal file
15
src/lib/utils/httpUtils.ts
Normal 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 };
|
||||
20
src/lib/utils/mappers/mixMapper.ts
Normal file
20
src/lib/utils/mappers/mixMapper.ts
Normal 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};
|
||||
203
src/server/adapters/drizzleAdapter.ts
Normal file
203
src/server/adapters/drizzleAdapter.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user