Initial commit
This commit is contained in:
38
apps/server/Dockerfile
Normal file
38
apps/server/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# ---- Build stage ----
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY apps/server/package.json apps/server/
|
||||
COPY apps/web/package.json apps/web/
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
COPY . .
|
||||
RUN npm run build -w @adc-retivo/web
|
||||
RUN npm run build -w @adc-retivo/server
|
||||
|
||||
# ---- Prune prod deps ----
|
||||
FROM build AS prod-deps
|
||||
RUN npm prune --omit=dev \
|
||||
&& mkdir -p /app/apps/server/node_modules /app/apps/web/node_modules
|
||||
|
||||
# ---- Runner stage ----
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libchromaprint-tools ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=prod-deps /app/apps/server/node_modules ./apps/server/node_modules
|
||||
COPY package.json ./
|
||||
COPY apps/server/package.json apps/server/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY --from=build /app/apps/server/dist ./apps/server/dist
|
||||
COPY --from=build /app/apps/web/dist ./apps/web/dist
|
||||
COPY apps/server/drizzle ./apps/server/drizzle
|
||||
ENV SERVE_WEB_DIST=/app/apps/web/dist
|
||||
WORKDIR /app/apps/server
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/index.js"]
|
||||
11
apps/server/Dockerfile.dev
Normal file
11
apps/server/Dockerfile.dev
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libchromaprint-tools ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY apps/server/package.json apps/server/
|
||||
COPY apps/web/package.json apps/web/
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
EXPOSE 3001
|
||||
CMD ["npm", "run", "dev", "-w", "@adc-retivo/server"]
|
||||
12
apps/server/drizzle.config.ts
Normal file
12
apps/server/drizzle.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://adc:adc@localhost:5432/adc",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
} satisfies Config;
|
||||
174
apps/server/drizzle/0000_clever_famine.sql
Normal file
174
apps/server/drizzle/0000_clever_famine.sql
Normal file
@@ -0,0 +1,174 @@
|
||||
CREATE TYPE "public"."library_mode" AS ENUM('reference', 'copy');--> statement-breakpoint
|
||||
CREATE TYPE "public"."playlist_visibility" AS ENUM('public', 'unlisted', 'private');--> statement-breakpoint
|
||||
CREATE TYPE "public"."track_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint
|
||||
CREATE TABLE "albums" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"artist_id" uuid,
|
||||
"mb_release_group_id" text,
|
||||
"mb_release_id" text,
|
||||
"release_date" text,
|
||||
"cover_url" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "artist_follows" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"artist_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "artist_follows_user_id_artist_id_pk" PRIMARY KEY("user_id","artist_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "artists" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"sort_name" text,
|
||||
"mb_artist_id" text,
|
||||
"description" text,
|
||||
"image_url" text,
|
||||
"country" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"track_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"body" text NOT NULL,
|
||||
"deleted" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "library_playlists" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"playlist_id" uuid NOT NULL,
|
||||
"mode" "library_mode" DEFAULT 'reference' NOT NULL,
|
||||
"copied_playlist_id" uuid,
|
||||
"can_edit" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "playlist_collaborators" (
|
||||
"playlist_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"can_edit" boolean DEFAULT true NOT NULL,
|
||||
CONSTRAINT "playlist_collaborators_playlist_id_user_id_pk" PRIMARY KEY("playlist_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "playlist_tracks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"playlist_id" uuid NOT NULL,
|
||||
"track_id" uuid NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"added_by_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "playlists" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"owner_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"cover_url" text,
|
||||
"visibility" "playlist_visibility" DEFAULT 'private' NOT NULL,
|
||||
"is_shareable" boolean DEFAULT false NOT NULL,
|
||||
"allow_collaborator_edit" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "track_likes" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"track_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "track_likes_user_id_track_id_pk" PRIMARY KEY("user_id","track_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tracks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"artist_id" uuid,
|
||||
"album_id" uuid,
|
||||
"duration_ms" integer,
|
||||
"track_number" integer,
|
||||
"disc_number" integer,
|
||||
"release_date" text,
|
||||
"lyrics_plain" text,
|
||||
"lyrics_synced" jsonb,
|
||||
"lyrics_source" text,
|
||||
"file_key" text NOT NULL,
|
||||
"file_size" bigint,
|
||||
"mime_type" text,
|
||||
"uploaded_by_id" uuid,
|
||||
"status" "track_status" DEFAULT 'pending' NOT NULL,
|
||||
"rejection_reason" text,
|
||||
"fingerprint" text,
|
||||
"mb_recording_id" text,
|
||||
"cover_url" text,
|
||||
"play_count" integer DEFAULT 0 NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"approved_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"display_name" text,
|
||||
"password_hash" text NOT NULL,
|
||||
"avatar_url" text,
|
||||
"bio" text,
|
||||
"is_super_admin" boolean DEFAULT false NOT NULL,
|
||||
"permissions" jsonb DEFAULT '{"canCreatePlaylists":true,"canUploadTracks":true,"canAutoApprove":false,"canModerate":false,"canManageUsers":false,"canManageLibrary":false,"canViewAllPlaylists":false}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "albums" ADD CONSTRAINT "albums_artist_id_artists_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "artist_follows" ADD CONSTRAINT "artist_follows_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "artist_follows" ADD CONSTRAINT "artist_follows_artist_id_artists_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "library_playlists" ADD CONSTRAINT "library_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "library_playlists" ADD CONSTRAINT "library_playlists_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "library_playlists" ADD CONSTRAINT "library_playlists_copied_playlist_id_playlists_id_fk" FOREIGN KEY ("copied_playlist_id") REFERENCES "public"."playlists"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "playlist_collaborators" ADD CONSTRAINT "playlist_collaborators_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "playlist_collaborators" ADD CONSTRAINT "playlist_collaborators_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "playlist_tracks" ADD CONSTRAINT "playlist_tracks_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "playlist_tracks" ADD CONSTRAINT "playlist_tracks_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "playlist_tracks" ADD CONSTRAINT "playlist_tracks_added_by_id_users_id_fk" FOREIGN KEY ("added_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "playlists" ADD CONSTRAINT "playlists_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "track_likes" ADD CONSTRAINT "track_likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "track_likes" ADD CONSTRAINT "track_likes_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tracks" ADD CONSTRAINT "tracks_artist_id_artists_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tracks" ADD CONSTRAINT "tracks_album_id_albums_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."albums"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tracks" ADD CONSTRAINT "tracks_uploaded_by_id_users_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "albums_title_idx" ON "albums" USING btree ("title");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "albums_mb_rg_idx" ON "albums" USING btree ("mb_release_group_id");--> statement-breakpoint
|
||||
CREATE INDEX "artists_name_idx" ON "artists" USING btree ("name");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "artists_mb_idx" ON "artists" USING btree ("mb_artist_id");--> statement-breakpoint
|
||||
CREATE INDEX "comments_track_idx" ON "comments" USING btree ("track_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "comments_parent_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "library_playlists_uniq" ON "library_playlists" USING btree ("user_id","playlist_id");--> statement-breakpoint
|
||||
CREATE INDEX "library_playlists_user_idx" ON "library_playlists" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "playlist_tracks_playlist_idx" ON "playlist_tracks" USING btree ("playlist_id","position");--> statement-breakpoint
|
||||
CREATE INDEX "playlist_tracks_track_idx" ON "playlist_tracks" USING btree ("track_id");--> statement-breakpoint
|
||||
CREATE INDEX "playlists_owner_idx" ON "playlists" USING btree ("owner_id");--> statement-breakpoint
|
||||
CREATE INDEX "playlists_visibility_idx" ON "playlists" USING btree ("visibility");--> statement-breakpoint
|
||||
CREATE INDEX "tracks_status_idx" ON "tracks" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "tracks_title_idx" ON "tracks" USING btree ("title");--> statement-breakpoint
|
||||
CREATE INDEX "tracks_uploader_idx" ON "tracks" USING btree ("uploaded_by_id");--> statement-breakpoint
|
||||
CREATE INDEX "tracks_fingerprint_idx" ON "tracks" USING btree ("fingerprint");--> statement-breakpoint
|
||||
CREATE INDEX "tracks_mb_recording_idx" ON "tracks" USING btree ("mb_recording_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree (lower("email"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_username_idx" ON "users" USING btree (lower("username"));
|
||||
1
apps/server/drizzle/0001_colossal_roxanne_simpson.sql
Normal file
1
apps/server/drizzle/0001_colossal_roxanne_simpson.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP INDEX "tracks_fingerprint_idx";
|
||||
14
apps/server/drizzle/0002_red_susan_delgado.sql
Normal file
14
apps/server/drizzle/0002_red_susan_delgado.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "track_artists" (
|
||||
"track_id" uuid NOT NULL,
|
||||
"artist_id" uuid NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"role" text DEFAULT 'main' NOT NULL,
|
||||
CONSTRAINT "track_artists_track_id_artist_id_pk" PRIMARY KEY("track_id","artist_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "track_artists" ADD CONSTRAINT "track_artists_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "track_artists" ADD CONSTRAINT "track_artists_artist_id_artists_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "track_artists_artist_idx" ON "track_artists" USING btree ("artist_id");--> statement-breakpoint
|
||||
-- Backfill: make the existing single artist of each track a main credit.
|
||||
INSERT INTO "track_artists" ("track_id", "artist_id", "position", "role")
|
||||
SELECT "id", "artist_id", 0, 'main' FROM "tracks" WHERE "artist_id" IS NOT NULL;
|
||||
1408
apps/server/drizzle/meta/0000_snapshot.json
Normal file
1408
apps/server/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1393
apps/server/drizzle/meta/0001_snapshot.json
Normal file
1393
apps/server/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1483
apps/server/drizzle/meta/0002_snapshot.json
Normal file
1483
apps/server/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
27
apps/server/drizzle/meta/_journal.json
Normal file
27
apps/server/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1791209057358,
|
||||
"tag": "0000_clever_famine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1791213238259,
|
||||
"tag": "0001_colossal_roxanne_simpson",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1791215521207,
|
||||
"tag": "0002_red_susan_delgado",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
49
apps/server/package.json
Normal file
49
apps/server/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@adc-retivo/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"worker": "tsx src/workers/metadata.worker.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"minio": "^8.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"music-metadata": "^10.6.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"pino": "^9.6.0",
|
||||
"postgres": "^3.4.5",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.5",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
13
apps/server/src/db/index.ts
Normal file
13
apps/server/src/db/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { env } from "../env.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export const sql = postgres(env.DATABASE_URL, {
|
||||
max: 10,
|
||||
onnotice: () => {},
|
||||
});
|
||||
|
||||
export const db = drizzle(sql, { schema });
|
||||
export { schema };
|
||||
export type DB = typeof db;
|
||||
38
apps/server/src/db/migrate.ts
Normal file
38
apps/server/src/db/migrate.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import postgres from "postgres";
|
||||
import { env } from "../env.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// migrations live in <server>/drizzle (two levels up from dist/src/db or src/db)
|
||||
const migrationsFolder = path.resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function main() {
|
||||
const client = postgres(env.DATABASE_URL, { max: 1, onnotice: () => {} });
|
||||
const db = drizzle(client);
|
||||
console.log(`[migrate] applying migrations from ${migrationsFolder}`);
|
||||
// Retry a few times: on Docker Desktop (macOS) bind-mounted files can briefly
|
||||
// read as empty right after the container starts.
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
try {
|
||||
await migrate(db, { migrationsFolder });
|
||||
console.log("[migrate] done");
|
||||
lastError = undefined;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.warn(`[migrate] attempt ${attempt} failed, retrying...`);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
await client.end();
|
||||
if (lastError) throw lastError;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[migrate] failed", err);
|
||||
process.exit(1);
|
||||
});
|
||||
10
apps/server/src/db/queries.ts
Normal file
10
apps/server/src/db/queries.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shared relational `with` clause for tracks so every endpoint returns the
|
||||
* same shape (primary artist, full artist credits, album, uploader).
|
||||
*/
|
||||
export const trackWith = {
|
||||
artist: true,
|
||||
album: true,
|
||||
uploader: true,
|
||||
artists: { with: { artist: true } },
|
||||
} as const;
|
||||
434
apps/server/src/db/schema.ts
Normal file
434
apps/server/src/db/schema.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
type AnyPgColumn,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Enums */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const trackStatus = pgEnum("track_status", ["pending", "approved", "rejected"]);
|
||||
export const playlistVisibility = pgEnum("playlist_visibility", [
|
||||
"public",
|
||||
"unlisted",
|
||||
"private",
|
||||
]);
|
||||
export const libraryMode = pgEnum("library_mode", ["reference", "copy"]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Permissions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface Permissions {
|
||||
canCreatePlaylists: boolean;
|
||||
canUploadTracks: boolean;
|
||||
canAutoApprove: boolean;
|
||||
canModerate: boolean;
|
||||
canManageUsers: boolean;
|
||||
canManageLibrary: boolean;
|
||||
canViewAllPlaylists: boolean;
|
||||
}
|
||||
|
||||
export const defaultPermissions: Permissions = {
|
||||
canCreatePlaylists: true,
|
||||
canUploadTracks: true,
|
||||
canAutoApprove: false,
|
||||
canModerate: false,
|
||||
canManageUsers: false,
|
||||
canManageLibrary: false,
|
||||
canViewAllPlaylists: false,
|
||||
};
|
||||
|
||||
export const superAdminPermissions: Permissions = {
|
||||
canCreatePlaylists: true,
|
||||
canUploadTracks: true,
|
||||
canAutoApprove: true,
|
||||
canModerate: true,
|
||||
canManageUsers: true,
|
||||
canManageLibrary: true,
|
||||
canViewAllPlaylists: true,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Users */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
email: text("email").notNull(),
|
||||
username: text("username").notNull(),
|
||||
displayName: text("display_name"),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
avatarUrl: text("avatar_url"),
|
||||
bio: text("bio"),
|
||||
isSuperAdmin: boolean("is_super_admin").notNull().default(false),
|
||||
permissions: jsonb("permissions").$type<Permissions>().notNull().default(defaultPermissions),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
emailIdx: uniqueIndex("users_email_idx").on(sql`lower(${t.email})`),
|
||||
usernameIdx: uniqueIndex("users_username_idx").on(sql`lower(${t.username})`),
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Artists */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const artists = pgTable(
|
||||
"artists",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
sortName: text("sort_name"),
|
||||
mbArtistId: text("mb_artist_id"),
|
||||
description: text("description"),
|
||||
imageUrl: text("image_url"),
|
||||
country: text("country"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
nameIdx: index("artists_name_idx").on(t.name),
|
||||
mbIdx: uniqueIndex("artists_mb_idx").on(t.mbArtistId),
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Albums */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const albums = pgTable(
|
||||
"albums",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
title: text("title").notNull(),
|
||||
artistId: uuid("artist_id").references(() => artists.id, { onDelete: "set null" }),
|
||||
mbReleaseGroupId: text("mb_release_group_id"),
|
||||
mbReleaseId: text("mb_release_id"),
|
||||
releaseDate: text("release_date"),
|
||||
coverUrl: text("cover_url"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
titleIdx: index("albums_title_idx").on(t.title),
|
||||
rgIdx: uniqueIndex("albums_mb_rg_idx").on(t.mbReleaseGroupId),
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tracks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface LyricsLine {
|
||||
timeMs: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const tracks = pgTable(
|
||||
"tracks",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
title: text("title").notNull(),
|
||||
artistId: uuid("artist_id").references(() => artists.id, { onDelete: "set null" }),
|
||||
albumId: uuid("album_id").references(() => albums.id, { onDelete: "set null" }),
|
||||
durationMs: integer("duration_ms"),
|
||||
trackNumber: integer("track_number"),
|
||||
discNumber: integer("disc_number"),
|
||||
releaseDate: text("release_date"),
|
||||
lyricsPlain: text("lyrics_plain"),
|
||||
lyricsSynced: jsonb("lyrics_synced").$type<LyricsLine[]>(),
|
||||
lyricsSource: text("lyrics_source"),
|
||||
fileKey: text("file_key").notNull(),
|
||||
fileSize: bigint("file_size", { mode: "number" }),
|
||||
mimeType: text("mime_type"),
|
||||
uploadedById: uuid("uploaded_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
status: trackStatus("status").notNull().default("pending"),
|
||||
rejectionReason: text("rejection_reason"),
|
||||
fingerprint: text("fingerprint"),
|
||||
mbRecordingId: text("mb_recording_id"),
|
||||
coverUrl: text("cover_url"),
|
||||
playCount: integer("play_count").notNull().default(0),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
statusIdx: index("tracks_status_idx").on(t.status),
|
||||
titleIdx: index("tracks_title_idx").on(t.title),
|
||||
uploaderIdx: index("tracks_uploader_idx").on(t.uploadedById),
|
||||
mbIdx: index("tracks_mb_recording_idx").on(t.mbRecordingId),
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Track artists (many-to-many) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const trackArtists = pgTable(
|
||||
"track_artists",
|
||||
{
|
||||
trackId: uuid("track_id")
|
||||
.notNull()
|
||||
.references(() => tracks.id, { onDelete: "cascade" }),
|
||||
artistId: uuid("artist_id")
|
||||
.notNull()
|
||||
.references(() => artists.id, { onDelete: "cascade" }),
|
||||
position: integer("position").notNull().default(0),
|
||||
role: text("role").notNull().default("main"),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.trackId, t.artistId] }),
|
||||
artistIdx: index("track_artists_artist_idx").on(t.artistId),
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Likes & follows */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const trackLikes = pgTable(
|
||||
"track_likes",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
trackId: uuid("track_id")
|
||||
.notNull()
|
||||
.references(() => tracks.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({ pk: primaryKey({ columns: [t.userId, t.trackId] }) }),
|
||||
);
|
||||
|
||||
export const artistFollows = pgTable(
|
||||
"artist_follows",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
artistId: uuid("artist_id")
|
||||
.notNull()
|
||||
.references(() => artists.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({ pk: primaryKey({ columns: [t.userId, t.artistId] }) }),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Playlists */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const playlists = pgTable(
|
||||
"playlists",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
ownerId: uuid("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
coverUrl: text("cover_url"),
|
||||
visibility: playlistVisibility("visibility").notNull().default("private"),
|
||||
isShareable: boolean("is_shareable").notNull().default(false),
|
||||
allowCollaboratorEdit: boolean("allow_collaborator_edit").notNull().default(true),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
ownerIdx: index("playlists_owner_idx").on(t.ownerId),
|
||||
visibilityIdx: index("playlists_visibility_idx").on(t.visibility),
|
||||
}),
|
||||
);
|
||||
|
||||
export const playlistTracks = pgTable(
|
||||
"playlist_tracks",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
playlistId: uuid("playlist_id")
|
||||
.notNull()
|
||||
.references(() => playlists.id, { onDelete: "cascade" }),
|
||||
trackId: uuid("track_id")
|
||||
.notNull()
|
||||
.references(() => tracks.id, { onDelete: "cascade" }),
|
||||
position: integer("position").notNull().default(0),
|
||||
addedById: uuid("added_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
playlistIdx: index("playlist_tracks_playlist_idx").on(t.playlistId, t.position),
|
||||
trackIdx: index("playlist_tracks_track_idx").on(t.trackId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const playlistCollaborators = pgTable(
|
||||
"playlist_collaborators",
|
||||
{
|
||||
playlistId: uuid("playlist_id")
|
||||
.notNull()
|
||||
.references(() => playlists.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
canEdit: boolean("can_edit").notNull().default(true),
|
||||
},
|
||||
(t) => ({ pk: primaryKey({ columns: [t.playlistId, t.userId] }) }),
|
||||
);
|
||||
|
||||
/** Playlists a user has added to their personal library (reference or copy). */
|
||||
export const libraryPlaylists = pgTable(
|
||||
"library_playlists",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
playlistId: uuid("playlist_id")
|
||||
.notNull()
|
||||
.references(() => playlists.id, { onDelete: "cascade" }),
|
||||
mode: libraryMode("mode").notNull().default("reference"),
|
||||
/** When mode = copy, points at the user-owned cloned playlist. */
|
||||
copiedPlaylistId: uuid("copied_playlist_id").references(() => playlists.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
canEdit: boolean("can_edit").notNull().default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniq: uniqueIndex("library_playlists_uniq").on(t.userId, t.playlistId),
|
||||
userIdx: index("library_playlists_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Comments */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const comments = pgTable(
|
||||
"comments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
trackId: uuid("track_id")
|
||||
.notNull()
|
||||
.references(() => tracks.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
parentId: uuid("parent_id").references((): AnyPgColumn => comments.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
body: text("body").notNull(),
|
||||
deleted: boolean("deleted").notNull().default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
trackIdx: index("comments_track_idx").on(t.trackId, t.createdAt),
|
||||
parentIdx: index("comments_parent_idx").on(t.parentId),
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Relations (for query builder convenience) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
tracks: many(tracks),
|
||||
playlists: many(playlists),
|
||||
comments: many(comments),
|
||||
}));
|
||||
|
||||
export const artistsRelations = relations(artists, ({ many }) => ({
|
||||
albums: many(albums),
|
||||
tracks: many(tracks),
|
||||
credits: many(trackArtists),
|
||||
followers: many(artistFollows),
|
||||
}));
|
||||
|
||||
export const albumsRelations = relations(albums, ({ one, many }) => ({
|
||||
artist: one(artists, { fields: [albums.artistId], references: [artists.id] }),
|
||||
tracks: many(tracks),
|
||||
}));
|
||||
|
||||
export const tracksRelations = relations(tracks, ({ one, many }) => ({
|
||||
artist: one(artists, { fields: [tracks.artistId], references: [artists.id] }),
|
||||
album: one(albums, { fields: [tracks.albumId], references: [albums.id] }),
|
||||
uploader: one(users, { fields: [tracks.uploadedById], references: [users.id] }),
|
||||
artists: many(trackArtists),
|
||||
playlistEntries: many(playlistTracks),
|
||||
comments: many(comments),
|
||||
}));
|
||||
|
||||
export const trackArtistsRelations = relations(trackArtists, ({ one }) => ({
|
||||
track: one(tracks, { fields: [trackArtists.trackId], references: [tracks.id] }),
|
||||
artist: one(artists, { fields: [trackArtists.artistId], references: [artists.id] }),
|
||||
}));
|
||||
|
||||
export const playlistsRelations = relations(playlists, ({ one, many }) => ({
|
||||
owner: one(users, { fields: [playlists.ownerId], references: [users.id] }),
|
||||
entries: many(playlistTracks),
|
||||
collaborators: many(playlistCollaborators),
|
||||
}));
|
||||
|
||||
export const playlistTracksRelations = relations(playlistTracks, ({ one }) => ({
|
||||
playlist: one(playlists, {
|
||||
fields: [playlistTracks.playlistId],
|
||||
references: [playlists.id],
|
||||
}),
|
||||
track: one(tracks, { fields: [playlistTracks.trackId], references: [tracks.id] }),
|
||||
addedBy: one(users, { fields: [playlistTracks.addedById], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const playlistCollaboratorsRelations = relations(playlistCollaborators, ({ one }) => ({
|
||||
playlist: one(playlists, {
|
||||
fields: [playlistCollaborators.playlistId],
|
||||
references: [playlists.id],
|
||||
}),
|
||||
user: one(users, { fields: [playlistCollaborators.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const libraryPlaylistsRelations = relations(libraryPlaylists, ({ one }) => ({
|
||||
user: one(users, { fields: [libraryPlaylists.userId], references: [users.id] }),
|
||||
playlist: one(playlists, {
|
||||
fields: [libraryPlaylists.playlistId],
|
||||
references: [playlists.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const commentsRelations = relations(comments, ({ one, many }) => ({
|
||||
user: one(users, { fields: [comments.userId], references: [users.id] }),
|
||||
track: one(tracks, { fields: [comments.trackId], references: [tracks.id] }),
|
||||
parent: one(comments, {
|
||||
fields: [comments.parentId],
|
||||
references: [comments.id],
|
||||
relationName: "commentReplies",
|
||||
}),
|
||||
replies: many(comments, { relationName: "commentReplies" }),
|
||||
}));
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type Track = typeof tracks.$inferSelect;
|
||||
export type TrackArtist = typeof trackArtists.$inferSelect;
|
||||
export type Artist = typeof artists.$inferSelect;
|
||||
export type Album = typeof albums.$inferSelect;
|
||||
export type Playlist = typeof playlists.$inferSelect;
|
||||
export type Comment = typeof comments.$inferSelect;
|
||||
40
apps/server/src/db/seed.ts
Normal file
40
apps/server/src/db/seed.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { sql as rawSql } from "drizzle-orm";
|
||||
import { env } from "../env.js";
|
||||
import { db, sql } from "./index.js";
|
||||
import { superAdminPermissions, users } from "./schema.js";
|
||||
|
||||
async function main() {
|
||||
const [{ count }] = await db
|
||||
.select({ count: rawSql<number>`count(*)::int` })
|
||||
.from(users);
|
||||
|
||||
if (count > 0) {
|
||||
console.log("[seed] users already exist, skipping admin bootstrap");
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(env.ADMIN_PASSWORD, 10);
|
||||
const [admin] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: env.ADMIN_EMAIL.trim().toLowerCase(),
|
||||
username: env.ADMIN_USERNAME.trim(),
|
||||
displayName: "Administrator",
|
||||
passwordHash,
|
||||
isSuperAdmin: true,
|
||||
permissions: superAdminPermissions,
|
||||
})
|
||||
.returning();
|
||||
|
||||
console.log(`[seed] created super admin: ${admin.email} (username: ${admin.username})`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("[seed] failed", err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await sql.end();
|
||||
});
|
||||
56
apps/server/src/env.ts
Normal file
56
apps/server/src/env.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { config as loadEnv } from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
// Load .env from the repo root when running outside Docker (no-op in containers).
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
loadEnv({ path: path.resolve(__dirname, "../../../.env") });
|
||||
loadEnv();
|
||||
|
||||
const bool = (def: boolean) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v === undefined ? def : v === "true" || v === "1"));
|
||||
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
APP_URL: z.string().default("http://localhost:3000"),
|
||||
CORS_ORIGINS: z.string().optional().default(""),
|
||||
|
||||
SESSION_SECRET: z.string().min(16).default("dev-secret-change-me-please-32chars-min"),
|
||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||
|
||||
DATABASE_URL: z.string().default("postgres://adc:adc@localhost:5432/adc"),
|
||||
REDIS_URL: z.string().default("redis://localhost:6379"),
|
||||
|
||||
MINIO_ENDPOINT: z.string().default("localhost"),
|
||||
MINIO_PORT: z.coerce.number().default(9000),
|
||||
MINIO_SSL: bool(false),
|
||||
MINIO_ACCESS_KEY: z.string().default("minioadmin"),
|
||||
MINIO_SECRET_KEY: z.string().default("minioadmin"),
|
||||
MINIO_BUCKET: z.string().default("adc-retivo"),
|
||||
MINIO_PUBLIC_URL: z.string().optional().default(""),
|
||||
|
||||
MB_USER_AGENT: z
|
||||
.string()
|
||||
.default("ADCRetivo/1.0 ( https://example.com contact:admin@example.com )"),
|
||||
ACOUSTID_API_KEY: z.string().optional().default(""),
|
||||
FPCALC_PATH: z.string().default("fpcalc"),
|
||||
|
||||
ADMIN_EMAIL: z.string().default("admin@adc.retivo"),
|
||||
ADMIN_USERNAME: z.string().default("admin"),
|
||||
ADMIN_PASSWORD: z.string().default("admin123"),
|
||||
|
||||
SERVE_WEB_DIST: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
export const env = schema.parse(process.env);
|
||||
|
||||
export const isProd = env.NODE_ENV === "production";
|
||||
|
||||
export const corsOrigins = env.CORS_ORIGINS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
13
apps/server/src/express.d.ts
vendored
Normal file
13
apps/server/src/express.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { User } from "../db/schema.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: User;
|
||||
sessionId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
93
apps/server/src/index.ts
Normal file
93
apps/server/src/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import helmet from "helmet";
|
||||
import { corsOrigins, env, isProd } from "./env.js";
|
||||
import { HttpError } from "./lib/http.js";
|
||||
import { logger } from "./lib/logger.js";
|
||||
import { ensureBucket } from "./lib/storage.js";
|
||||
import { attachUser } from "./middleware/auth.js";
|
||||
import { apiRouter } from "./routes/index.js";
|
||||
import { startMetadataWorker } from "./workers/metadata.worker.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function main() {
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
contentSecurityPolicy: false,
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
cors({
|
||||
origin: corsOrigins.length ? corsOrigins : true,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use(cookieParser());
|
||||
if (!isProd) {
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on("finish", () => {
|
||||
logger.debug(
|
||||
{ method: req.method, url: req.originalUrl, status: res.statusCode, ms: Date.now() - start },
|
||||
"request",
|
||||
);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use(
|
||||
"/api/auth/login",
|
||||
rateLimit({ windowMs: 60_000, limit: 20, standardHeaders: true, legacyHeaders: false }),
|
||||
);
|
||||
|
||||
app.use(attachUser);
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
app.get("/api/health", (_req, res) => res.json({ ok: true, time: new Date().toISOString() }));
|
||||
|
||||
// Serve the built SPA in production
|
||||
const webDist = env.SERVE_WEB_DIST || path.resolve(__dirname, "../../web/dist");
|
||||
app.use(express.static(webDist));
|
||||
app.get("*", (req, res, next) => {
|
||||
if (req.path.startsWith("/api")) return next();
|
||||
res.sendFile(path.join(webDist, "index.html"), (err) => {
|
||||
if (err) next();
|
||||
});
|
||||
});
|
||||
|
||||
// 404 for unknown API routes
|
||||
app.use("/api", (_req, res) => res.status(404).json({ error: "Не найдено" }));
|
||||
|
||||
// Error handler
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
if (err instanceof HttpError) {
|
||||
return res.status(err.status).json({ error: err.message, details: err.details });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Внутренняя ошибка";
|
||||
logger.error({ err }, "unhandled error");
|
||||
res.status(500).json({ error: isProd ? "Внутренняя ошибка сервера" : message });
|
||||
});
|
||||
|
||||
await ensureBucket();
|
||||
startMetadataWorker();
|
||||
|
||||
app.listen(env.PORT, () => {
|
||||
logger.info(`ADC Retivo API listening on :${env.PORT} (${env.NODE_ENV})`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error({ err }, "failed to start server");
|
||||
process.exit(1);
|
||||
});
|
||||
49
apps/server/src/lib/http.ts
Normal file
49
apps/server/src/lib/http.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { ZodSchema } from "zod";
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
constructor(status: number, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export const badRequest = (msg = "Некорректный запрос", details?: unknown) =>
|
||||
new HttpError(400, msg, details);
|
||||
export const unauthorized = (msg = "Требуется вход") => new HttpError(401, msg);
|
||||
export const forbidden = (msg = "Недостаточно прав") => new HttpError(403, msg);
|
||||
export const notFound = (msg = "Не найдено") => new HttpError(404, msg);
|
||||
export const conflict = (msg = "Конфликт") => new HttpError(409, msg);
|
||||
|
||||
type Handler = (req: Request, res: Response, next: NextFunction) => Promise<unknown> | unknown;
|
||||
|
||||
export function asyncHandler(fn: Handler) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBody<T>(schema: ZodSchema<T>, body: unknown): T {
|
||||
const result = schema.safeParse(body);
|
||||
if (!result.success) {
|
||||
throw badRequest("Ошибка валидации", result.error.flatten());
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function parsePagination(req: Request, defaultLimit = 40) {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || defaultLimit, 1), 200);
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
export const uuidRe =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function assertUuid(value: string, message = "Некорректный идентификатор"): string {
|
||||
if (!uuidRe.test(value)) throw badRequest(message);
|
||||
return value;
|
||||
}
|
||||
13
apps/server/src/lib/logger.ts
Normal file
13
apps/server/src/lib/logger.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import pino from "pino";
|
||||
import { env, isProd } from "../env.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? (isProd ? "info" : "debug"),
|
||||
transport: isProd
|
||||
? undefined
|
||||
: {
|
||||
target: "pino-pretty",
|
||||
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
||||
},
|
||||
base: { app: env.APP_URL },
|
||||
});
|
||||
67
apps/server/src/lib/metadata/acoustid.ts
Normal file
67
apps/server/src/lib/metadata/acoustid.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { env } from "../../env.js";
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ChromaprintResult {
|
||||
duration: number;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export async function computeFingerprint(filePath: string): Promise<ChromaprintResult | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(env.FPCALC_PATH, ["-json", filePath], {
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { duration?: number; fingerprint?: string };
|
||||
if (!parsed.fingerprint) return null;
|
||||
return { duration: parsed.duration ?? 0, fingerprint: parsed.fingerprint };
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "fpcalc unavailable or failed; skipping fingerprint");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AcoustIdMatch {
|
||||
recordingId?: string;
|
||||
score: number;
|
||||
releaseGroupIds: string[];
|
||||
}
|
||||
|
||||
interface AcoustIdResponse {
|
||||
status: string;
|
||||
results?: Array<{
|
||||
score: number;
|
||||
recordings?: Array<{ id: string; releasegroups?: Array<{ id: string }> }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function lookupAcoustId(
|
||||
fingerprint: string,
|
||||
durationSeconds: number,
|
||||
): Promise<AcoustIdMatch | null> {
|
||||
if (!env.ACOUSTID_API_KEY) return null;
|
||||
try {
|
||||
const url = new URL("https://api.acoustid.org/v2/lookup");
|
||||
url.searchParams.set("client", env.ACOUSTID_API_KEY);
|
||||
url.searchParams.set("meta", "recordings releasegroups");
|
||||
url.searchParams.set("duration", String(Math.round(durationSeconds)));
|
||||
url.searchParams.set("fingerprint", fingerprint);
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as AcoustIdResponse;
|
||||
if (data.status !== "ok" || !data.results?.length) return null;
|
||||
const best = [...data.results].sort((a, b) => b.score - a.score)[0];
|
||||
const recording = best.recordings?.[0];
|
||||
return {
|
||||
recordingId: recording?.id,
|
||||
score: best.score,
|
||||
releaseGroupIds: (recording?.releasegroups ?? []).map((r) => r.id),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "acoustid lookup failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
35
apps/server/src/lib/metadata/coverart.ts
Normal file
35
apps/server/src/lib/metadata/coverart.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
async function exists(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Range: "bytes=0-0" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
return res.ok || res.status === 206;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover Art Archive front cover. Prefers the release-group (album-level) image.
|
||||
*/
|
||||
export async function findCoverArt(opts: {
|
||||
releaseGroupId?: string;
|
||||
releaseId?: string;
|
||||
}): Promise<string | null> {
|
||||
const candidates: string[] = [];
|
||||
if (opts.releaseGroupId) {
|
||||
candidates.push(`https://coverartarchive.org/release-group/${opts.releaseGroupId}/front-500`);
|
||||
}
|
||||
if (opts.releaseId) {
|
||||
candidates.push(`https://coverartarchive.org/release/${opts.releaseId}/front-500`);
|
||||
}
|
||||
for (const url of candidates) {
|
||||
if (await exists(url)) return url;
|
||||
}
|
||||
logger.debug({ opts }, "no cover art found");
|
||||
return null;
|
||||
}
|
||||
132
apps/server/src/lib/metadata/index.ts
Normal file
132
apps/server/src/lib/metadata/index.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { logger } from "../logger.js";
|
||||
import { computeFingerprint, lookupAcoustId } from "./acoustid.js";
|
||||
import { findCoverArt } from "./coverart.js";
|
||||
import { fetchLyrics } from "./lrclib.js";
|
||||
import { lookupRecording, searchRecording } from "./musicbrainz.js";
|
||||
import { readTags } from "./tags.js";
|
||||
import { cleanFilename, parseArtistList, type Enrichment, type TagData } from "./types.js";
|
||||
import { fetchArtistInfo } from "./wikipedia.js";
|
||||
|
||||
export interface EnrichResult {
|
||||
tags: TagData;
|
||||
data: Enrichment;
|
||||
}
|
||||
|
||||
export async function enrichTrack(opts: {
|
||||
filePath: string;
|
||||
originalName?: string;
|
||||
}): Promise<EnrichResult> {
|
||||
const tags = await readTags(opts.filePath);
|
||||
const data: Enrichment = { sources: [] };
|
||||
|
||||
// 1) Acoustic fingerprint via Chromaprint + AcoustID (optional)
|
||||
const fp = await computeFingerprint(opts.filePath);
|
||||
let acoustidRecordingId: string | undefined;
|
||||
if (fp) {
|
||||
data.fingerprint = fp.fingerprint;
|
||||
if (fp.duration) data.durationMs = Math.round(fp.duration * 1000);
|
||||
const match = await lookupAcoustId(fp.fingerprint, fp.duration);
|
||||
if (match?.recordingId && match.score >= 0.5) {
|
||||
acoustidRecordingId = match.recordingId;
|
||||
data.sources.push("acoustid");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Derive title/artist guesses from tags, then from filename "Artist - Title"
|
||||
const filename = opts.originalName ? cleanFilename(opts.originalName) : "";
|
||||
let guessTitle = tags.title;
|
||||
let guessArtist = tags.artist ?? tags.albumArtist;
|
||||
if (!guessTitle && filename) {
|
||||
const m = filename.match(/^(.+?)\s+-\s+(.+)$/);
|
||||
if (m) {
|
||||
guessArtist = guessArtist ?? m[1].trim();
|
||||
guessTitle = m[2].trim();
|
||||
} else {
|
||||
guessTitle = filename;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) MusicBrainz canonical metadata
|
||||
let mb: Enrichment | null = null;
|
||||
if (acoustidRecordingId) mb = await lookupRecording(acoustidRecordingId);
|
||||
if (!mb) mb = await searchRecording(guessTitle ?? "", guessArtist, tags.durationMs);
|
||||
if (mb) {
|
||||
data.title = mb.title ?? data.title;
|
||||
data.artistName = mb.artistName ?? data.artistName;
|
||||
data.albumTitle = mb.albumTitle ?? data.albumTitle;
|
||||
data.releaseDate = mb.releaseDate ?? data.releaseDate;
|
||||
data.trackNumber = mb.trackNumber ?? data.trackNumber;
|
||||
data.discNumber = mb.discNumber ?? data.discNumber;
|
||||
data.mbRecordingId = mb.mbRecordingId;
|
||||
data.mbArtistId = mb.mbArtistId;
|
||||
data.mbReleaseGroupId = mb.mbReleaseGroupId;
|
||||
data.mbReleaseId = mb.mbReleaseId;
|
||||
if (!data.durationMs && mb.durationMs) data.durationMs = mb.durationMs;
|
||||
data.sources.push(...mb.sources);
|
||||
}
|
||||
|
||||
// 4) Fallbacks from local tags / filename.
|
||||
// The actual file duration is authoritative for playback, so it wins over
|
||||
// MusicBrainz's catalog length (which may differ from the uploaded file).
|
||||
if (tags.durationMs) data.durationMs = tags.durationMs;
|
||||
data.title ??= guessTitle;
|
||||
data.albumTitle ??= tags.album;
|
||||
data.releaseDate ??= tags.date;
|
||||
data.trackNumber ??= tags.trackNumber;
|
||||
data.discNumber ??= tags.discNumber;
|
||||
|
||||
// Multiple artists: from MB, or split the tag / filename artist string
|
||||
// ("A, B", "A feat. B", "A & B", ...) into separate credits.
|
||||
if (!data.artists?.length) {
|
||||
const candidates = (
|
||||
tags.artists?.length
|
||||
? tags.artists
|
||||
: [tags.artist ?? tags.albumArtist ?? guessArtist].filter(Boolean)
|
||||
) as string[];
|
||||
const parsed = candidates.flatMap((name) => parseArtistList(name));
|
||||
if (parsed.length) data.artists = parsed;
|
||||
}
|
||||
data.artistName = data.artistName ?? data.artists?.[0]?.name ?? guessArtist;
|
||||
|
||||
// 5) Cover art
|
||||
if (data.mbReleaseGroupId || data.mbReleaseId) {
|
||||
const coverUrl = await findCoverArt({
|
||||
releaseGroupId: data.mbReleaseGroupId,
|
||||
releaseId: data.mbReleaseId,
|
||||
});
|
||||
if (coverUrl) {
|
||||
data.coverUrl = coverUrl;
|
||||
data.sources.push("coverartarchive");
|
||||
}
|
||||
}
|
||||
|
||||
// 6) Artist bio + image
|
||||
if (data.artistName) {
|
||||
const info = await fetchArtistInfo(data.artistName);
|
||||
if (info) {
|
||||
data.artistDescription = info.extract;
|
||||
data.artistImageUrl = info.imageUrl;
|
||||
data.sources.push("wikipedia");
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Lyrics (plain + synced/LRC)
|
||||
const lyrics = await fetchLyrics({
|
||||
title: data.title ?? "",
|
||||
artist: data.artistName,
|
||||
album: data.albumTitle,
|
||||
durationMs: data.durationMs,
|
||||
});
|
||||
if (lyrics) {
|
||||
data.lyricsPlain = lyrics.plain;
|
||||
data.lyricsSynced = lyrics.synced;
|
||||
data.lyricsSource = lyrics.source;
|
||||
data.sources.push(lyrics.source);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{ title: data.title, artist: data.artistName, sources: data.sources },
|
||||
"metadata enrichment finished",
|
||||
);
|
||||
return { tags, data };
|
||||
}
|
||||
108
apps/server/src/lib/metadata/lrclib.ts
Normal file
108
apps/server/src/lib/metadata/lrclib.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { LyricsLine } from "../../db/schema.js";
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
export interface LyricsResult {
|
||||
plain?: string;
|
||||
synced?: LyricsLine[] | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const LRC_LINE = /\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]([^\[]*)/g;
|
||||
|
||||
export function parseLrc(lrc: string): LyricsLine[] {
|
||||
const lines: LyricsLine[] = [];
|
||||
for (const rawLine of lrc.split(/\r?\n/)) {
|
||||
let match: RegExpExecArray | null;
|
||||
LRC_LINE.lastIndex = 0;
|
||||
const matches: LayoutMatch[] = [];
|
||||
while ((match = LRC_LINE.exec(rawLine)) !== null) {
|
||||
const minutes = Number(match[1]);
|
||||
const seconds = Number(match[2].replace(":", "."));
|
||||
const text = match[3].trim();
|
||||
matches.push({ timeMs: Math.round((minutes * 60 + seconds) * 1000), text });
|
||||
}
|
||||
for (const m of matches) {
|
||||
if (m.text.length > 0) lines.push(m);
|
||||
}
|
||||
}
|
||||
return lines.sort((a, b) => a.timeMs - b.timeMs);
|
||||
}
|
||||
|
||||
interface LayoutMatch {
|
||||
timeMs: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface LrclibTrack {
|
||||
id: number;
|
||||
trackName?: string;
|
||||
artistName?: string;
|
||||
albumName?: string;
|
||||
duration?: number;
|
||||
plainLyrics?: string | null;
|
||||
syncedLyrics?: string | null;
|
||||
}
|
||||
|
||||
async function lrclibFetch(url: URL): Promise<LrclibTrack | LrclibTrack[] | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "ADCRetivo/1.0 (https://github.com/adc-retivo)",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as LrclibTrack | LrclibTrack[];
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "lrclib request failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pickBest(tracks: LrclibTrack[], durationMs?: number): LrclibTrack | undefined {
|
||||
if (!tracks.length) return undefined;
|
||||
const withSynced = tracks.filter((t) => t.syncedLyrics);
|
||||
const pool = withSynced.length ? withSynced : tracks;
|
||||
if (!durationMs) return pool[0];
|
||||
const targetSec = durationMs / 1000;
|
||||
return pool.reduce((acc, cur) => {
|
||||
const da = Math.abs((acc.duration ?? targetSec) - targetSec);
|
||||
const dc = Math.abs((cur.duration ?? targetSec) - targetSec);
|
||||
return dc < da ? cur : acc;
|
||||
}, pool[0]);
|
||||
}
|
||||
|
||||
export async function fetchLyrics(opts: {
|
||||
title: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
durationMs?: number;
|
||||
}): Promise<LyricsResult | null> {
|
||||
if (!opts.title) return null;
|
||||
|
||||
const exact = new URL("https://lrclib.net/api/get");
|
||||
exact.searchParams.set("track_name", opts.title);
|
||||
if (opts.artist) exact.searchParams.set("artist_name", opts.artist);
|
||||
if (opts.album) exact.searchParams.set("album_name", opts.album);
|
||||
if (opts.durationMs) exact.searchParams.set("duration", String(Math.round(opts.durationMs / 1000)));
|
||||
|
||||
let track = (await lrclibFetch(exact)) as LrclibTrack | null;
|
||||
if (!track || !Array.isArray(track)) {
|
||||
const search = new URL("https://lrclib.net/api/search");
|
||||
search.searchParams.set("q", [opts.title, opts.artist].filter(Boolean).join(" "));
|
||||
const results = (await lrclibFetch(search)) as LrclibTrack[] | null;
|
||||
if (results && Array.isArray(results) && results.length) {
|
||||
track = pickBest(results, opts.durationMs) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!track || Array.isArray(track)) return null;
|
||||
const synced = track.syncedLyrics ? parseLrc(track.syncedLyrics) : null;
|
||||
if (!synced?.length && !track.plainLyrics) return null;
|
||||
return {
|
||||
plain: track.plainLyrics ?? undefined,
|
||||
synced: synced?.length ? synced : null,
|
||||
source: "lrclib",
|
||||
};
|
||||
}
|
||||
142
apps/server/src/lib/metadata/musicbrainz.ts
Normal file
142
apps/server/src/lib/metadata/musicbrainz.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { env } from "../../env.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { parseArtistList, type ArtistCredit, type Enrichment } from "./types.js";
|
||||
|
||||
let lastRequest = 0;
|
||||
|
||||
async function mbFetch<T>(
|
||||
path: string,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<T | null> {
|
||||
const wait = Math.max(0, 1100 - (Date.now() - lastRequest));
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
lastRequest = Date.now();
|
||||
|
||||
const url = new URL(`https://musicbrainz.org${path}`);
|
||||
url.searchParams.set("fmt", "json");
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": env.MB_USER_AGENT, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn({ status: res.status, url: url.toString() }, "musicbrainz request failed");
|
||||
return null;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "musicbrainz request error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface MBArtistCredit {
|
||||
name?: string;
|
||||
joinphrase?: string;
|
||||
artist?: { id: string; name: string };
|
||||
}
|
||||
|
||||
interface MBRelease {
|
||||
id: string;
|
||||
title?: string;
|
||||
date?: string;
|
||||
"release-group"?: {
|
||||
id: string;
|
||||
"first-release-date"?: string;
|
||||
"primary-type"?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MBRecording {
|
||||
id: string;
|
||||
title: string;
|
||||
length?: number;
|
||||
"artist-credit"?: MBArtistCredit[];
|
||||
releases?: MBRelease[];
|
||||
}
|
||||
|
||||
interface MBSearchResponse {
|
||||
recordings?: MBRecording[];
|
||||
}
|
||||
|
||||
function normalizeRecording(rec: MBRecording, sourceLabel: string): Enrichment {
|
||||
const credits = rec["artist-credit"] ?? [];
|
||||
const credit = credits[0];
|
||||
const releases = rec.releases ?? [];
|
||||
// prefer release with a release-group and an earliest date
|
||||
const withGroup = releases.filter((r) => r["release-group"]);
|
||||
withGroup.sort((a, b) => {
|
||||
const da = a["release-group"]?.["first-release-date"] ?? a.date ?? "9999";
|
||||
const db = b["release-group"]?.["first-release-date"] ?? b.date ?? "9999";
|
||||
return da.localeCompare(db);
|
||||
});
|
||||
const release = withGroup[0] ?? releases[0];
|
||||
|
||||
let artists: ArtistCredit[] = credits
|
||||
.filter((c) => c.artist)
|
||||
.map((c) => ({ name: c.artist!.name, mbArtistId: c.artist!.id, role: "main" }));
|
||||
|
||||
const combined = artists.length <= 1 ? credit?.name ?? credit?.artist?.name : undefined;
|
||||
if (combined && /[,;&]|feat\.?|ft\.?|при участии/i.test(combined)) {
|
||||
const parsed = parseArtistList(combined);
|
||||
if (parsed.length > 1) artists = parsed;
|
||||
}
|
||||
|
||||
return {
|
||||
title: rec.title,
|
||||
artistName: artists[0]?.name ?? credit?.artist?.name ?? credit?.name,
|
||||
artists,
|
||||
mbRecordingId: rec.id,
|
||||
mbArtistId: artists[0]?.mbArtistId ?? credit?.artist?.id,
|
||||
mbReleaseId: release?.id,
|
||||
mbReleaseGroupId: release?.["release-group"]?.id,
|
||||
albumTitle: release?.title,
|
||||
releaseDate:
|
||||
release?.["release-group"]?.["first-release-date"] ?? release?.date ?? undefined,
|
||||
durationMs: rec.length ?? undefined,
|
||||
sources: [sourceLabel],
|
||||
};
|
||||
}
|
||||
|
||||
export async function lookupRecording(mbid: string): Promise<Enrichment | null> {
|
||||
const rec = await mbFetch<MBRecording>(`/ws/2/recording/${mbid}`, {
|
||||
inc: "artist-credits+releases+release-groups",
|
||||
});
|
||||
if (!rec) return null;
|
||||
return normalizeRecording(rec, "musicbrainz:recording");
|
||||
}
|
||||
|
||||
export async function searchRecording(
|
||||
title: string,
|
||||
artist?: string,
|
||||
durationMs?: number,
|
||||
): Promise<Enrichment | null> {
|
||||
if (!title) return null;
|
||||
const parts = [`recording:"${title.replace(/"/g, "")}"`];
|
||||
if (artist) parts.push(`artist:"${artist.replace(/"/g, "")}"`);
|
||||
const data = await mbFetch<MBSearchResponse>("/ws/2/recording", {
|
||||
query: parts.join(" AND "),
|
||||
limit: "5",
|
||||
inc: "artist-credits",
|
||||
});
|
||||
const recordings = data?.recordings ?? [];
|
||||
if (!recordings.length) return null;
|
||||
|
||||
// pick closest by duration when available
|
||||
let best = recordings[0];
|
||||
if (durationMs) {
|
||||
best = recordings.reduce((acc, cur) => {
|
||||
const da = Math.abs((acc.length ?? durationMs) - durationMs);
|
||||
const dc = Math.abs((cur.length ?? durationMs) - durationMs);
|
||||
return dc < da ? cur : acc;
|
||||
}, recordings[0]);
|
||||
}
|
||||
return normalizeRecording(best, "musicbrainz:search");
|
||||
}
|
||||
|
||||
export async function lookupArtist(mbid: string): Promise<{ name?: string } | null> {
|
||||
const artist = await mbFetch<{ name?: string }>(`/ws/2/artist/${mbid}`);
|
||||
return artist ?? null;
|
||||
}
|
||||
27
apps/server/src/lib/metadata/tags.ts
Normal file
27
apps/server/src/lib/metadata/tags.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { parseFile, selectCover } from "music-metadata";
|
||||
import type { TagData } from "./types.js";
|
||||
|
||||
export async function readTags(filePath: string): Promise<TagData> {
|
||||
try {
|
||||
const metadata = await parseFile(filePath, { duration: true });
|
||||
const common = metadata.common;
|
||||
const cover = selectCover(common.picture);
|
||||
return {
|
||||
title: common.title?.trim() || undefined,
|
||||
artist: common.artist?.trim() || undefined,
|
||||
artists: common.artists?.length ? common.artists : undefined,
|
||||
albumArtist: common.albumartist?.trim() || undefined,
|
||||
album: common.album?.trim() || undefined,
|
||||
date: common.date || (common.year ? String(common.year) : undefined),
|
||||
trackNumber: common.track?.no ?? undefined,
|
||||
discNumber: common.disk?.no ?? undefined,
|
||||
durationMs: metadata.format.duration
|
||||
? Math.round(metadata.format.duration * 1000)
|
||||
: undefined,
|
||||
pictureBuffer: cover?.data ? Buffer.from(cover.data) : undefined,
|
||||
pictureMime: cover?.format,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
76
apps/server/src/lib/metadata/types.ts
Normal file
76
apps/server/src/lib/metadata/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { LyricsLine } from "../../db/schema.js";
|
||||
|
||||
export interface TagData {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
artists?: string[];
|
||||
albumArtist?: string;
|
||||
album?: string;
|
||||
date?: string;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
durationMs?: number;
|
||||
pictureBuffer?: Buffer;
|
||||
pictureMime?: string;
|
||||
}
|
||||
|
||||
export interface ArtistCredit {
|
||||
name: string;
|
||||
mbArtistId?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface Enrichment {
|
||||
title?: string;
|
||||
artistName?: string;
|
||||
artists?: ArtistCredit[];
|
||||
albumTitle?: string;
|
||||
releaseDate?: string;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
durationMs?: number;
|
||||
mbRecordingId?: string;
|
||||
mbArtistId?: string;
|
||||
mbReleaseGroupId?: string;
|
||||
mbReleaseId?: string;
|
||||
coverUrl?: string;
|
||||
artistDescription?: string;
|
||||
artistImageUrl?: string;
|
||||
lyricsPlain?: string;
|
||||
lyricsSynced?: LyricsLine[] | null;
|
||||
lyricsSource?: string;
|
||||
fingerprint?: string;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export function cleanFilename(name: string): string {
|
||||
return name
|
||||
.replace(/\.[a-z0-9]{2,5}$/i, "")
|
||||
.replace(/[_]+/g, " ")
|
||||
.replace(/\s*-\s*/g, " - ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const FEAT_RE = /\s+(?:feat\.?|ft\.?|featuring|при\s+участии|совместно\s+с)\s+/i;
|
||||
const SPLIT_RE = /\s*(?:,|;|&|\u00d7|\bx\b|\/\s)\s*/i;
|
||||
|
||||
/**
|
||||
* Splits a combined artist string like "A, B feat. C" into separate credits.
|
||||
* Everything before a "feat." marker is `main`, the rest is `featured`.
|
||||
*/
|
||||
export function parseArtistList(raw?: string | null): ArtistCredit[] {
|
||||
if (!raw) return [];
|
||||
const [mainPart, ...featParts] = raw.split(FEAT_RE);
|
||||
const result: ArtistCredit[] = [];
|
||||
const add = (name: string, role: string) => {
|
||||
const trimmed = name.trim().replace(/^["'\-]+|["'\-]+$/g, "").trim();
|
||||
if (!trimmed) return;
|
||||
if (result.some((a) => a.name.toLowerCase() === trimmed.toLowerCase())) return;
|
||||
result.push({ name: trimmed, role });
|
||||
};
|
||||
for (const n of mainPart.split(SPLIT_RE)) add(n, "main");
|
||||
for (const fp of featParts) for (const n of fp.split(SPLIT_RE)) add(n, "featured");
|
||||
return result;
|
||||
}
|
||||
|
||||
49
apps/server/src/lib/metadata/wikipedia.ts
Normal file
49
apps/server/src/lib/metadata/wikipedia.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
export interface ArtistInfo {
|
||||
extract?: string;
|
||||
imageUrl?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface WikiSummary {
|
||||
type?: string;
|
||||
extract?: string;
|
||||
content_urls?: { desktop?: { page?: string } };
|
||||
thumbnail?: { source?: string };
|
||||
originalimage?: { source?: string };
|
||||
}
|
||||
|
||||
async function fetchSummary(lang: string, title: string): Promise<WikiSummary | null> {
|
||||
const url = new URL(
|
||||
`https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`,
|
||||
);
|
||||
url.searchParams.set("redirect", "true");
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "ADCRetivo/1.0 (https://github.com/adc-retivo)" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as WikiSummary;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches a short artist biography, trying Russian Wikipedia first, then English. */
|
||||
export async function fetchArtistInfo(name: string): Promise<ArtistInfo | null> {
|
||||
if (!name) return null;
|
||||
for (const lang of ["ru", "en"]) {
|
||||
const summary = await fetchSummary(lang, name);
|
||||
if (summary?.extract && summary.type !== "disambiguation") {
|
||||
return {
|
||||
extract: summary.extract,
|
||||
imageUrl: summary.originalimage?.source ?? summary.thumbnail?.source,
|
||||
url: summary.content_urls?.desktop?.page,
|
||||
};
|
||||
}
|
||||
}
|
||||
logger.debug({ name }, "no wikipedia info found");
|
||||
return null;
|
||||
}
|
||||
9
apps/server/src/lib/password.ts
Normal file
9
apps/server/src/lib/password.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export function hashPassword(plain: string): Promise<string> {
|
||||
return bcrypt.hash(plain, 10);
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, hash);
|
||||
}
|
||||
27
apps/server/src/lib/permissions.ts
Normal file
27
apps/server/src/lib/permissions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Permissions, User } from "../db/schema.js";
|
||||
import { superAdminPermissions } from "../db/schema.js";
|
||||
|
||||
export type PermissionKey = keyof Permissions;
|
||||
|
||||
export function resolvePermissions(user: Pick<User, "isSuperAdmin" | "permissions">): Permissions {
|
||||
if (user.isSuperAdmin) return superAdminPermissions;
|
||||
return { ...user.permissions };
|
||||
}
|
||||
|
||||
export function hasPerm(
|
||||
user: Pick<User, "isSuperAdmin" | "permissions"> | null | undefined,
|
||||
key: PermissionKey,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (user.isSuperAdmin) return true;
|
||||
return Boolean(user.permissions?.[key]);
|
||||
}
|
||||
|
||||
export function canAccessAdmin(user: Pick<User, "isSuperAdmin" | "permissions">): boolean {
|
||||
if (user.isSuperAdmin) return true;
|
||||
const p = user.permissions;
|
||||
return Boolean(
|
||||
p &&
|
||||
(p.canModerate || p.canManageUsers || p.canManageLibrary || p.canViewAllPlaylists),
|
||||
);
|
||||
}
|
||||
181
apps/server/src/lib/presenters.ts
Normal file
181
apps/server/src/lib/presenters.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
Album,
|
||||
Artist,
|
||||
Comment,
|
||||
Permissions,
|
||||
Playlist,
|
||||
Track,
|
||||
User,
|
||||
} from "../db/schema.js";
|
||||
import { resolvePermissions } from "./permissions.js";
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
bio: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export function publicUser(u: User | null | undefined): PublicUser | null {
|
||||
if (!u) return null;
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
avatarUrl: u.avatarUrl,
|
||||
bio: u.bio,
|
||||
createdAt: u.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfUser extends PublicUser {
|
||||
email: string;
|
||||
isSuperAdmin: boolean;
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
export function selfUser(u: User): SelfUser {
|
||||
return {
|
||||
...publicUser(u)!,
|
||||
email: u.email,
|
||||
isSuperAdmin: u.isSuperAdmin,
|
||||
permissions: resolvePermissions(u),
|
||||
};
|
||||
}
|
||||
|
||||
export function adminUser(u: User): SelfUser {
|
||||
return selfUser(u);
|
||||
}
|
||||
|
||||
type TrackWithRelations = Track & {
|
||||
artist?: Artist | null;
|
||||
album?: Album | null;
|
||||
uploader?: User | null;
|
||||
artists?: { position: number; role: string; artist?: Artist | null }[];
|
||||
};
|
||||
|
||||
export function trackDTO(t: TrackWithRelations, opts: { includeUploader?: boolean } = {}) {
|
||||
const credits = (t.artists ?? [])
|
||||
.filter((c) => c.artist)
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((c) => ({
|
||||
id: c.artist!.id,
|
||||
name: c.artist!.name,
|
||||
imageUrl: c.artist!.imageUrl,
|
||||
role: c.role,
|
||||
}));
|
||||
|
||||
const primary =
|
||||
credits[0] ??
|
||||
(t.artist ? { id: t.artist.id, name: t.artist.name, imageUrl: t.artist.imageUrl, role: "main" } : null);
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
durationMs: t.durationMs,
|
||||
trackNumber: t.trackNumber,
|
||||
discNumber: t.discNumber,
|
||||
releaseDate: t.releaseDate,
|
||||
status: t.status,
|
||||
rejectionReason: t.rejectionReason,
|
||||
coverUrl: t.coverUrl,
|
||||
playCount: t.playCount,
|
||||
hasLyrics: Boolean(t.lyricsPlain || t.lyricsSynced?.length),
|
||||
lyricsSource: t.lyricsSource,
|
||||
createdAt: t.createdAt,
|
||||
approvedAt: t.approvedAt,
|
||||
artist: primary,
|
||||
artists: credits,
|
||||
album: t.album
|
||||
? { id: t.album.id, title: t.album.title, coverUrl: t.album.coverUrl }
|
||||
: null,
|
||||
uploadedBy:
|
||||
opts.includeUploader === false ? undefined : publicUser(t.uploader),
|
||||
};
|
||||
}
|
||||
|
||||
export type TrackDTO = ReturnType<typeof trackDTO>;
|
||||
|
||||
export function artistDTO(a: Artist & { followersCount?: number }) {
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
sortName: a.sortName,
|
||||
description: a.description,
|
||||
imageUrl: a.imageUrl,
|
||||
country: a.country,
|
||||
mbArtistId: a.mbArtistId,
|
||||
followersCount: a.followersCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function albumDTO(a: Album & { artist?: Artist | null }) {
|
||||
return {
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
releaseDate: a.releaseDate,
|
||||
coverUrl: a.coverUrl,
|
||||
artist: a.artist ? { id: a.artist.id, name: a.artist.name, imageUrl: a.artist.imageUrl } : null,
|
||||
};
|
||||
}
|
||||
|
||||
type PlaylistWithRelations = Playlist & {
|
||||
owner?: User | null;
|
||||
entries?: { trackId: string }[];
|
||||
collaborators?: { userId: string; canEdit: boolean }[];
|
||||
};
|
||||
|
||||
export function playlistDTO(
|
||||
p: PlaylistWithRelations,
|
||||
extra: { trackCount?: number; canEdit?: boolean; isOwner?: boolean } = {},
|
||||
) {
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
coverUrl: p.coverUrl,
|
||||
visibility: p.visibility,
|
||||
isShareable: p.isShareable,
|
||||
allowCollaboratorEdit: p.allowCollaboratorEdit,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
owner: publicUser(p.owner),
|
||||
trackCount: extra.trackCount ?? p.entries?.length ?? 0,
|
||||
canEdit: extra.canEdit ?? false,
|
||||
isOwner: extra.isOwner ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export type PlaylistDTO = ReturnType<typeof playlistDTO>;
|
||||
|
||||
export interface CommentDTO {
|
||||
id: string;
|
||||
trackId: string;
|
||||
parentId: string | null;
|
||||
body: string | null;
|
||||
deleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user: PublicUser | null;
|
||||
replies: CommentDTO[];
|
||||
}
|
||||
|
||||
export function commentDTO(
|
||||
c: Comment & { user?: User | null },
|
||||
replies: CommentDTO[] = [],
|
||||
): CommentDTO {
|
||||
return {
|
||||
id: c.id,
|
||||
trackId: c.trackId,
|
||||
parentId: c.parentId,
|
||||
body: c.deleted ? null : c.body,
|
||||
deleted: c.deleted,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
user: publicUser(c.user),
|
||||
replies,
|
||||
};
|
||||
}
|
||||
33
apps/server/src/lib/queue.ts
Normal file
33
apps/server/src/lib/queue.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { newRedis } from "./redis.js";
|
||||
|
||||
export const METADATA_QUEUE = "adc-metadata";
|
||||
|
||||
export interface MetadataJobData {
|
||||
trackId: string;
|
||||
filePath: string;
|
||||
mimeType?: string;
|
||||
originalName?: string;
|
||||
}
|
||||
|
||||
const connection = newRedis();
|
||||
|
||||
export const metadataQueue = new Queue<MetadataJobData>(METADATA_QUEUE, {
|
||||
connection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 5000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
});
|
||||
|
||||
export async function enqueueMetadata(
|
||||
data: MetadataJobData,
|
||||
opts: { force?: boolean } = {},
|
||||
) {
|
||||
// `force` (manual retry) bypasses the dedupe jobId so the job actually runs again.
|
||||
return metadataQueue.add("process", data, {
|
||||
jobId: opts.force ? undefined : `track-${data.trackId}`,
|
||||
});
|
||||
}
|
||||
21
apps/server/src/lib/redis.ts
Normal file
21
apps/server/src/lib/redis.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../env.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
let client: Redis | null = null;
|
||||
|
||||
export function getRedis(): Redis {
|
||||
if (!client) {
|
||||
client = new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
lazyConnect: false,
|
||||
});
|
||||
client.on("error", (err) => logger.error({ err }, "redis error"));
|
||||
client.on("connect", () => logger.info("redis connected"));
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export function newRedis(): Redis {
|
||||
return new Redis(env.REDIS_URL, { maxRetriesPerRequest: null });
|
||||
}
|
||||
70
apps/server/src/lib/session.ts
Normal file
70
apps/server/src/lib/session.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { CookieOptions } from "express";
|
||||
import { env, isProd } from "../env.js";
|
||||
import { getRedis } from "./redis.js";
|
||||
|
||||
export const SESSION_COOKIE = "adc_sid";
|
||||
const PREFIX = "adc:sess:";
|
||||
const TTL_SECONDS = env.SESSION_TTL_DAYS * 24 * 60 * 60;
|
||||
|
||||
export interface SessionData {
|
||||
userId: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export async function createSession(userId: string): Promise<string> {
|
||||
const id = randomBytes(32).toString("base64url");
|
||||
const data: SessionData = { userId, createdAt: Date.now() };
|
||||
await getRedis().set(PREFIX + id, JSON.stringify(data), "EX", TTL_SECONDS);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getSession(id: string | undefined | null): Promise<SessionData | null> {
|
||||
if (!id) return null;
|
||||
const raw = await getRedis().get(PREFIX + id);
|
||||
if (!raw) return null;
|
||||
// Sliding expiration
|
||||
await getRedis().expire(PREFIX + id, TTL_SECONDS);
|
||||
try {
|
||||
return JSON.parse(raw) as SessionData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function destroySession(id: string | undefined | null): Promise<void> {
|
||||
if (!id) return;
|
||||
await getRedis().del(PREFIX + id);
|
||||
}
|
||||
|
||||
export async function destroyAllUserSessions(userId: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
let cursor = "0";
|
||||
do {
|
||||
const [next, keys] = await redis.scan(cursor, "MATCH", PREFIX + "*", "COUNT", 100);
|
||||
cursor = next;
|
||||
if (keys.length) {
|
||||
const values = await redis.mget(...keys);
|
||||
const toDelete = keys.filter((k, i) => {
|
||||
const v = values[i];
|
||||
if (!v) return false;
|
||||
try {
|
||||
return (JSON.parse(v) as SessionData).userId === userId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (toDelete.length) await redis.del(...toDelete);
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isProd,
|
||||
path: "/",
|
||||
maxAge: TTL_SECONDS * 1000,
|
||||
};
|
||||
}
|
||||
81
apps/server/src/lib/storage.ts
Normal file
81
apps/server/src/lib/storage.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Client as MinioClient } from "minio";
|
||||
import fs from "node:fs";
|
||||
import { env } from "../env.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
export const minio = new MinioClient({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PORT,
|
||||
useSSL: env.MINIO_SSL,
|
||||
accessKey: env.MINIO_ACCESS_KEY,
|
||||
secretKey: env.MINIO_SECRET_KEY,
|
||||
});
|
||||
|
||||
export const BUCKET = env.MINIO_BUCKET;
|
||||
|
||||
export async function ensureBucket(): Promise<void> {
|
||||
const exists = await minio.bucketExists(BUCKET).catch(() => false);
|
||||
if (!exists) {
|
||||
await minio.makeBucket(BUCKET);
|
||||
logger.info({ bucket: BUCKET }, "created object storage bucket");
|
||||
}
|
||||
}
|
||||
|
||||
export async function putFile(
|
||||
key: string,
|
||||
filePath: string,
|
||||
contentType?: string,
|
||||
): Promise<void> {
|
||||
await minio.fPutObject(BUCKET, key, filePath, {
|
||||
"Content-Type": contentType ?? "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
export async function putBuffer(
|
||||
key: string,
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
): Promise<void> {
|
||||
await minio.putObject(BUCKET, key, buffer, buffer.length, {
|
||||
"Content-Type": contentType ?? "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeObject(key: string): Promise<void> {
|
||||
await minio.removeObject(BUCKET, key).catch((err) => {
|
||||
logger.warn({ err, key }, "failed to remove object");
|
||||
});
|
||||
}
|
||||
|
||||
export interface ObjectStat {
|
||||
size: number;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export async function statObject(key: string): Promise<ObjectStat | null> {
|
||||
try {
|
||||
const stat = await minio.statObject(BUCKET, key);
|
||||
return {
|
||||
size: stat.size,
|
||||
contentType: (stat.metaData?.["content-type"] as string) ?? "application/octet-stream",
|
||||
etag: stat.etag,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamObject(key: string, start?: number, end?: number) {
|
||||
const from = start ?? 0;
|
||||
return minio.getPartialObject(
|
||||
BUCKET,
|
||||
key,
|
||||
from,
|
||||
end !== undefined ? end - from + 1 : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putFileFromBufferWithType(key: string, buffer: Buffer, contentType: string) {
|
||||
await putBuffer(key, buffer, contentType);
|
||||
}
|
||||
43
apps/server/src/middleware/auth.ts
Normal file
43
apps/server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { forbidden, unauthorized } from "../lib/http.js";
|
||||
import { canAccessAdmin, hasPerm, type PermissionKey } from "../lib/permissions.js";
|
||||
import { SESSION_COOKIE, getSession } from "../lib/session.js";
|
||||
|
||||
export async function attachUser(req: Request, _res: Response, next: NextFunction) {
|
||||
try {
|
||||
const sid = req.cookies?.[SESSION_COOKIE];
|
||||
if (!sid) return next();
|
||||
const session = await getSession(sid);
|
||||
if (!session) return next();
|
||||
const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
|
||||
if (user) {
|
||||
req.user = user;
|
||||
req.sessionId = sid;
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, _res: Response, next: NextFunction) {
|
||||
if (!req.user) return next(unauthorized());
|
||||
next();
|
||||
}
|
||||
|
||||
export function requirePermission(key: PermissionKey) {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
if (!req.user) return next(unauthorized());
|
||||
if (!hasPerm(req.user, key)) return next(forbidden());
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, _res: Response, next: NextFunction) {
|
||||
if (!req.user) return next(unauthorized());
|
||||
if (!canAccessAdmin(req.user)) return next(forbidden());
|
||||
next();
|
||||
}
|
||||
324
apps/server/src/routes/admin.ts
Normal file
324
apps/server/src/routes/admin.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { and, desc, eq, ne, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/index.js";
|
||||
import { trackWith } from "../db/queries.js";
|
||||
import {
|
||||
artists,
|
||||
comments,
|
||||
defaultPermissions,
|
||||
playlists,
|
||||
tracks,
|
||||
users,
|
||||
superAdminPermissions,
|
||||
type Permissions,
|
||||
} from "../db/schema.js";
|
||||
import {
|
||||
HttpError,
|
||||
asyncHandler,
|
||||
assertUuid,
|
||||
badRequest,
|
||||
conflict,
|
||||
notFound,
|
||||
parseBody,
|
||||
parsePagination,
|
||||
} from "../lib/http.js";
|
||||
import { hasPerm } from "../lib/permissions.js";
|
||||
import { adminUser, playlistDTO, trackDTO } from "../lib/presenters.js";
|
||||
import { destroyAllUserSessions } from "../lib/session.js";
|
||||
import { requireAdmin, requirePermission } from "../middleware/auth.js";
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
adminRouter.use("/admin", requireAdmin);
|
||||
|
||||
/* ------------------------------- stats ------------------------------- */
|
||||
|
||||
adminRouter.get(
|
||||
"/admin/stats",
|
||||
asyncHandler(async (_req, res) => {
|
||||
const [{ tracks: trackCount }] = await db
|
||||
.select({ tracks: sql<number>`count(*)::int` })
|
||||
.from(tracks);
|
||||
const [{ pending }] = await db
|
||||
.select({ pending: sql<number>`count(*)::int` })
|
||||
.from(tracks)
|
||||
.where(eq(tracks.status, "pending"));
|
||||
const [{ approved }] = await db
|
||||
.select({ approved: sql<number>`count(*)::int` })
|
||||
.from(tracks)
|
||||
.where(eq(tracks.status, "approved"));
|
||||
const [{ rejected }] = await db
|
||||
.select({ rejected: sql<number>`count(*)::int` })
|
||||
.from(tracks)
|
||||
.where(eq(tracks.status, "rejected"));
|
||||
const [{ users: userCount }] = await db.select({ users: sql<number>`count(*)::int` }).from(users);
|
||||
const [{ playlists: playlistCount }] = await db
|
||||
.select({ playlists: sql<number>`count(*)::int` })
|
||||
.from(playlists);
|
||||
const [{ artists: artistCount }] = await db
|
||||
.select({ artists: sql<number>`count(*)::int` })
|
||||
.from(artists);
|
||||
const [{ comments: commentCount }] = await db
|
||||
.select({ comments: sql<number>`count(*)::int` })
|
||||
.from(comments);
|
||||
|
||||
res.json({
|
||||
tracks: { total: trackCount, pending, approved, rejected },
|
||||
users: userCount,
|
||||
playlists: playlistCount,
|
||||
artists: artistCount,
|
||||
comments: commentCount,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------- users ------------------------------- */
|
||||
|
||||
adminRouter.get(
|
||||
"/admin/users",
|
||||
requirePermission("canManageUsers"),
|
||||
asyncHandler(async (_req, res) => {
|
||||
const rows = await db.select().from(users).orderBy(desc(users.createdAt));
|
||||
res.json({ items: rows.map(adminUser) });
|
||||
}),
|
||||
);
|
||||
|
||||
const permissionsSchema = z.object({
|
||||
canCreatePlaylists: z.boolean().optional(),
|
||||
canUploadTracks: z.boolean().optional(),
|
||||
canAutoApprove: z.boolean().optional(),
|
||||
canModerate: z.boolean().optional(),
|
||||
canManageUsers: z.boolean().optional(),
|
||||
canManageLibrary: z.boolean().optional(),
|
||||
canViewAllPlaylists: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3).max(60).regex(/^[a-zA-Z0-9_.-]+$/, "Только латиница, цифры, _. -"),
|
||||
password: z.string().min(6).max(200),
|
||||
displayName: z.string().max(120).optional(),
|
||||
isSuperAdmin: z.boolean().optional(),
|
||||
permissions: permissionsSchema.optional(),
|
||||
});
|
||||
|
||||
adminRouter.post(
|
||||
"/admin/users",
|
||||
requirePermission("canManageUsers"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const data = parseBody(createUserSchema, req.body);
|
||||
const email = data.email.toLowerCase();
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
sql`lower(${users.email}) = ${email} OR lower(${users.username}) = ${data.username.toLowerCase()}`,
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) throw conflict("Пользователь с такой почтой или именем уже существует");
|
||||
|
||||
const isSuper = Boolean(data.isSuperAdmin) && req.user!.isSuperAdmin;
|
||||
const [created] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
username: data.username,
|
||||
displayName: data.displayName ?? data.username,
|
||||
passwordHash: await bcrypt.hash(data.password, 10),
|
||||
isSuperAdmin: isSuper,
|
||||
permissions: isSuper
|
||||
? superAdminPermissions
|
||||
: { ...defaultPermissions, ...(data.permissions ?? {}) },
|
||||
})
|
||||
.returning();
|
||||
|
||||
res.status(201).json({ user: adminUser(created) });
|
||||
}),
|
||||
);
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
displayName: z.string().max(120).nullable().optional(),
|
||||
bio: z.string().max(2000).nullable().optional(),
|
||||
avatarUrl: z.string().nullable().optional(),
|
||||
email: z.string().email().optional(),
|
||||
username: z.string().min(3).max(60).optional(),
|
||||
isSuperAdmin: z.boolean().optional(),
|
||||
permissions: permissionsSchema.optional(),
|
||||
});
|
||||
|
||||
adminRouter.patch(
|
||||
"/admin/users/:id",
|
||||
requirePermission("canManageUsers"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
if (!target) throw notFound("Пользователь не найден");
|
||||
|
||||
const data = parseBody(updateUserSchema, req.body);
|
||||
const patch: Partial<typeof users.$inferInsert> = { updatedAt: new Date() };
|
||||
|
||||
if (data.email) patch.email = data.email.toLowerCase();
|
||||
if (data.username) patch.username = data.username;
|
||||
if (data.displayName !== undefined) patch.displayName = data.displayName;
|
||||
if (data.bio !== undefined) patch.bio = data.bio;
|
||||
if (data.avatarUrl !== undefined) patch.avatarUrl = data.avatarUrl;
|
||||
|
||||
if (data.permissions) {
|
||||
const merged: Permissions = { ...target.permissions, ...data.permissions };
|
||||
patch.permissions = target.isSuperAdmin ? superAdminPermissions : merged;
|
||||
}
|
||||
|
||||
if (data.isSuperAdmin !== undefined) {
|
||||
if (!req.user!.isSuperAdmin) throw new HttpError(403, "Только супер-админ может менять этот флаг");
|
||||
if (target.id === req.user!.id && !data.isSuperAdmin) {
|
||||
throw badRequest("Нельзя снять права супер-админа с самого себя");
|
||||
}
|
||||
patch.isSuperAdmin = data.isSuperAdmin;
|
||||
if (data.isSuperAdmin) patch.permissions = superAdminPermissions;
|
||||
}
|
||||
|
||||
await db.update(users).set(patch).where(eq(users.id, id));
|
||||
const [updated] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
res.json({ user: adminUser(updated) });
|
||||
}),
|
||||
);
|
||||
|
||||
const resetPasswordSchema = z.object({ password: z.string().min(6).max(200) });
|
||||
|
||||
adminRouter.post(
|
||||
"/admin/users/:id/password",
|
||||
requirePermission("canManageUsers"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const { password } = parseBody(resetPasswordSchema, req.body);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: await bcrypt.hash(password, 10), updatedAt: new Date() })
|
||||
.where(eq(users.id, id));
|
||||
await destroyAllUserSessions(id);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
adminRouter.delete(
|
||||
"/admin/users/:id",
|
||||
requirePermission("canManageUsers"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
if (id === req.user!.id) throw badRequest("Нельзя удалить самого себя");
|
||||
const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
if (!target) throw notFound("Пользователь не найден");
|
||||
if (target.isSuperAdmin && !req.user!.isSuperAdmin) {
|
||||
throw new HttpError(403, "Только супер-админ может удалять супер-админов");
|
||||
}
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ---------------------------- moderation ---------------------------- */
|
||||
|
||||
adminRouter.get(
|
||||
"/admin/moderation",
|
||||
requirePermission("canModerate"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { limit, offset } = parsePagination(req, 50);
|
||||
const rows = await db.query.tracks.findMany({
|
||||
where: eq(tracks.status, "pending"),
|
||||
with: trackWith,
|
||||
orderBy: [desc(tracks.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(tracks)
|
||||
.where(eq(tracks.status, "pending"));
|
||||
res.json({ items: rows.map((t) => trackDTO(t as never)), total: count });
|
||||
}),
|
||||
);
|
||||
|
||||
adminRouter.post(
|
||||
"/admin/tracks/:id/approve",
|
||||
requirePermission("canModerate"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
await db
|
||||
.update(tracks)
|
||||
.set({
|
||||
status: "approved",
|
||||
approvedAt: new Date(),
|
||||
rejectionReason: null,
|
||||
metadata: sql`coalesce(${tracks.metadata}, '{}'::jsonb) - 'processing'`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tracks.id, id));
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const rejectSchema = z.object({ reason: z.string().max(1000).optional() });
|
||||
|
||||
adminRouter.post(
|
||||
"/admin/tracks/:id/reject",
|
||||
requirePermission("canModerate"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const { reason } = parseBody(rejectSchema, req.body);
|
||||
await db
|
||||
.update(tracks)
|
||||
.set({ status: "rejected", rejectionReason: reason ?? null, updatedAt: new Date() })
|
||||
.where(eq(tracks.id, id));
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------ tracks ------------------------------ */
|
||||
|
||||
adminRouter.get(
|
||||
"/admin/tracks",
|
||||
requirePermission("canManageLibrary"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { limit, offset } = parsePagination(req, 100);
|
||||
const status = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||
const filters: any[] = [];
|
||||
if (status && ["pending", "approved", "rejected"].includes(status)) {
|
||||
filters.push(eq(tracks.status, status as "pending" | "approved" | "rejected"));
|
||||
}
|
||||
if (q) filters.push(sql`${tracks.title} ILIKE ${"%" + q + "%"}`);
|
||||
|
||||
const rows = await db.query.tracks.findMany({
|
||||
where: filters.length ? and(...filters) : undefined,
|
||||
with: trackWith,
|
||||
orderBy: [desc(tracks.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(tracks)
|
||||
.where(filters.length ? and(...filters) : undefined);
|
||||
res.json({ items: rows.map((t) => trackDTO(t as never)), total: count });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ----------------------------- playlists ----------------------------- */
|
||||
adminRouter.get(
|
||||
"/admin/playlists",
|
||||
requirePermission("canViewAllPlaylists"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { limit, offset } = parsePagination(req, 100);
|
||||
const rows = await db.query.playlists.findMany({
|
||||
with: { owner: true, entries: true },
|
||||
orderBy: [desc(playlists.updatedAt)],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
const [{ count }] = await db.select({ count: sql<number>`count(*)::int` }).from(playlists);
|
||||
res.json({ items: rows.map((p) => playlistDTO(p as never)), total: count });
|
||||
}),
|
||||
);
|
||||
60
apps/server/src/routes/albums.ts
Normal file
60
apps/server/src/routes/albums.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Router } from "express";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/index.js";
|
||||
import { trackWith } from "../db/queries.js";
|
||||
import { albums, artists, tracks } from "../db/schema.js";
|
||||
import { HttpError, asyncHandler, assertUuid, notFound, parseBody } from "../lib/http.js";
|
||||
import { hasPerm } from "../lib/permissions.js";
|
||||
import { albumDTO, trackDTO } from "../lib/presenters.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const albumsRouter = Router();
|
||||
|
||||
albumsRouter.get(
|
||||
"/albums/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const [album] = await db.select().from(albums).where(eq(albums.id, id)).limit(1);
|
||||
if (!album) throw notFound("Альбом не найден");
|
||||
|
||||
let artist = null;
|
||||
if (album.artistId) {
|
||||
[artist] = await db.select().from(artists).where(eq(artists.id, album.artistId)).limit(1);
|
||||
}
|
||||
|
||||
const rows = await db.query.tracks.findMany({
|
||||
where: and(eq(tracks.albumId, id), eq(tracks.status, "approved")),
|
||||
with: trackWith,
|
||||
orderBy: [asc(tracks.discNumber), asc(tracks.trackNumber), asc(tracks.title)],
|
||||
});
|
||||
|
||||
res.json({
|
||||
album: albumDTO({ ...album, artist: artist ?? null }),
|
||||
tracks: rows.map((t) => trackDTO(t as never)),
|
||||
canEdit: hasPerm(req.user, "canManageLibrary"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const patchSchema = z.object({
|
||||
title: z.string().min(1).max(300).optional(),
|
||||
artistId: z.string().uuid().nullable().optional(),
|
||||
releaseDate: z.string().nullable().optional(),
|
||||
coverUrl: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
albumsRouter.patch(
|
||||
"/albums/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!hasPerm(req.user, "canManageLibrary")) throw new HttpError(403, "Нет прав");
|
||||
const id = assertUuid(req.params.id);
|
||||
const data = parseBody(patchSchema, req.body);
|
||||
await db.update(albums).set({ ...data, updatedAt: new Date() }).where(eq(albums.id, id));
|
||||
const [album] = await db.select().from(albums).where(eq(albums.id, id)).limit(1);
|
||||
if (!album) throw notFound("Альбом не найден");
|
||||
res.json({ album: albumDTO(album) });
|
||||
}),
|
||||
);
|
||||
142
apps/server/src/routes/artists.ts
Normal file
142
apps/server/src/routes/artists.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Router } from "express";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/index.js";
|
||||
import { trackWith } from "../db/queries.js";
|
||||
import { albums, artistFollows, artists, trackArtists, tracks } from "../db/schema.js";
|
||||
import { asyncHandler, assertUuid, notFound, parseBody, parsePagination } from "../lib/http.js";
|
||||
import { HttpError } from "../lib/http.js";
|
||||
import { hasPerm } from "../lib/permissions.js";
|
||||
import { albumDTO, artistDTO, trackDTO } from "../lib/presenters.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const artistsRouter = Router();
|
||||
|
||||
artistsRouter.get(
|
||||
"/artists/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const [artist] = await db.select().from(artists).where(eq(artists.id, id)).limit(1);
|
||||
if (!artist) throw notFound("Артист не найден");
|
||||
|
||||
const [{ count: followersCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(artistFollows)
|
||||
.where(eq(artistFollows.artistId, id));
|
||||
|
||||
const [following] = await db
|
||||
.select()
|
||||
.from(artistFollows)
|
||||
.where(and(eq(artistFollows.artistId, id), eq(artistFollows.userId, req.user!.id)))
|
||||
.limit(1);
|
||||
|
||||
const albumRows = await db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(eq(albums.artistId, id))
|
||||
.orderBy(desc(albums.releaseDate))
|
||||
.limit(50);
|
||||
|
||||
const [{ count: trackCount }] = await db
|
||||
.select({ count: sql<number>`count(distinct ${trackArtists.trackId})::int` })
|
||||
.from(trackArtists)
|
||||
.innerJoin(tracks, eq(trackArtists.trackId, tracks.id))
|
||||
.where(and(eq(trackArtists.artistId, id), eq(tracks.status, "approved")));
|
||||
|
||||
res.json({
|
||||
artist: { ...artistDTO({ ...artist, followersCount }), trackCount },
|
||||
following: Boolean(following),
|
||||
albums: albumRows.map((a) => albumDTO({ ...a, artist })),
|
||||
canEdit: hasPerm(req.user, "canManageLibrary"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
artistsRouter.get(
|
||||
"/artists/:id/tracks",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const { limit, offset } = parsePagination(req, 50);
|
||||
const rows = await db.query.tracks.findMany({
|
||||
where: and(
|
||||
eq(tracks.status, "approved"),
|
||||
inArray(
|
||||
tracks.id,
|
||||
db.select({ id: trackArtists.trackId }).from(trackArtists).where(eq(trackArtists.artistId, id)),
|
||||
),
|
||||
),
|
||||
with: trackWith,
|
||||
orderBy: [desc(tracks.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json({ items: rows.map((t) => trackDTO(t as never)) });
|
||||
}),
|
||||
);
|
||||
|
||||
artistsRouter.get(
|
||||
"/artists/:id/albums",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const [artist] = await db.select().from(artists).where(eq(artists.id, id)).limit(1);
|
||||
if (!artist) throw notFound("Артист не найден");
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(eq(albums.artistId, id))
|
||||
.orderBy(desc(albums.releaseDate));
|
||||
res.json({ items: rows.map((a) => albumDTO({ ...a, artist })) });
|
||||
}),
|
||||
);
|
||||
|
||||
const patchSchema = z.object({
|
||||
name: z.string().min(1).max(300).optional(),
|
||||
sortName: z.string().nullable().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
imageUrl: z.string().nullable().optional(),
|
||||
country: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
artistsRouter.patch(
|
||||
"/artists/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!hasPerm(req.user, "canManageLibrary")) {
|
||||
throw new HttpError(403, "Нет прав");
|
||||
}
|
||||
const id = assertUuid(req.params.id);
|
||||
const data = parseBody(patchSchema, req.body);
|
||||
await db.update(artists).set({ ...data, updatedAt: new Date() }).where(eq(artists.id, id));
|
||||
const [artist] = await db.select().from(artists).where(eq(artists.id, id)).limit(1);
|
||||
if (!artist) throw notFound("Артист не найден");
|
||||
res.json({ artist: artistDTO(artist) });
|
||||
}),
|
||||
);
|
||||
|
||||
artistsRouter.post(
|
||||
"/artists/:id/follow",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
await db
|
||||
.insert(artistFollows)
|
||||
.values({ artistId: id, userId: req.user!.id })
|
||||
.onConflictDoNothing();
|
||||
res.json({ following: true });
|
||||
}),
|
||||
);
|
||||
|
||||
artistsRouter.delete(
|
||||
"/artists/:id/follow",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
await db
|
||||
.delete(artistFollows)
|
||||
.where(and(eq(artistFollows.artistId, id), eq(artistFollows.userId, req.user!.id)));
|
||||
res.json({ following: false });
|
||||
}),
|
||||
);
|
||||
68
apps/server/src/routes/auth.ts
Normal file
68
apps/server/src/routes/auth.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from "express";
|
||||
import { eq, or, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { asyncHandler, unauthorized } from "../lib/http.js";
|
||||
import { verifyPassword } from "../lib/password.js";
|
||||
import { selfUser } from "../lib/presenters.js";
|
||||
import {
|
||||
SESSION_COOKIE,
|
||||
createSession,
|
||||
destroySession,
|
||||
sessionCookieOptions,
|
||||
} from "../lib/session.js";
|
||||
import { parseBody } from "../lib/http.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const loginSchema = z.object({
|
||||
login: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
authRouter.post(
|
||||
"/login",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { login, password } = parseBody(loginSchema, req.body);
|
||||
const value = login.trim().toLowerCase();
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(or(sql`lower(${users.email}) = ${value}`, sql`lower(${users.username}) = ${value}`))
|
||||
.limit(1);
|
||||
|
||||
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||
throw unauthorized("Неверная почта/логин или пароль");
|
||||
}
|
||||
|
||||
const sid = await createSession(user.id);
|
||||
res.cookie(SESSION_COOKIE, sid, sessionCookieOptions());
|
||||
res.json({ user: selfUser(user) });
|
||||
}),
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/logout",
|
||||
asyncHandler(async (req, res) => {
|
||||
await destroySession(req.cookies?.[SESSION_COOKIE]);
|
||||
res.clearCookie(SESSION_COOKIE, { path: "/" });
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
authRouter.get(
|
||||
"/me",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ user: selfUser(req.user!) });
|
||||
}),
|
||||
);
|
||||
|
||||
authRouter.get(
|
||||
"/session",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ user: req.user ? selfUser(req.user) : null });
|
||||
}),
|
||||
);
|
||||
132
apps/server/src/routes/comments.ts
Normal file
132
apps/server/src/routes/comments.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Router } from "express";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/index.js";
|
||||
import { comments } from "../db/schema.js";
|
||||
import { HttpError, asyncHandler, assertUuid, notFound, parseBody } from "../lib/http.js";
|
||||
import { hasPerm } from "../lib/permissions.js";
|
||||
import { commentDTO, type CommentDTO } from "../lib/presenters.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const commentsRouter = Router();
|
||||
|
||||
commentsRouter.get(
|
||||
"/tracks/:id/comments",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const trackId = assertUuid(req.params.id);
|
||||
const rows = await db.query.comments.findMany({
|
||||
where: eq(comments.trackId, trackId),
|
||||
with: { user: true },
|
||||
orderBy: [asc(comments.createdAt)],
|
||||
});
|
||||
|
||||
const map = new Map<string, CommentDTO>();
|
||||
const roots: CommentDTO[] = [];
|
||||
for (const row of rows) {
|
||||
map.set(row.id, commentDTO(row as never));
|
||||
}
|
||||
for (const row of rows) {
|
||||
const dto = map.get(row.id)!;
|
||||
if (row.parentId && map.has(row.parentId)) {
|
||||
map.get(row.parentId)!.replies.push(dto);
|
||||
} else {
|
||||
roots.push(dto);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ comments: roots, total: rows.length });
|
||||
}),
|
||||
);
|
||||
|
||||
const createSchema = z.object({
|
||||
body: z.string().min(1).max(4000),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
commentsRouter.post(
|
||||
"/tracks/:id/comments",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const trackId = assertUuid(req.params.id);
|
||||
const data = parseBody(createSchema, req.body);
|
||||
|
||||
if (data.parentId) {
|
||||
const [parent] = await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(and(eq(comments.id, data.parentId), eq(comments.trackId, trackId)))
|
||||
.limit(1);
|
||||
if (!parent) throw notFound("Комментарий, на который вы отвечаете, не найден");
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(comments)
|
||||
.values({
|
||||
trackId,
|
||||
userId: req.user!.id,
|
||||
parentId: data.parentId ?? null,
|
||||
body: data.body,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const full = await db.query.comments.findFirst({
|
||||
where: eq(comments.id, created.id),
|
||||
with: { user: true },
|
||||
});
|
||||
res.status(201).json({ comment: commentDTO(full as never) });
|
||||
}),
|
||||
);
|
||||
|
||||
const patchSchema = z.object({ body: z.string().min(1).max(4000) });
|
||||
|
||||
commentsRouter.patch(
|
||||
"/comments/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const [comment] = await db.select().from(comments).where(eq(comments.id, id)).limit(1);
|
||||
if (!comment) throw notFound("Комментарий не найден");
|
||||
const canEdit = comment.userId === req.user!.id || hasPerm(req.user, "canManageLibrary");
|
||||
if (!canEdit) throw new HttpError(403, "Нет прав");
|
||||
|
||||
const { body } = parseBody(patchSchema, req.body);
|
||||
await db
|
||||
.update(comments)
|
||||
.set({ body, updatedAt: new Date() })
|
||||
.where(eq(comments.id, id));
|
||||
const full = await db.query.comments.findFirst({
|
||||
where: eq(comments.id, id),
|
||||
with: { user: true },
|
||||
});
|
||||
res.json({ comment: commentDTO(full as never) });
|
||||
}),
|
||||
);
|
||||
|
||||
commentsRouter.delete(
|
||||
"/comments/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const [comment] = await db.select().from(comments).where(eq(comments.id, id)).limit(1);
|
||||
if (!comment) throw notFound("Комментарий не найден");
|
||||
const canDelete =
|
||||
comment.userId === req.user!.id ||
|
||||
hasPerm(req.user, "canManageLibrary") ||
|
||||
hasPerm(req.user, "canModerate");
|
||||
if (!canDelete) throw new HttpError(403, "Нет прав");
|
||||
|
||||
// Soft-delete when it has replies so the thread stays readable
|
||||
const replies = await db
|
||||
.select({ id: comments.id })
|
||||
.from(comments)
|
||||
.where(eq(comments.parentId, id))
|
||||
.limit(1);
|
||||
if (replies.length) {
|
||||
await db.update(comments).set({ deleted: true, body: "" }).where(eq(comments.id, id));
|
||||
} else {
|
||||
await db.delete(comments).where(eq(comments.id, id));
|
||||
}
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
30
apps/server/src/routes/index.ts
Normal file
30
apps/server/src/routes/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Router } from "express";
|
||||
import { adminRouter } from "./admin.js";
|
||||
import { albumsRouter } from "./albums.js";
|
||||
import { artistsRouter } from "./artists.js";
|
||||
import { authRouter } from "./auth.js";
|
||||
import { commentsRouter } from "./comments.js";
|
||||
import { libraryRouter } from "./library.js";
|
||||
import { mediaRouter } from "./media.js";
|
||||
import { meRouter } from "./me.js";
|
||||
import { playlistsRouter } from "./playlists.js";
|
||||
import { searchRouter } from "./search.js";
|
||||
import { tracksRouter } from "./tracks.js";
|
||||
import { uploadsRouter } from "./uploads.js";
|
||||
import { usersRouter } from "./users.js";
|
||||
|
||||
export const apiRouter = Router();
|
||||
|
||||
apiRouter.use("/auth", authRouter);
|
||||
apiRouter.use(meRouter);
|
||||
apiRouter.use(mediaRouter);
|
||||
apiRouter.use(tracksRouter);
|
||||
apiRouter.use(commentsRouter);
|
||||
apiRouter.use(uploadsRouter);
|
||||
apiRouter.use(artistsRouter);
|
||||
apiRouter.use(albumsRouter);
|
||||
apiRouter.use(playlistsRouter);
|
||||
apiRouter.use(libraryRouter);
|
||||
apiRouter.use(searchRouter);
|
||||
apiRouter.use(usersRouter);
|
||||
apiRouter.use(adminRouter);
|
||||
80
apps/server/src/routes/library.ts
Normal file
80
apps/server/src/routes/library.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Router } from "express";
|
||||
import { desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { trackWith } from "../db/queries.js";
|
||||
import {
|
||||
albums,
|
||||
artistFollows,
|
||||
artists,
|
||||
libraryPlaylists,
|
||||
playlists,
|
||||
trackLikes,
|
||||
tracks,
|
||||
} from "../db/schema.js";
|
||||
import { asyncHandler } from "../lib/http.js";
|
||||
import { albumDTO, artistDTO, playlistDTO, trackDTO } from "../lib/presenters.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const libraryRouter = Router();
|
||||
|
||||
libraryRouter.get(
|
||||
"/library",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Owned playlists
|
||||
const ownedRows = await db.query.playlists.findMany({
|
||||
where: eq(playlists.ownerId, userId),
|
||||
with: { owner: true, entries: true },
|
||||
orderBy: [sql`${playlists.updatedAt} DESC`],
|
||||
});
|
||||
|
||||
// Library (added) playlists
|
||||
const libRows = await db
|
||||
.select({
|
||||
mode: libraryPlaylists.mode,
|
||||
canEdit: libraryPlaylists.canEdit,
|
||||
copiedPlaylistId: libraryPlaylists.copiedPlaylistId,
|
||||
playlist: playlists,
|
||||
})
|
||||
.from(libraryPlaylists)
|
||||
.innerJoin(playlists, eq(libraryPlaylists.playlistId, playlists.id))
|
||||
.where(eq(libraryPlaylists.userId, userId))
|
||||
.orderBy(desc(libraryPlaylists.createdAt));
|
||||
|
||||
// Followed artists
|
||||
const followRows = await db
|
||||
.select({ artist: artists })
|
||||
.from(artistFollows)
|
||||
.innerJoin(artists, eq(artistFollows.artistId, artists.id))
|
||||
.where(eq(artistFollows.userId, userId))
|
||||
.orderBy(desc(artistFollows.createdAt));
|
||||
|
||||
// Liked tracks
|
||||
const likedRows = await db.query.tracks.findMany({
|
||||
where: inArray(
|
||||
tracks.id,
|
||||
db.select({ id: trackLikes.trackId }).from(trackLikes).where(eq(trackLikes.userId, userId)),
|
||||
),
|
||||
with: trackWith,
|
||||
orderBy: [desc(tracks.createdAt)],
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
res.json({
|
||||
ownedPlaylists: ownedRows.map((p) => playlistDTO(p as never, { isOwner: true, canEdit: true })),
|
||||
savedPlaylists: libRows.map((r) => ({
|
||||
mode: r.mode,
|
||||
canEdit: r.canEdit,
|
||||
originalPlaylistId: r.playlist.id,
|
||||
playlist: playlistDTO(r.playlist as never, {
|
||||
canEdit: r.canEdit || r.playlist.ownerId === userId,
|
||||
isOwner: r.playlist.ownerId === userId,
|
||||
}),
|
||||
})),
|
||||
followedArtists: followRows.map((r) => artistDTO(r.artist)),
|
||||
likedTracks: likedRows.map((t) => trackDTO(t as never)),
|
||||
});
|
||||
}),
|
||||
);
|
||||
89
apps/server/src/routes/me.ts
Normal file
89
apps/server/src/routes/me.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { asyncHandler, badRequest, parseBody } from "../lib/http.js";
|
||||
import { hashPassword, verifyPassword } from "../lib/password.js";
|
||||
import { selfUser } from "../lib/presenters.js";
|
||||
import { destroyAllUserSessions, SESSION_COOKIE, createSession, sessionCookieOptions } from "../lib/session.js";
|
||||
import { putBuffer } from "../lib/storage.js";
|
||||
import { mediaUrl } from "../services/trackIngest.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const meRouter = Router();
|
||||
|
||||
const patchSchema = z.object({
|
||||
displayName: z.string().max(120).nullable().optional(),
|
||||
bio: z.string().max(2000).nullable().optional(),
|
||||
avatarUrl: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
meRouter.patch(
|
||||
"/me",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const data = parseBody(patchSchema, req.body);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(users.id, req.user!.id));
|
||||
const [user] = await db.select().from(users).where(eq(users.id, req.user!.id)).limit(1);
|
||||
res.json({ user: selfUser(user) });
|
||||
}),
|
||||
);
|
||||
|
||||
const passwordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(6).max(200),
|
||||
});
|
||||
|
||||
meRouter.post(
|
||||
"/me/password",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { currentPassword, newPassword } = parseBody(passwordSchema, req.body);
|
||||
const ok = await verifyPassword(currentPassword, req.user!.passwordHash);
|
||||
if (!ok) throw badRequest("Текущий пароль неверен");
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, req.user!.id));
|
||||
|
||||
// invalidate all sessions, then re-issue one for the current client
|
||||
await destroyAllUserSessions(req.user!.id);
|
||||
const sid = await createSession(req.user!.id);
|
||||
res.cookie(SESSION_COOKIE, sid, sessionCookieOptions());
|
||||
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const avatarUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith("image/")) cb(null, true);
|
||||
else cb(new Error("Только изображения"));
|
||||
},
|
||||
});
|
||||
|
||||
meRouter.post(
|
||||
"/me/avatar",
|
||||
requireAuth,
|
||||
avatarUpload.single("avatar"),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.file) throw badRequest("Файл не загружен");
|
||||
const ext = (req.file.mimetype.split("/")[1] ?? "png").replace("jpeg", "jpg");
|
||||
const key = `avatars/${req.user!.id}/${randomUUID()}.${ext}`;
|
||||
await putBuffer(key, req.file.buffer, req.file.mimetype);
|
||||
const avatarUrl = mediaUrl(key);
|
||||
await db.update(users).set({ avatarUrl, updatedAt: new Date() }).where(eq(users.id, req.user!.id));
|
||||
const [user] = await db.select().from(users).where(eq(users.id, req.user!.id)).limit(1);
|
||||
res.json({ user: selfUser(user) });
|
||||
}),
|
||||
);
|
||||
28
apps/server/src/routes/media.ts
Normal file
28
apps/server/src/routes/media.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Router } from "express";
|
||||
import { BUCKET, minio, statObject } from "../lib/storage.js";
|
||||
import { asyncHandler, notFound } from "../lib/http.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const mediaRouter = Router();
|
||||
|
||||
mediaRouter.get(
|
||||
"/media/*",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const key = decodeURIComponent((req.params as Record<string, string>)[0] ?? "");
|
||||
if (!key || key.includes("..")) throw notFound("Файл не найден");
|
||||
const stat = await statObject(key);
|
||||
if (!stat) throw notFound("Файл не найден");
|
||||
|
||||
res.setHeader("Content-Type", stat.contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
res.setHeader("Content-Length", stat.size);
|
||||
|
||||
const stream = await minio.getObject(BUCKET, key);
|
||||
stream.on("error", () => {
|
||||
if (!res.headersSent) res.status(500).end();
|
||||
else res.end();
|
||||
});
|
||||
stream.pipe(res);
|
||||
}),
|
||||
);
|
||||
450
apps/server/src/routes/playlists.ts
Normal file
450
apps/server/src/routes/playlists.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { Router } from "express";
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/index.js";
|
||||
import { trackWith } from "../db/queries.js";
|
||||
import {
|
||||
libraryPlaylists,
|
||||
playlistCollaborators,
|
||||
playlistTracks,
|
||||
playlists,
|
||||
tracks,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import {
|
||||
HttpError,
|
||||
asyncHandler,
|
||||
assertUuid,
|
||||
conflict,
|
||||
forbidden,
|
||||
notFound,
|
||||
parseBody,
|
||||
parsePagination,
|
||||
badRequest,
|
||||
} from "../lib/http.js";
|
||||
import { hasPerm } from "../lib/permissions.js";
|
||||
import { playlistDTO, publicUser, trackDTO } from "../lib/presenters.js";
|
||||
import { canEditPlaylist, canViewPlaylist, getPlaylist } from "../services/access.js";
|
||||
import { requireAuth, requirePermission } from "../middleware/auth.js";
|
||||
|
||||
export const playlistsRouter = Router();
|
||||
|
||||
async function loadPlaylistFull(id: string) {
|
||||
const playlist = await db.query.playlists.findFirst({
|
||||
where: eq(playlists.id, id),
|
||||
with: { owner: true, collaborators: true },
|
||||
});
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
return playlist;
|
||||
}
|
||||
|
||||
async function entriesFor(playlistId: string) {
|
||||
const rows = await db.query.playlistTracks.findMany({
|
||||
where: eq(playlistTracks.playlistId, playlistId),
|
||||
with: { track: { with: trackWith }, addedBy: true },
|
||||
orderBy: [asc(playlistTracks.position), asc(playlistTracks.createdAt)],
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
entryId: r.id,
|
||||
position: r.position,
|
||||
addedBy: publicUser(r.addedBy),
|
||||
track: r.track ? trackDTO(r.track as never) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/* --------------------------- create / list --------------------------- */
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
visibility: z.enum(["public", "unlisted", "private"]).optional(),
|
||||
isShareable: z.boolean().optional(),
|
||||
allowCollaboratorEdit: z.boolean().optional(),
|
||||
});
|
||||
|
||||
playlistsRouter.post(
|
||||
"/playlists",
|
||||
requirePermission("canCreatePlaylists"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const data = parseBody(createSchema, req.body);
|
||||
const [playlist] = await db
|
||||
.insert(playlists)
|
||||
.values({
|
||||
ownerId: req.user!.id,
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
visibility: data.visibility ?? "private",
|
||||
isShareable: data.isShareable ?? false,
|
||||
allowCollaboratorEdit: data.allowCollaboratorEdit ?? true,
|
||||
})
|
||||
.returning();
|
||||
res.status(201).json({ playlist: playlistDTO({ ...playlist, owner: req.user }, { isOwner: true, canEdit: true, trackCount: 0 }) });
|
||||
}),
|
||||
);
|
||||
|
||||
playlistsRouter.get(
|
||||
"/me/playlists",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const owned = await db.query.playlists.findMany({
|
||||
where: eq(playlists.ownerId, req.user!.id),
|
||||
with: { owner: true, entries: true },
|
||||
orderBy: [sql`${playlists.updatedAt} DESC`],
|
||||
});
|
||||
res.json({
|
||||
owned: owned.map((p) => playlistDTO(p as never, { isOwner: true, canEdit: true })),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------- single ------------------------------- */
|
||||
|
||||
playlistsRouter.get(
|
||||
"/playlists/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await loadPlaylistFull(id);
|
||||
if (!(await canViewPlaylist(req.user, playlist))) throw notFound("Плейлист не найден");
|
||||
|
||||
const [libEntry] = await db
|
||||
.select()
|
||||
.from(libraryPlaylists)
|
||||
.where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id)))
|
||||
.limit(1);
|
||||
|
||||
const entries = await entriesFor(id);
|
||||
const canEdit = await canEditPlaylist(req.user, playlist);
|
||||
|
||||
res.json({
|
||||
playlist: playlistDTO(playlist as never, {
|
||||
canEdit,
|
||||
isOwner: playlist.ownerId === req.user!.id,
|
||||
trackCount: entries.length,
|
||||
}),
|
||||
entries,
|
||||
collaborators: playlist.collaborators.map((c) => ({ userId: c.userId, canEdit: c.canEdit })),
|
||||
inLibrary: Boolean(libEntry),
|
||||
libraryMode: libEntry?.mode ?? null,
|
||||
libraryCanEdit: libEntry?.canEdit ?? false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const patchSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
coverUrl: z.string().nullable().optional(),
|
||||
visibility: z.enum(["public", "unlisted", "private"]).optional(),
|
||||
isShareable: z.boolean().optional(),
|
||||
allowCollaboratorEdit: z.boolean().optional(),
|
||||
});
|
||||
|
||||
playlistsRouter.patch(
|
||||
"/playlists/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await getPlaylist(id);
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
if (!(await canEditPlaylist(req.user, playlist))) throw forbidden("Нет прав на редактирование");
|
||||
|
||||
const data = parseBody(patchSchema, req.body);
|
||||
await db.update(playlists).set({ ...data, updatedAt: new Date() }).where(eq(playlists.id, id));
|
||||
const updated = await loadPlaylistFull(id);
|
||||
res.json({
|
||||
playlist: playlistDTO(updated as never, {
|
||||
canEdit: true,
|
||||
isOwner: updated.ownerId === req.user!.id,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
playlistsRouter.delete(
|
||||
"/playlists/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await getPlaylist(id);
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
if (playlist.ownerId !== req.user!.id && !hasPerm(req.user, "canManageLibrary")) {
|
||||
throw forbidden("Нет прав на удаление");
|
||||
}
|
||||
await db.delete(playlists).where(eq(playlists.id, id));
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------- tracks ------------------------------- */
|
||||
|
||||
const addTrackSchema = z.object({
|
||||
trackId: z.string().uuid(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
playlistsRouter.post(
|
||||
"/playlists/:id/tracks",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await getPlaylist(id);
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
if (!(await canEditPlaylist(req.user, playlist))) throw forbidden("Нет прав на редактирование");
|
||||
|
||||
const { trackId, position } = parseBody(addTrackSchema, req.body);
|
||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
|
||||
if (!track || (track.status !== "approved" && track.uploadedById !== req.user!.id)) {
|
||||
throw notFound("Трек не найден");
|
||||
}
|
||||
|
||||
let pos = position;
|
||||
if (pos === undefined) {
|
||||
const [{ max }] = await db
|
||||
.select({ max: sql<number>`coalesce(max(${playlistTracks.position}), -1)::int` })
|
||||
.from(playlistTracks)
|
||||
.where(eq(playlistTracks.playlistId, id));
|
||||
pos = max + 1;
|
||||
} else {
|
||||
await db
|
||||
.update(playlistTracks)
|
||||
.set({ position: sql`${playlistTracks.position} + 1` })
|
||||
.where(and(eq(playlistTracks.playlistId, id), sql`${playlistTracks.position} >= ${pos}`));
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.insert(playlistTracks)
|
||||
.values({ playlistId: id, trackId, position: pos, addedById: req.user!.id })
|
||||
.returning();
|
||||
|
||||
await db.update(playlists).set({ updatedAt: new Date() }).where(eq(playlists.id, id));
|
||||
res.status(201).json({ entryId: entry.id, position: entry.position });
|
||||
}),
|
||||
);
|
||||
|
||||
playlistsRouter.delete(
|
||||
"/playlists/:id/tracks/:entryId",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await getPlaylist(id);
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
if (!(await canEditPlaylist(req.user, playlist))) throw forbidden("Нет прав на редактирование");
|
||||
|
||||
await db
|
||||
.delete(playlistTracks)
|
||||
.where(and(eq(playlistTracks.id, assertUuid(req.params.entryId)), eq(playlistTracks.playlistId, id)));
|
||||
await db.update(playlists).set({ updatedAt: new Date() }).where(eq(playlists.id, id));
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const orderSchema = z.object({ entryIds: z.array(z.string().uuid()) });
|
||||
|
||||
playlistsRouter.put(
|
||||
"/playlists/:id/order",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await getPlaylist(id);
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
if (!(await canEditPlaylist(req.user, playlist))) throw forbidden("Нет прав на редактирование");
|
||||
|
||||
const { entryIds } = parseBody(orderSchema, req.body);
|
||||
await db.transaction(async (tx) => {
|
||||
for (let i = 0; i < entryIds.length; i++) {
|
||||
await tx
|
||||
.update(playlistTracks)
|
||||
.set({ position: i })
|
||||
.where(and(eq(playlistTracks.id, entryIds[i]), eq(playlistTracks.playlistId, id)));
|
||||
}
|
||||
await tx.update(playlists).set({ updatedAt: new Date() }).where(eq(playlists.id, id));
|
||||
});
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ------------------------------ library ------------------------------ */
|
||||
|
||||
const librarySchema = z.object({
|
||||
mode: z.enum(["reference", "copy"]).default("reference"),
|
||||
});
|
||||
|
||||
playlistsRouter.post(
|
||||
"/playlists/:id/library",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await getPlaylist(id);
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
if (!(await canViewPlaylist(req.user, playlist))) throw notFound("Плейлист не найден");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(libraryPlaylists)
|
||||
.where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id)))
|
||||
.limit(1);
|
||||
if (existing) throw conflict("Плейлист уже в библиотеке");
|
||||
|
||||
const { mode } = parseBody(librarySchema, req.body);
|
||||
|
||||
if (mode === "copy") {
|
||||
const sourceEntries = await db
|
||||
.select()
|
||||
.from(playlistTracks)
|
||||
.where(eq(playlistTracks.playlistId, id))
|
||||
.orderBy(asc(playlistTracks.position));
|
||||
|
||||
const copy = await db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(playlists)
|
||||
.values({
|
||||
ownerId: req.user!.id,
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
coverUrl: playlist.coverUrl,
|
||||
visibility: "private",
|
||||
isShareable: false,
|
||||
allowCollaboratorEdit: false,
|
||||
})
|
||||
.returning();
|
||||
if (sourceEntries.length) {
|
||||
await tx.insert(playlistTracks).values(
|
||||
sourceEntries.map((e, i) => ({
|
||||
playlistId: created.id,
|
||||
trackId: e.trackId,
|
||||
position: i,
|
||||
addedById: req.user!.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
await tx.insert(libraryPlaylists).values({
|
||||
userId: req.user!.id,
|
||||
playlistId: id,
|
||||
mode: "copy",
|
||||
copiedPlaylistId: created.id,
|
||||
canEdit: true,
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
mode: "copy",
|
||||
copiedPlaylistId: copy.id,
|
||||
playlist: playlistDTO({ ...copy, owner: req.user }, { isOwner: true, canEdit: true }),
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(libraryPlaylists).values({
|
||||
userId: req.user!.id,
|
||||
playlistId: id,
|
||||
mode: "reference",
|
||||
canEdit: playlist.allowCollaboratorEdit,
|
||||
});
|
||||
res.status(201).json({ mode: "reference", canEdit: playlist.allowCollaboratorEdit });
|
||||
}),
|
||||
);
|
||||
|
||||
playlistsRouter.delete(
|
||||
"/playlists/:id/library",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(libraryPlaylists)
|
||||
.where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id)))
|
||||
.limit(1);
|
||||
if (entry?.mode === "copy" && entry.copiedPlaylistId) {
|
||||
// remove the personal copy too
|
||||
await db
|
||||
.delete(libraryPlaylists)
|
||||
.where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id)));
|
||||
await db.delete(playlists).where(eq(playlists.id, entry.copiedPlaylistId));
|
||||
} else {
|
||||
await db
|
||||
.delete(libraryPlaylists)
|
||||
.where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id)));
|
||||
}
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ---------------------------- collaborators ---------------------------- */
|
||||
|
||||
playlistsRouter.get(
|
||||
"/playlists/:id/collaborators",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await loadPlaylistFull(id);
|
||||
if (!(await canViewPlaylist(req.user, playlist))) throw notFound("Плейлист не найден");
|
||||
const ids = playlist.collaborators.map((c) => c.userId);
|
||||
const usersRows = ids.length
|
||||
? await db.select().from(users).where(inArray(users.id, ids))
|
||||
: [];
|
||||
const byId = new Map(usersRows.map((u) => [u.id, u]));
|
||||
res.json({
|
||||
collaborators: playlist.collaborators.map((c) => ({
|
||||
canEdit: c.canEdit,
|
||||
user: publicUser(byId.get(c.userId)),
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const collabSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
canEdit: z.boolean().default(true),
|
||||
});
|
||||
|
||||
playlistsRouter.post(
|
||||
"/playlists/:id/collaborators",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await getPlaylist(id);
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
if (playlist.ownerId !== req.user!.id && !hasPerm(req.user, "canManageLibrary")) {
|
||||
throw forbidden("Только владелец может добавлять соавторов");
|
||||
}
|
||||
const { username, canEdit } = parseBody(collabSchema, req.body);
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(sql`lower(${users.username}) = ${username.toLowerCase()}`)
|
||||
.limit(1);
|
||||
if (!user) throw notFound("Пользователь не найден");
|
||||
|
||||
await db
|
||||
.insert(playlistCollaborators)
|
||||
.values({ playlistId: id, userId: user.id, canEdit })
|
||||
.onConflictDoUpdate({
|
||||
target: [playlistCollaborators.playlistId, playlistCollaborators.userId],
|
||||
set: { canEdit },
|
||||
});
|
||||
res.status(201).json({ user: publicUser(user), canEdit });
|
||||
}),
|
||||
);
|
||||
|
||||
playlistsRouter.delete(
|
||||
"/playlists/:id/collaborators/:userId",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = assertUuid(req.params.id);
|
||||
const playlist = await getPlaylist(id);
|
||||
if (!playlist) throw notFound("Плейлист не найден");
|
||||
if (playlist.ownerId !== req.user!.id && !hasPerm(req.user, "canManageLibrary")) {
|
||||
throw forbidden("Только владелец может удалять соавторов");
|
||||
}
|
||||
await db
|
||||
.delete(playlistCollaborators)
|
||||
.where(
|
||||
and(
|
||||
eq(playlistCollaborators.playlistId, id),
|
||||
eq(playlistCollaborators.userId, assertUuid(req.params.userId)),
|
||||
),
|
||||
);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
68
apps/server/src/routes/search.ts
Normal file
68
apps/server/src/routes/search.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from "express";
|
||||
import { and, eq, ne, or, sql } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { trackWith } from "../db/queries.js";
|
||||
import { albums, artists, playlists, tracks, users } from "../db/schema.js";
|
||||
import { asyncHandler } from "../lib/http.js";
|
||||
import { albumDTO, artistDTO, playlistDTO, publicUser, trackDTO } from "../lib/presenters.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const searchRouter = Router();
|
||||
|
||||
searchRouter.get(
|
||||
"/search",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||
const type = typeof req.query.type === "string" ? req.query.type : "all";
|
||||
if (q.length < 1) {
|
||||
return res.json({ tracks: [], artists: [], albums: [], playlists: [], users: [] });
|
||||
}
|
||||
const like = `%${q}%`;
|
||||
const want = (t: string) => type === "all" || type === t;
|
||||
|
||||
const [trackRows, artistRows, albumRows, playlistRows, userRows] = await Promise.all([
|
||||
want("tracks")
|
||||
? db.query.tracks.findMany({
|
||||
where: and(
|
||||
eq(tracks.status, "approved"),
|
||||
or(sql`${tracks.title} ILIKE ${like}`),
|
||||
),
|
||||
with: trackWith,
|
||||
limit: 20,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
want("artists")
|
||||
? db.select().from(artists).where(sql`${artists.name} ILIKE ${like}`).limit(20)
|
||||
: Promise.resolve([]),
|
||||
want("albums")
|
||||
? db.select().from(albums).where(sql`${albums.title} ILIKE ${like}`).limit(20)
|
||||
: Promise.resolve([]),
|
||||
want("playlists")
|
||||
? db.query.playlists.findMany({
|
||||
where: and(
|
||||
ne(playlists.visibility, "private"),
|
||||
sql`${playlists.title} ILIKE ${like}`,
|
||||
),
|
||||
with: { owner: true, entries: true },
|
||||
limit: 20,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
want("users")
|
||||
? db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(or(sql`${users.username} ILIKE ${like}`, sql`${users.displayName} ILIKE ${like}`))
|
||||
.limit(20)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
tracks: trackRows.map((t) => trackDTO(t as never)),
|
||||
artists: artistRows.map((a) => artistDTO(a)),
|
||||
albums: albumRows.map((a) => albumDTO(a)),
|
||||
playlists: playlistRows.map((p) => playlistDTO(p as never)),
|
||||
users: userRows.map((u) => publicUser(u)),
|
||||
});
|
||||
}),
|
||||
);
|
||||
311
apps/server/src/routes/tracks.ts
Normal file
311
apps/server/src/routes/tracks.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { Router } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/index.js";
|
||||
import { albums, trackArtists, trackLikes, tracks } from "../db/schema.js";
|
||||
import { canViewTrack } from "../services/access.js";
|
||||
import {
|
||||
HttpError,
|
||||
asyncHandler,
|
||||
assertUuid,
|
||||
notFound,
|
||||
parseBody,
|
||||
parsePagination,
|
||||
} from "../lib/http.js";
|
||||
import { hasPerm } from "../lib/permissions.js";
|
||||
import { trackDTO } from "../lib/presenters.js";
|
||||
import { trackWith } from "../db/queries.js";
|
||||
import { removeObject, BUCKET, minio, putBuffer, statObject } from "../lib/storage.js";
|
||||
import { mediaUrl } from "../services/trackIngest.js";
|
||||
import { findOrCreateArtist } from "../services/trackIngest.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const tracksRouter = Router();
|
||||
|
||||
const trackInclude = trackWith;
|
||||
|
||||
async function loadTrack(id: string) {
|
||||
const track = await db.query.tracks.findFirst({
|
||||
where: eq(tracks.id, id),
|
||||
with: trackInclude,
|
||||
});
|
||||
if (!track) throw notFound("Трек не найден");
|
||||
return track;
|
||||
}
|
||||
|
||||
/* ----------------------------- list ----------------------------- */
|
||||
|
||||
tracksRouter.get(
|
||||
"/tracks",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { limit, offset } = parsePagination(req);
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||
const artistId = typeof req.query.artistId === "string" ? req.query.artistId : undefined;
|
||||
const albumId = typeof req.query.albumId === "string" ? req.query.albumId : undefined;
|
||||
|
||||
const filters = [eq(tracks.status, "approved")];
|
||||
if (q) filters.push(sql`${tracks.title} ILIKE ${"%" + q + "%"}`);
|
||||
if (artistId) filters.push(eq(tracks.artistId, assertUuid(artistId)));
|
||||
if (albumId) filters.push(eq(tracks.albumId, assertUuid(albumId)));
|
||||
|
||||
const rows = await db.query.tracks.findMany({
|
||||
where: and(...filters),
|
||||
with: trackInclude,
|
||||
orderBy: [desc(tracks.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(tracks)
|
||||
.where(and(...filters));
|
||||
|
||||
res.json({ items: rows.map((t) => trackDTO(t as never)), total: count });
|
||||
}),
|
||||
);
|
||||
|
||||
tracksRouter.get(
|
||||
"/tracks/random",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 20, 60);
|
||||
const rows = await db.query.tracks.findMany({
|
||||
where: eq(tracks.status, "approved"),
|
||||
with: trackInclude,
|
||||
orderBy: sql`random()`,
|
||||
limit,
|
||||
});
|
||||
res.json({ items: rows.map((t) => trackDTO(t as never)) });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ----------------------------- single ----------------------------- */
|
||||
|
||||
tracksRouter.get(
|
||||
"/tracks/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const track = await loadTrack(assertUuid(req.params.id));
|
||||
if (!canViewTrack(req.user, track)) throw notFound("Трек не найден");
|
||||
|
||||
const [liked] = await db
|
||||
.select()
|
||||
.from(trackLikes)
|
||||
.where(and(eq(trackLikes.trackId, track.id), eq(trackLikes.userId, req.user!.id)))
|
||||
.limit(1);
|
||||
|
||||
res.json({
|
||||
track: trackDTO(track as never),
|
||||
lyrics: {
|
||||
plain: track.lyricsPlain,
|
||||
synced: track.lyricsSynced,
|
||||
source: track.lyricsSource,
|
||||
},
|
||||
liked: Boolean(liked),
|
||||
canEdit: track.uploadedById === req.user!.id || hasPerm(req.user, "canManageLibrary"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
tracksRouter.get(
|
||||
"/tracks/:id/lyrics",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const track = await loadTrack(assertUuid(req.params.id));
|
||||
if (!canViewTrack(req.user, track)) throw notFound("Трек не найден");
|
||||
res.json({
|
||||
plain: track.lyricsPlain,
|
||||
synced: track.lyricsSynced,
|
||||
source: track.lyricsSource,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const patchSchema = z.object({
|
||||
title: z.string().min(1).max(300).optional(),
|
||||
artistId: z.string().uuid().nullable().optional(),
|
||||
albumId: z.string().uuid().nullable().optional(),
|
||||
trackNumber: z.number().int().nullable().optional(),
|
||||
discNumber: z.number().int().nullable().optional(),
|
||||
releaseDate: z.string().nullable().optional(),
|
||||
coverUrl: z.string().nullable().optional(),
|
||||
durationMs: z.number().int().nullable().optional(),
|
||||
lyricsPlain: z.string().nullable().optional(),
|
||||
lyricsSynced: z
|
||||
.array(z.object({ timeMs: z.number(), text: z.string() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
artists: z.array(z.string().min(1).max(300)).optional(),
|
||||
});
|
||||
|
||||
tracksRouter.patch(
|
||||
"/tracks/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const track = await loadTrack(assertUuid(req.params.id));
|
||||
const canEdit = track.uploadedById === req.user!.id || hasPerm(req.user, "canManageLibrary");
|
||||
if (!canEdit) throw new HttpError(403, "Нет прав на редактирование трека");
|
||||
|
||||
const { artists: artistNames, ...data } = parseBody(patchSchema, req.body);
|
||||
await db
|
||||
.update(tracks)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(tracks.id, track.id));
|
||||
|
||||
if (artistNames) {
|
||||
const resolved: { trackId: string; artistId: string; position: number; role: string }[] = [];
|
||||
for (let i = 0; i < artistNames.length; i++) {
|
||||
const id = await findOrCreateArtist(artistNames[i], {});
|
||||
resolved.push({ trackId: track.id, artistId: id, position: i, role: i === 0 ? "main" : "featured" });
|
||||
}
|
||||
await db.delete(trackArtists).where(eq(trackArtists.trackId, track.id));
|
||||
if (resolved.length) {
|
||||
await db.insert(trackArtists).values(resolved);
|
||||
await db
|
||||
.update(tracks)
|
||||
.set({ artistId: resolved[0].artistId, updatedAt: new Date() })
|
||||
.where(eq(tracks.id, track.id));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ track: trackDTO((await loadTrack(track.id)) as never) });
|
||||
}),
|
||||
);
|
||||
|
||||
tracksRouter.delete(
|
||||
"/tracks/:id",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const track = await loadTrack(assertUuid(req.params.id));
|
||||
const canEdit = track.uploadedById === req.user!.id || hasPerm(req.user, "canManageLibrary");
|
||||
if (!canEdit) throw new HttpError(403, "Нет прав на удаление трека");
|
||||
|
||||
await db.delete(tracks).where(eq(tracks.id, track.id));
|
||||
await removeObject(track.fileKey);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ----------------------------- like ----------------------------- */
|
||||
|
||||
tracksRouter.post(
|
||||
"/tracks/:id/like",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const track = await loadTrack(assertUuid(req.params.id));
|
||||
if (!canViewTrack(req.user, track)) throw notFound("Трек не найден");
|
||||
await db
|
||||
.insert(trackLikes)
|
||||
.values({ userId: req.user!.id, trackId: track.id })
|
||||
.onConflictDoNothing();
|
||||
res.json({ liked: true });
|
||||
}),
|
||||
);
|
||||
|
||||
tracksRouter.delete(
|
||||
"/tracks/:id/like",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const trackId = assertUuid(req.params.id);
|
||||
await db
|
||||
.delete(trackLikes)
|
||||
.where(and(eq(trackLikes.trackId, trackId), eq(trackLikes.userId, req.user!.id)));
|
||||
res.json({ liked: false });
|
||||
}),
|
||||
);
|
||||
|
||||
/* ----------------------------- stream ----------------------------- */
|
||||
|
||||
tracksRouter.get(
|
||||
"/tracks/:id/stream",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const track = await loadTrack(assertUuid(req.params.id));
|
||||
if (!canViewTrack(req.user, track)) throw notFound("Трек не найден");
|
||||
|
||||
const stat = await statObject(track.fileKey);
|
||||
if (!stat) throw notFound("Файл трека не найден");
|
||||
|
||||
const range = req.headers.range;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Type", track.mimeType || stat.contentType);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
if (req.query.download === "1") {
|
||||
const ext = (track.fileKey.match(/\.[a-z0-9]+$/i)?.[0] ?? "");
|
||||
const base = track.artist?.name ? `${track.artist.name} - ${track.title}` : track.title;
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(base + ext)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (range) {
|
||||
const match = /bytes=(\d*)-(\d*)/.exec(range);
|
||||
const start = match?.[1] ? Number(match[1]) : 0;
|
||||
const end = match?.[2] ? Number(match[2]) : stat.size - 1;
|
||||
if (start >= stat.size) {
|
||||
res.status(416).setHeader("Content-Range", `bytes */${stat.size}`);
|
||||
return res.end();
|
||||
}
|
||||
res.status(206);
|
||||
res.setHeader("Content-Range", `bytes ${start}-${end}/${stat.size}`);
|
||||
res.setHeader("Content-Length", end - start + 1);
|
||||
const stream = await minio.getPartialObject(BUCKET, track.fileKey, start, end - start + 1);
|
||||
stream.on("error", () => res.end());
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader("Content-Length", stat.size);
|
||||
const stream = await minio.getObject(BUCKET, track.fileKey);
|
||||
stream.on("error", () => res.end());
|
||||
stream.pipe(res);
|
||||
}),
|
||||
);
|
||||
|
||||
/* --------------------------- cover upload --------------------------- */
|
||||
|
||||
const coverUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith("image/")) cb(null, true);
|
||||
else cb(new Error("Только изображения"));
|
||||
},
|
||||
});
|
||||
|
||||
tracksRouter.post(
|
||||
"/tracks/:id/cover",
|
||||
requireAuth,
|
||||
coverUpload.single("cover"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const track = await loadTrack(assertUuid(req.params.id));
|
||||
const canEdit = track.uploadedById === req.user!.id || hasPerm(req.user, "canManageLibrary");
|
||||
if (!canEdit) throw new HttpError(403, "Нет прав на редактирование трека");
|
||||
if (!req.file) throw new HttpError(400, "Файл обложки не загружен");
|
||||
|
||||
const ext = (req.file.mimetype.split("/")[1] ?? "jpg").replace("jpeg", "jpg");
|
||||
const key = `covers/${track.id}/${randomUUID()}.${ext}`;
|
||||
await putBuffer(key, req.file.buffer, req.file.mimetype);
|
||||
const coverUrl = mediaUrl(key);
|
||||
|
||||
await db
|
||||
.update(tracks)
|
||||
.set({ coverUrl, updatedAt: new Date() })
|
||||
.where(eq(tracks.id, track.id));
|
||||
|
||||
if (req.query.album === "1" && track.albumId) {
|
||||
await db
|
||||
.update(albums)
|
||||
.set({ coverUrl, updatedAt: new Date() })
|
||||
.where(eq(albums.id, track.albumId));
|
||||
}
|
||||
|
||||
res.json({ track: trackDTO((await loadTrack(track.id)) as never) });
|
||||
}),
|
||||
);
|
||||
207
apps/server/src/routes/uploads.ts
Normal file
207
apps/server/src/routes/uploads.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, unlink } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Router } from "express";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import multer from "multer";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "../db/index.js";
|
||||
import { trackWith } from "../db/queries.js";
|
||||
import { tracks } from "../db/schema.js";
|
||||
import { asyncHandler, badRequest, HttpError, notFound } from "../lib/http.js";
|
||||
import { cleanFilename } from "../lib/metadata/types.js";
|
||||
import { hasPerm } from "../lib/permissions.js";
|
||||
import { trackDTO } from "../lib/presenters.js";
|
||||
import { enqueueMetadata } from "../lib/queue.js";
|
||||
import { BUCKET, minio, putFile, removeObject } from "../lib/storage.js";
|
||||
import { requirePermission } from "../middleware/auth.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
|
||||
const uploadDir = path.join(os.tmpdir(), "adc-retivo-uploads");
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (_req, _file, cb) => {
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).slice(0, 10) || ".bin";
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 150 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith("audio/") || /\.(mp3|flac|wav|ogg|m4a|aac|opus|wma)$/i.test(file.originalname)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Поддерживаются только аудиофайлы"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadsRouter = Router();
|
||||
|
||||
function sha256File(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
createReadStream(filePath)
|
||||
.on("data", (chunk) => hash.update(chunk))
|
||||
.on("end", () => resolve(hash.digest("hex")))
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart clients (browsers) send filenames as UTF-8, but busboy/multer decode
|
||||
* them as latin1, which turns Cyrillic into mojibake ("дора"). Re-decode as UTF-8.
|
||||
*/
|
||||
function decodeOriginalName(name: string): string {
|
||||
if (!/[\u0080-\u00ff]/.test(name)) return name;
|
||||
const decoded = Buffer.from(name, "latin1").toString("utf8");
|
||||
return decoded.includes("\uFFFD") ? name : decoded;
|
||||
}
|
||||
|
||||
uploadsRouter.post(
|
||||
"/uploads",
|
||||
requirePermission("canUploadTracks"),
|
||||
upload.single("file"),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.file) throw badRequest("Файл не загружен");
|
||||
const file = req.file;
|
||||
const originalName = decodeOriginalName(file.originalname);
|
||||
|
||||
try {
|
||||
const hash = await sha256File(file.path);
|
||||
const [duplicate] = await db
|
||||
.select({ id: tracks.id, title: tracks.title })
|
||||
.from(tracks)
|
||||
.where(and(sql`${tracks.metadata}->>'hash' = ${hash}`))
|
||||
.limit(1);
|
||||
|
||||
if (duplicate) {
|
||||
throw new HttpError(409, `Дубликат: «${duplicate.title}» уже есть в библиотеке`, {
|
||||
trackId: duplicate.id,
|
||||
title: duplicate.title,
|
||||
});
|
||||
}
|
||||
|
||||
const autoApprove = hasPerm(req.user, "canAutoApprove");
|
||||
const ext = path.extname(originalName).slice(0, 10) || ".bin";
|
||||
const fileKey = `tracks/${randomUUID()}${ext}`;
|
||||
|
||||
await putFile(fileKey, file.path, file.mimetype);
|
||||
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({
|
||||
title: cleanFilename(originalName) || "Без названия",
|
||||
fileKey,
|
||||
fileSize: file.size,
|
||||
mimeType: file.mimetype,
|
||||
uploadedById: req.user!.id,
|
||||
status: autoApprove ? "approved" : "pending",
|
||||
approvedAt: autoApprove ? new Date() : null,
|
||||
metadata: { hash, originalName, processing: true },
|
||||
})
|
||||
.returning();
|
||||
|
||||
await enqueueMetadata({
|
||||
trackId: track.id,
|
||||
filePath: file.path,
|
||||
mimeType: file.mimetype,
|
||||
originalName,
|
||||
});
|
||||
|
||||
logger.info({ trackId: track.id, autoApprove }, "track uploaded");
|
||||
|
||||
res.status(201).json({
|
||||
track: trackDTO({ ...track, artist: null, album: null, uploader: req.user }),
|
||||
});
|
||||
} catch (err) {
|
||||
await unlink(file.path).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/* My uploads: pending/processing + rejected (approved disappear) */
|
||||
uploadsRouter.get(
|
||||
"/uploads/mine",
|
||||
requirePermission("canUploadTracks"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const rows = await db.query.tracks.findMany({
|
||||
where: eq(tracks.uploadedById, req.user!.id),
|
||||
with: trackWith,
|
||||
orderBy: [desc(tracks.createdAt)],
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
const pending: ReturnType<typeof trackDTO>[] = [];
|
||||
const rejected: ReturnType<typeof trackDTO>[] = [];
|
||||
|
||||
for (const t of rows) {
|
||||
const processing = Boolean((t.metadata as Record<string, unknown> | null)?.processing);
|
||||
if (t.status === "rejected") {
|
||||
rejected.push(trackDTO(t as never));
|
||||
} else if (t.status === "pending" || processing) {
|
||||
pending.push(trackDTO(t as never));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ pending, rejected });
|
||||
}),
|
||||
);
|
||||
|
||||
uploadsRouter.post(
|
||||
"/uploads/:id/retry",
|
||||
requirePermission("canUploadTracks"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(and(eq(tracks.id, req.params.id), eq(tracks.uploadedById, req.user!.id)))
|
||||
.limit(1);
|
||||
if (!track) throw notFound("Трек не найден");
|
||||
await db
|
||||
.update(tracks)
|
||||
.set({
|
||||
status: track.status === "rejected" ? "pending" : track.status,
|
||||
rejectionReason: track.status === "rejected" ? null : track.rejectionReason,
|
||||
metadata: { ...(track.metadata ?? {}), processing: true, requeuedAt: new Date().toISOString() },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tracks.id, track.id));
|
||||
|
||||
// Download the stored object back to a temp file and re-run metadata
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const ext = path.extname(track.fileKey).slice(0, 10) || ".bin";
|
||||
const tmpPath = path.join(uploadDir, `${randomUUID()}${ext}`);
|
||||
await minio.fGetObject(BUCKET, track.fileKey, tmpPath);
|
||||
await enqueueMetadata(
|
||||
{ trackId: track.id, filePath: tmpPath, originalName: track.title },
|
||||
{ force: true },
|
||||
);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
uploadsRouter.delete(
|
||||
"/uploads/:id",
|
||||
requirePermission("canUploadTracks"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(and(eq(tracks.id, req.params.id), eq(tracks.uploadedById, req.user!.id)))
|
||||
.limit(1);
|
||||
if (!track) throw notFound("Трек не найден");
|
||||
await db.delete(tracks).where(eq(tracks.id, track.id));
|
||||
await removeObject(track.fileKey);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
72
apps/server/src/routes/users.ts
Normal file
72
apps/server/src/routes/users.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Router } from "express";
|
||||
import { and, desc, eq, ne, sql } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { libraryPlaylists, playlists, tracks, users } from "../db/schema.js";
|
||||
import { asyncHandler, notFound } from "../lib/http.js";
|
||||
import { playlistDTO, publicUser, selfUser } from "../lib/presenters.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
export const usersRouter = Router();
|
||||
|
||||
usersRouter.get(
|
||||
"/users/:username",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(sql`lower(${users.username}) = ${req.params.username.toLowerCase()}`)
|
||||
.limit(1);
|
||||
if (!user) throw notFound("Пользователь не найден");
|
||||
|
||||
const publicPlaylists = await db.query.playlists.findMany({
|
||||
where: and(eq(playlists.ownerId, user.id), ne(playlists.visibility, "private")),
|
||||
with: { owner: true, entries: true },
|
||||
orderBy: [desc(playlists.updatedAt)],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const [{ count: trackCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(tracks)
|
||||
.where(and(eq(tracks.uploadedById, user.id), eq(tracks.status, "approved")));
|
||||
|
||||
res.json({
|
||||
user: publicUser(user),
|
||||
playlists: publicPlaylists.map((p) => playlistDTO(p as never)),
|
||||
trackCount,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
usersRouter.get(
|
||||
"/users/:username/library",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(sql`lower(${users.username}) = ${req.params.username.toLowerCase()}`)
|
||||
.limit(1);
|
||||
if (!user) throw notFound("Пользователь не найден");
|
||||
if (user.id !== req.user!.id && !req.user!.isSuperAdmin) {
|
||||
throw notFound("Пользователь не найден");
|
||||
}
|
||||
const libRows = await db
|
||||
.select({ playlist: playlists, mode: libraryPlaylists.mode })
|
||||
.from(libraryPlaylists)
|
||||
.innerJoin(playlists, eq(libraryPlaylists.playlistId, playlists.id))
|
||||
.where(eq(libraryPlaylists.userId, user.id));
|
||||
res.json({
|
||||
saved: libRows.map((r) => ({ mode: r.mode, playlist: playlistDTO(r.playlist as never) })),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
usersRouter.get(
|
||||
"/me/self",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ user: selfUser(req.user!) });
|
||||
}),
|
||||
);
|
||||
67
apps/server/src/services/access.ts
Normal file
67
apps/server/src/services/access.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { libraryPlaylists, playlistCollaborators, playlists, type Playlist, type User, type Track } from "../db/schema.js";
|
||||
import { hasPerm } from "../lib/permissions.js";
|
||||
|
||||
export async function getPlaylist(id: string): Promise<Playlist | null> {
|
||||
const [p] = await db.select().from(playlists).where(eq(playlists.id, id)).limit(1);
|
||||
return p ?? null;
|
||||
}
|
||||
|
||||
export async function canViewPlaylist(user: User | undefined, playlist: Playlist): Promise<boolean> {
|
||||
if (playlist.visibility !== "private") return true;
|
||||
if (!user) return false;
|
||||
if (playlist.ownerId === user.id) return true;
|
||||
if (hasPerm(user, "canViewAllPlaylists")) return true;
|
||||
const [collab] = await db
|
||||
.select()
|
||||
.from(playlistCollaborators)
|
||||
.where(
|
||||
and(
|
||||
eq(playlistCollaborators.playlistId, playlist.id),
|
||||
eq(playlistCollaborators.userId, user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (collab) return true;
|
||||
const [lib] = await db
|
||||
.select()
|
||||
.from(libraryPlaylists)
|
||||
.where(and(eq(libraryPlaylists.playlistId, playlist.id), eq(libraryPlaylists.userId, user.id)))
|
||||
.limit(1);
|
||||
return Boolean(lib);
|
||||
}
|
||||
|
||||
export async function canEditPlaylist(user: User | undefined, playlist: Playlist): Promise<boolean> {
|
||||
if (!user) return false;
|
||||
if (playlist.ownerId === user.id) return true;
|
||||
if (hasPerm(user, "canManageLibrary")) return true;
|
||||
const [collab] = await db
|
||||
.select()
|
||||
.from(playlistCollaborators)
|
||||
.where(
|
||||
and(
|
||||
eq(playlistCollaborators.playlistId, playlist.id),
|
||||
eq(playlistCollaborators.userId, user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (collab?.canEdit) return true;
|
||||
const [lib] = await db
|
||||
.select()
|
||||
.from(libraryPlaylists)
|
||||
.where(and(eq(libraryPlaylists.playlistId, playlist.id), eq(libraryPlaylists.userId, user.id)))
|
||||
.limit(1);
|
||||
return Boolean(lib?.canEdit);
|
||||
}
|
||||
|
||||
export function canViewTrack(user: User | undefined, track: Track): boolean {
|
||||
if (track.status === "approved") return true;
|
||||
if (!user) return false;
|
||||
if (track.uploadedById === user.id) return true;
|
||||
return hasPerm(user, "canManageLibrary") || hasPerm(user, "canModerate");
|
||||
}
|
||||
|
||||
export async function playlistsForUser(userId: string): Promise<Playlist[]> {
|
||||
return db.select().from(playlists).where(eq(playlists.ownerId, userId));
|
||||
}
|
||||
235
apps/server/src/services/trackIngest.ts
Normal file
235
apps/server/src/services/trackIngest.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { albums, artists, trackArtists, tracks } from "../db/schema.js";
|
||||
import { env } from "../env.js";
|
||||
import { enrichTrack } from "../lib/metadata/index.js";
|
||||
import { putBuffer } from "../lib/storage.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
|
||||
export function mediaUrl(key: string): string {
|
||||
return `/api/media/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an external image (Cover Art Archive / Wikipedia) into object storage
|
||||
* and returns a local /api/media URL, so we stop depending on third-party services.
|
||||
*/
|
||||
export async function localizeExternalImage(
|
||||
url: string | undefined,
|
||||
folder: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith("/api/media/")) return url;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": env.MB_USER_AGENT, Accept: "image/*" },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) return url;
|
||||
const contentType = (res.headers.get("content-type") ?? "image/jpeg").split(";")[0];
|
||||
if (!contentType.startsWith("image/")) return url;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length === 0 || buffer.length > 12 * 1024 * 1024) return url;
|
||||
const ext = (contentType.split("/")[1] ?? "jpg").replace("jpeg", "jpg").replace("svg+xml", "svg");
|
||||
const key = `${folder}/${randomUUID()}.${ext}`;
|
||||
await putBuffer(key, buffer, contentType);
|
||||
return mediaUrl(key);
|
||||
} catch (err) {
|
||||
logger.debug({ err, url }, "failed to localize image, keeping remote url");
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findOrCreateArtist(
|
||||
name: string,
|
||||
opts: { mbArtistId?: string; description?: string; imageUrl?: string },
|
||||
): Promise<string> {
|
||||
let existing;
|
||||
if (opts.mbArtistId) {
|
||||
[existing] = await db.select().from(artists).where(eq(artists.mbArtistId, opts.mbArtistId)).limit(1);
|
||||
}
|
||||
if (!existing) {
|
||||
[existing] = await db
|
||||
.select()
|
||||
.from(artists)
|
||||
.where(sql`lower(${artists.name}) = ${name.toLowerCase()}`)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const patch: Partial<typeof artists.$inferInsert> = { updatedAt: new Date() };
|
||||
if (!existing.mbArtistId && opts.mbArtistId) patch.mbArtistId = opts.mbArtistId;
|
||||
if (!existing.description && opts.description) patch.description = opts.description;
|
||||
if (!existing.imageUrl && opts.imageUrl) patch.imageUrl = opts.imageUrl;
|
||||
if (Object.keys(patch).length > 1) {
|
||||
await db.update(artists).set(patch).where(eq(artists.id, existing.id));
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(artists)
|
||||
.values({
|
||||
name,
|
||||
mbArtistId: opts.mbArtistId,
|
||||
description: opts.description,
|
||||
imageUrl: opts.imageUrl,
|
||||
metadata: { provenance: "auto" },
|
||||
})
|
||||
.returning();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function findOrCreateAlbum(
|
||||
title: string,
|
||||
artistId: string | null,
|
||||
opts: { mbReleaseGroupId?: string; mbReleaseId?: string; releaseDate?: string; coverUrl?: string },
|
||||
): Promise<string> {
|
||||
let existing;
|
||||
if (opts.mbReleaseGroupId) {
|
||||
[existing] = await db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(eq(albums.mbReleaseGroupId, opts.mbReleaseGroupId))
|
||||
.limit(1);
|
||||
}
|
||||
if (!existing) {
|
||||
[existing] = await db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(
|
||||
and(sql`lower(${albums.title}) = ${title.toLowerCase()}`, artistId ? eq(albums.artistId, artistId) : sql`true`),
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const patch: Partial<typeof albums.$inferInsert> = { updatedAt: new Date() };
|
||||
if (!existing.coverUrl && opts.coverUrl) patch.coverUrl = opts.coverUrl;
|
||||
if (!existing.releaseDate && opts.releaseDate) patch.releaseDate = opts.releaseDate;
|
||||
if (!existing.mbReleaseGroupId && opts.mbReleaseGroupId) patch.mbReleaseGroupId = opts.mbReleaseGroupId;
|
||||
if (!existing.mbReleaseId && opts.mbReleaseId) patch.mbReleaseId = opts.mbReleaseId;
|
||||
if (Object.keys(patch).length > 1) {
|
||||
await db.update(albums).set(patch).where(eq(albums.id, existing.id));
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(albums)
|
||||
.values({
|
||||
title,
|
||||
artistId,
|
||||
mbReleaseGroupId: opts.mbReleaseGroupId,
|
||||
mbReleaseId: opts.mbReleaseId,
|
||||
releaseDate: opts.releaseDate,
|
||||
coverUrl: opts.coverUrl,
|
||||
metadata: { provenance: "auto" },
|
||||
})
|
||||
.returning();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function ingestTrack(input: {
|
||||
trackId: string;
|
||||
filePath: string;
|
||||
originalName?: string;
|
||||
mimeType?: string;
|
||||
}): Promise<void> {
|
||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, input.trackId)).limit(1);
|
||||
if (!track) {
|
||||
logger.warn({ trackId: input.trackId }, "ingest: track not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const { tags, data } = await enrichTrack({
|
||||
filePath: input.filePath,
|
||||
originalName: input.originalName,
|
||||
});
|
||||
|
||||
// Localize external cover art so we don't keep hitting third-party services.
|
||||
let coverUrl = await localizeExternalImage(data.coverUrl, "covers");
|
||||
if (!coverUrl && tags.pictureBuffer) {
|
||||
const ext = (tags.pictureMime?.split("/")[1] ?? "jpg").replace("jpeg", "jpg");
|
||||
const key = `covers/${randomUUID()}.${ext}`;
|
||||
await putBuffer(key, tags.pictureBuffer, tags.pictureMime ?? "image/jpeg");
|
||||
coverUrl = mediaUrl(key);
|
||||
}
|
||||
|
||||
const credits =
|
||||
data.artists?.length
|
||||
? data.artists
|
||||
: [{ name: data.artistName ?? "Неизвестный исполнитель", role: "main" as const }];
|
||||
const artistImageUrl = await localizeExternalImage(data.artistImageUrl, "artists");
|
||||
|
||||
const resolvedArtists: { artistId: string; position: number; role: string }[] = [];
|
||||
for (let i = 0; i < credits.length; i++) {
|
||||
const credit = credits[i];
|
||||
const artistId = await findOrCreateArtist(credit.name, {
|
||||
mbArtistId: credit.mbArtistId,
|
||||
description: i === 0 ? data.artistDescription : undefined,
|
||||
imageUrl: i === 0 ? artistImageUrl : undefined,
|
||||
});
|
||||
resolvedArtists.push({ artistId, position: i, role: credit.role ?? "main" });
|
||||
}
|
||||
const artistId = resolvedArtists[0].artistId;
|
||||
|
||||
let albumId: string | null = null;
|
||||
if (data.albumTitle) {
|
||||
albumId = await findOrCreateAlbum(data.albumTitle, artistId, {
|
||||
mbReleaseGroupId: data.mbReleaseGroupId,
|
||||
mbReleaseId: data.mbReleaseId,
|
||||
releaseDate: data.releaseDate,
|
||||
coverUrl,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(tracks)
|
||||
.set({
|
||||
title: data.title ?? track.title,
|
||||
artistId,
|
||||
albumId,
|
||||
durationMs: data.durationMs ?? track.durationMs,
|
||||
trackNumber: data.trackNumber ?? track.trackNumber,
|
||||
discNumber: data.discNumber ?? track.discNumber,
|
||||
releaseDate: data.releaseDate ?? track.releaseDate,
|
||||
lyricsPlain: data.lyricsPlain ?? track.lyricsPlain,
|
||||
lyricsSynced: data.lyricsSynced ?? track.lyricsSynced,
|
||||
lyricsSource: data.lyricsSource ?? track.lyricsSource,
|
||||
fingerprint: data.fingerprint ?? track.fingerprint,
|
||||
mbRecordingId: data.mbRecordingId ?? track.mbRecordingId,
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
mimeType: input.mimeType ?? track.mimeType,
|
||||
metadata: {
|
||||
...(track.metadata ?? {}),
|
||||
processing: false,
|
||||
enrichment: data,
|
||||
sources: data.sources,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tracks.id, track.id));
|
||||
|
||||
// Replace the artist credits for this track (idempotent on re-ingest)
|
||||
await db.delete(trackArtists).where(eq(trackArtists.trackId, track.id));
|
||||
await db.insert(trackArtists).values(
|
||||
resolvedArtists.map((a) => ({
|
||||
trackId: track.id,
|
||||
artistId: a.artistId,
|
||||
position: a.position,
|
||||
role: a.role,
|
||||
})),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
trackId: track.id,
|
||||
title: data.title,
|
||||
artists: credits.map((c) => c.name),
|
||||
sources: data.sources,
|
||||
},
|
||||
"track ingested",
|
||||
);
|
||||
}
|
||||
34
apps/server/src/workers/metadata.worker.ts
Normal file
34
apps/server/src/workers/metadata.worker.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Worker } from "bullmq";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { logger } from "../lib/logger.js";
|
||||
import { METADATA_QUEUE, type MetadataJobData } from "../lib/queue.js";
|
||||
import { newRedis } from "../lib/redis.js";
|
||||
import { ingestTrack } from "../services/trackIngest.js";
|
||||
|
||||
export function startMetadataWorker() {
|
||||
const worker = new Worker<MetadataJobData>(
|
||||
METADATA_QUEUE,
|
||||
async (job) => {
|
||||
const { trackId, filePath, originalName, mimeType } = job.data;
|
||||
const maxAttempts = job.opts.attempts ?? 1;
|
||||
try {
|
||||
await ingestTrack({ trackId, filePath, originalName, mimeType });
|
||||
await unlink(filePath).catch(() => {});
|
||||
} catch (err) {
|
||||
// Keep the temp file for retries; only clean up after the last attempt.
|
||||
const isFinalAttempt = job.attemptsMade + 1 >= maxAttempts;
|
||||
if (isFinalAttempt) await unlink(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{ connection: newRedis(), concurrency: 2 },
|
||||
);
|
||||
|
||||
worker.on("completed", (job) => logger.info({ jobId: job.id }, "metadata job done"));
|
||||
worker.on("failed", (job, err) =>
|
||||
logger.error({ jobId: job?.id, err: err.message }, "metadata job failed"),
|
||||
);
|
||||
|
||||
logger.info("metadata worker started");
|
||||
return worker;
|
||||
}
|
||||
13
apps/server/tsconfig.json
Normal file
13
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
8
apps/web/Dockerfile.dev
Normal file
8
apps/web/Dockerfile.dev
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY apps/server/package.json apps/server/
|
||||
COPY apps/web/package.json apps/web/
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "-w", "@adc-retivo/web", "--", "--host", "0.0.0.0"]
|
||||
19
apps/web/index.html
Normal file
19
apps/web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#07080c" />
|
||||
<title>ADC Retivo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
apps/web/package.json
Normal file
32
apps/web/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@adc-retivo/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
66
apps/web/src/App.tsx
Normal file
66
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { Account } from "./pages/Account";
|
||||
import { AddTrack } from "./pages/AddTrack";
|
||||
import { Admin } from "./pages/Admin";
|
||||
import { AlbumPage } from "./pages/AlbumPage";
|
||||
import { ArtistPage } from "./pages/ArtistPage";
|
||||
import { Home } from "./pages/Home";
|
||||
import { Library } from "./pages/Library";
|
||||
import { Login } from "./pages/Login";
|
||||
import { PlaylistPage } from "./pages/PlaylistPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { TrackPage } from "./pages/TrackPage";
|
||||
import { UserPage } from "./pages/UserPage";
|
||||
import { useAuth } from "./store/auth";
|
||||
|
||||
function Splash() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse-slow text-lg font-semibold tracking-widest text-white/70">
|
||||
ADC RETIVO
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <Splash />;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={loading ? <Splash /> : user ? <Navigate to="/" replace /> : <Login />}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/add" element={<AddTrack />} />
|
||||
<Route path="/account" element={<Account />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/playlist/:id" element={<PlaylistPage />} />
|
||||
<Route path="/artist/:id" element={<ArtistPage />} />
|
||||
<Route path="/album/:id" element={<AlbumPage />} />
|
||||
<Route path="/track/:id" element={<TrackPage />} />
|
||||
<Route path="/u/:username" element={<UserPage />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/admin/:section" element={<Admin />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/components/AddToPlaylistModal.tsx
Normal file
80
apps/web/src/components/AddToPlaylistModal.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Plus, X } from "lucide-react";
|
||||
import { playlistsApi } from "../lib/api";
|
||||
import type { Track } from "../lib/types";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export function AddToPlaylistModal({
|
||||
track,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
track: Track | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
const { data: playlists, isLoading } = useQuery({
|
||||
queryKey: ["my-playlists"],
|
||||
queryFn: playlistsApi.mine,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (playlistId: string) => playlistsApi.addTrack(playlistId, track!.id),
|
||||
onSuccess: (_d, playlistId) => {
|
||||
setCreatedId(playlistId);
|
||||
void qc.invalidateQueries({ queryKey: ["playlist", playlistId] });
|
||||
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
|
||||
},
|
||||
});
|
||||
|
||||
const createAndAdd = useMutation({
|
||||
mutationFn: async () => {
|
||||
const pl = await playlistsApi.create({ title: `Плейлист · ${track?.title ?? ""}`.slice(0, 80) });
|
||||
await playlistsApi.addTrack(pl.id, track!.id);
|
||||
return pl;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Добавить в плейлист">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="btn-primary w-full"
|
||||
onClick={() => createAndAdd.mutate()}
|
||||
disabled={createAndAdd.isPending}
|
||||
>
|
||||
<Plus size={15} /> Новый плейлист
|
||||
</button>
|
||||
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto pt-2">
|
||||
{isLoading && <p className="text-sm text-zinc-500">Загрузка...</p>}
|
||||
{playlists?.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
onClick={() => add.mutate(pl.id)}
|
||||
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm hover:bg-white/5"
|
||||
>
|
||||
<span className="truncate">{pl.title}</span>
|
||||
{createdId === pl.id ? (
|
||||
<Check size={15} className="text-emerald-400" />
|
||||
) : (
|
||||
<Plus size={15} className="text-zinc-500" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{playlists && playlists.length === 0 && (
|
||||
<p className="px-3 text-sm text-zinc-500">У вас пока нет плейлистов.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/components/ArtistLinks.tsx
Normal file
37
apps/web/src/components/ArtistLinks.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { TrackArtistCredit } from "../lib/types";
|
||||
|
||||
/**
|
||||
* Renders one or more artist credits as individual profile links,
|
||||
* e.g. "Дора, Мукка feat. Мэйби Бэйби".
|
||||
*/
|
||||
export function ArtistLinks({
|
||||
artists,
|
||||
fallback,
|
||||
className,
|
||||
linkClassName = "hover:underline",
|
||||
onNavigate,
|
||||
}: {
|
||||
artists?: TrackArtistCredit[] | null;
|
||||
fallback?: string | null;
|
||||
className?: string;
|
||||
linkClassName?: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const list = artists?.length ? artists : [];
|
||||
if (!list.length) {
|
||||
return <span className={className}>{fallback ?? "Неизвестный исполнитель"}</span>;
|
||||
}
|
||||
return (
|
||||
<span className={className}>
|
||||
{list.map((a, i) => (
|
||||
<span key={a.id}>
|
||||
{i > 0 && <span className="text-zinc-600">, </span>}
|
||||
<Link to={`/artist/${a.id}`} className={linkClassName} onClick={onNavigate}>
|
||||
{a.name}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/components/Comments.tsx
Normal file
168
apps/web/src/components/Comments.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CornerDownRight, Send, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { tracksApi } from "../lib/api";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { Comment } from "../lib/types";
|
||||
import { useAuth } from "../store/auth";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
export function Comments({ trackId, compact = false }: { trackId: string; compact?: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["comments", trackId],
|
||||
queryFn: () => tracksApi.comments(trackId),
|
||||
});
|
||||
|
||||
const invalidate = () => void qc.invalidateQueries({ queryKey: ["comments", trackId] });
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: ({ text, parentId }: { text: string; parentId?: string }) =>
|
||||
tracksApi.addComment(trackId, text, parentId ?? null),
|
||||
onSuccess: () => {
|
||||
setBody("");
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (commentId: string) => tracksApi.deleteComment(commentId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={compact ? "" : "card p-4"}>
|
||||
<h2 className="section-title mb-3">Комментарии ({data?.total ?? 0})</h2>
|
||||
|
||||
<form
|
||||
className="mb-4 flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (body.trim()) add.mutate({ text: body.trim() });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Оставить комментарий..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary px-3" disabled={!body.trim() || add.isPending}>
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-4">
|
||||
{data?.comments.map((c) => (
|
||||
<CommentNode
|
||||
key={c.id}
|
||||
comment={c}
|
||||
currentUserId={user?.id ?? ""}
|
||||
onReply={(text, parentId) => add.mutate({ text, parentId })}
|
||||
onDelete={(cid) => remove.mutate(cid)}
|
||||
/>
|
||||
))}
|
||||
{data && data.comments.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">Комментариев пока нет. Будьте первым!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentNode({
|
||||
comment,
|
||||
currentUserId,
|
||||
onReply,
|
||||
onDelete,
|
||||
}: {
|
||||
comment: Comment;
|
||||
currentUserId: string;
|
||||
onReply: (text: string, parentId: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [reply, setReply] = useState("");
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Link to={comment.user ? `/u/${comment.user.username}` : "#"}>
|
||||
<Cover
|
||||
src={comment.user?.avatarUrl}
|
||||
name={comment.user?.displayName || comment.user?.username}
|
||||
rounded="rounded-full"
|
||||
className="h-7 w-7 shrink-0"
|
||||
/>
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{comment.user ? (
|
||||
<Link to={`/u/${comment.user.username}`} className="link text-xs font-medium">
|
||||
{comment.user.displayName || comment.user.username}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">удалён</span>
|
||||
)}
|
||||
<span className="text-[10px] text-zinc-600">{formatDate(comment.createdAt)}</span>
|
||||
</div>
|
||||
<p className={comment.deleted ? "italic text-zinc-600" : "text-zinc-200"}>
|
||||
{comment.deleted ? "Комментарий удалён" : comment.body}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-[11px] text-zinc-500">
|
||||
<button className="hover:text-white" onClick={() => setReplyOpen((v) => !v)}>
|
||||
<CornerDownRight size={11} className="mr-1 inline" /> Ответить
|
||||
</button>
|
||||
{comment.user?.id === currentUserId && !comment.deleted && (
|
||||
<button className="hover:text-red-300" onClick={() => onDelete(comment.id)}>
|
||||
<Trash2 size={11} className="mr-1 inline" /> Удалить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{replyOpen && (
|
||||
<form
|
||||
className="mt-2 flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (reply.trim()) {
|
||||
onReply(reply.trim(), comment.id);
|
||||
setReply("");
|
||||
setReplyOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input py-1.5 text-xs"
|
||||
placeholder="Ответ..."
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary px-3 py-1.5">
|
||||
<Send size={13} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{comment.replies.length > 0 && (
|
||||
<div className="mt-3 space-y-3 border-l border-white/10 pl-3">
|
||||
{comment.replies.map((r) => (
|
||||
<CommentNode
|
||||
key={r.id}
|
||||
comment={r}
|
||||
currentUserId={currentUserId}
|
||||
onReply={onReply}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/components/Cover.tsx
Normal file
25
apps/web/src/components/Cover.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cx, initials } from "../lib/format";
|
||||
|
||||
export function Cover({
|
||||
src,
|
||||
name,
|
||||
className,
|
||||
rounded = "rounded-xl",
|
||||
}: {
|
||||
src?: string | null;
|
||||
name?: string | null;
|
||||
className?: string;
|
||||
rounded?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cx("relative overflow-hidden bg-white/5", rounded, className)}>
|
||||
{src ? (
|
||||
<img src={src} alt={name ?? ""} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-xs font-semibold text-zinc-500">
|
||||
{initials(name) || "AR"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
apps/web/src/components/CreatePlaylistModal.tsx
Normal file
83
apps/web/src/components/CreatePlaylistModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { playlistsApi } from "../lib/api";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export function CreatePlaylistModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [form, setForm] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
visibility: "public" | "unlisted" | "private";
|
||||
}>({ title: "", description: "", visibility: "private" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
playlistsApi.create({
|
||||
title: form.title,
|
||||
description: form.description || null,
|
||||
visibility: form.visibility,
|
||||
}),
|
||||
onSuccess: (pl) => {
|
||||
void qc.invalidateQueries({ queryKey: ["library"] });
|
||||
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
|
||||
setForm({ title: "", description: "", visibility: "private" });
|
||||
onCreated?.(pl.id);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: any) => setError(e?.response?.data?.error ?? "Ошибка"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Новый плейлист">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Название</label>
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Мой плейлист"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Описание</label>
|
||||
<textarea
|
||||
className="input min-h-20"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Доступ</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.visibility}
|
||||
onChange={(e) => setForm({ ...form, visibility: e.target.value as typeof form.visibility })} >
|
||||
<option value="private">Приватный</option>
|
||||
<option value="unlisted">По ссылке</option>
|
||||
<option value="public">Публичный</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-300">{error}</p>}
|
||||
<button
|
||||
className="btn-primary w-full"
|
||||
onClick={() => create.mutate()}
|
||||
disabled={!form.title.trim() || create.isPending}
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
176
apps/web/src/components/Layout.tsx
Normal file
176
apps/web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Compass,
|
||||
Library as LibraryIcon,
|
||||
PlusCircle,
|
||||
Search as SearchIcon,
|
||||
Settings,
|
||||
Shield,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { cx } from "../lib/format";
|
||||
import { canAccessAdmin } from "../lib/permissions";
|
||||
import { useAuth } from "../store/auth";
|
||||
import { Player } from "./Player";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Главная", icon: Compass, end: true },
|
||||
{ to: "/library", label: "Библиотека", icon: LibraryIcon },
|
||||
{ to: "/add", label: "Добавить", icon: PlusCircle },
|
||||
{ to: "/account", label: "Аккаунт", icon: UserIcon },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const isAdmin = user ? canAccessAdmin(user) : false;
|
||||
|
||||
const submitSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (q) navigate(`/search?q=${encodeURIComponent(q)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Sidebar (desktop) */}
|
||||
<aside className="hidden w-64 shrink-0 flex-col gap-2 p-4 md:flex">
|
||||
<NavLink to="/" className="mb-4 flex items-center gap-3 px-2">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-accent to-accent2 text-sm font-black text-black">
|
||||
AR
|
||||
</span>
|
||||
<span className="text-sm font-semibold tracking-widest text-white">
|
||||
ADC RETIVO
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition",
|
||||
isActive
|
||||
? "bg-white/10 text-white"
|
||||
: "text-zinc-400 hover:bg-white/5 hover:text-white",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition",
|
||||
isActive || location.pathname.startsWith("/admin")
|
||||
? "bg-white/10 text-white"
|
||||
: "text-zinc-400 hover:bg-white/5 hover:text-white",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Shield size={18} />
|
||||
Админ-панель
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/5 bg-black/30 px-4 py-3 backdrop-blur-xl md:px-6">
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-accent to-accent2 text-[10px] font-black text-black">
|
||||
AR
|
||||
</span>
|
||||
</div>
|
||||
<form onSubmit={submitSearch} className="relative flex-1">
|
||||
<SearchIcon
|
||||
size={16}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
|
||||
/>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск треков, артистов, альбомов, плейлистов, людей..."
|
||||
className="input pl-9"
|
||||
/>
|
||||
</form>
|
||||
{user && (
|
||||
<NavLink
|
||||
to={`/u/${user.username}`}
|
||||
className="hidden items-center gap-2 rounded-full border border-white/10 bg-white/5 py-1.5 pl-1.5 pr-3 text-xs text-zinc-300 hover:bg-white/10 sm:flex"
|
||||
>
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 place-items-center rounded-full bg-white/10 text-[10px]">
|
||||
{user.username[0]?.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="max-w-[120px] truncate">
|
||||
{user.displayName || user.username}
|
||||
</span>
|
||||
</NavLink>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-40 pt-4 md:px-6 md:pb-32">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 flex items-stretch justify-around border-t border-white/10 bg-black/60 backdrop-blur-xl md:hidden">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
"flex flex-1 flex-col items-center gap-1 py-2 text-[10px] transition",
|
||||
isActive ? "text-white" : "text-zinc-500",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
"flex flex-1 flex-col items-center gap-1 py-2 text-[10px] transition",
|
||||
isActive ? "text-white" : "text-zinc-500",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Shield size={18} />
|
||||
Админ
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<Player />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
apps/web/src/components/LyricsView.tsx
Normal file
78
apps/web/src/components/LyricsView.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { tracksApi } from "../lib/api";
|
||||
import { selectCurrentTrack, usePlayer } from "../store/player";
|
||||
|
||||
export function LyricsView({ trackId, compact = false }: { trackId: string; compact?: boolean }) {
|
||||
const currentTime = usePlayer((s) => s.currentTime);
|
||||
const seek = usePlayer((s) => s.seek);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["lyrics", trackId],
|
||||
queryFn: () => tracksApi.get(trackId),
|
||||
enabled: Boolean(trackId),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const synced = data?.lyrics.synced ?? null;
|
||||
const plain = data?.lyrics.plain ?? null;
|
||||
|
||||
const activeIndex =
|
||||
synced && synced.length
|
||||
? synced.findIndex((line, i) => {
|
||||
const next = synced[i + 1];
|
||||
return currentTime * 1000 >= line.timeMs && (!next || currentTime * 1000 < next.timeMs);
|
||||
})
|
||||
: -1;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeRef.current && containerRef.current) {
|
||||
activeRef.current.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-6 text-sm text-zinc-500">Загрузка текста...</p>;
|
||||
}
|
||||
|
||||
if (!synced?.length && !plain) {
|
||||
return (
|
||||
<p className="p-6 text-sm text-zinc-500">
|
||||
Текст песни не найден. Его можно добавить вручную в админ-панели.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (synced?.length) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={compact ? "max-h-64 overflow-y-auto px-2" : "max-h-full overflow-y-auto px-2 py-4"}
|
||||
>
|
||||
{synced.map((line, i) => (
|
||||
<p
|
||||
key={`${line.timeMs}-${i}`}
|
||||
ref={i === activeIndex ? activeRef : undefined}
|
||||
onClick={() => seek(line.timeMs / 1000)}
|
||||
className={
|
||||
"cursor-pointer rounded-lg px-3 py-1.5 text-lg font-semibold leading-snug transition " +
|
||||
(i === activeIndex
|
||||
? "bg-white/10 text-white"
|
||||
: "text-zinc-500 hover:text-zinc-300")
|
||||
}
|
||||
>
|
||||
{line.text || "♪"}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap px-4 py-4 font-sans text-sm leading-relaxed text-zinc-300">
|
||||
{plain}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/components/Modal.tsx
Normal file
39
apps/web/src/components/Modal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
wide = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"w-full animate-fade-in rounded-2xl border border-white/10 bg-[#0d0f16]/95 p-5 shadow-2xl " +
|
||||
(wide ? "max-w-3xl" : "max-w-lg")
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-white">{title}</h3>
|
||||
<button className="text-zinc-400 hover:text-white" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
655
apps/web/src/components/Player.tsx
Normal file
655
apps/web/src/components/Player.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
ListMusic,
|
||||
Maximize2,
|
||||
MessageSquare,
|
||||
Mic2,
|
||||
Pause,
|
||||
Play,
|
||||
PlusCircle,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cx, formatTime } from "../lib/format";
|
||||
import { selectCurrentTrack, usePlayer } from "../store/player";
|
||||
import { AddToPlaylistModal } from "./AddToPlaylistModal";
|
||||
import { ArtistLinks } from "./ArtistLinks";
|
||||
import { Comments } from "./Comments";
|
||||
import { Cover } from "./Cover";
|
||||
import { LyricsView } from "./LyricsView";
|
||||
import { QueuePanel } from "./QueuePanel";
|
||||
|
||||
type Tab = "queue" | "lyrics" | "comments";
|
||||
|
||||
export function Player() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const current = usePlayer(selectCurrentTrack);
|
||||
const isPlaying = usePlayer((s) => s.isPlaying);
|
||||
const shuffle = usePlayer((s) => s.shuffle);
|
||||
const repeat = usePlayer((s) => s.repeat);
|
||||
const expanded = usePlayer((s) => s.expanded);
|
||||
const currentTime = usePlayer((s) => s.currentTime);
|
||||
const duration = usePlayer((s) => s.duration);
|
||||
const volume = usePlayer((s) => s.volume);
|
||||
|
||||
const toggle = usePlayer((s) => s.toggle);
|
||||
const next = usePlayer((s) => s.next);
|
||||
const prev = usePlayer((s) => s.prev);
|
||||
const setExpanded = usePlayer((s) => s.setExpanded);
|
||||
const setCurrentTime = usePlayer((s) => s.setCurrentTime);
|
||||
const setDuration = usePlayer((s) => s.setDuration);
|
||||
const setVolume = usePlayer((s) => s.setVolume);
|
||||
const toggleShuffle = usePlayer((s) => s.toggleShuffle);
|
||||
const cycleRepeat = usePlayer((s) => s.cycleRepeat);
|
||||
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>("queue");
|
||||
|
||||
// Load a new source when the track changes
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !current) return;
|
||||
const src = `/api/tracks/${current.id}/stream`;
|
||||
if (!audio.src.endsWith(src)) {
|
||||
audio.src = src;
|
||||
audio.load();
|
||||
}
|
||||
if (usePlayer.getState().isPlaying) void audio.play().catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [current?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (isPlaying) void audio.play().catch(() => {});
|
||||
else audio.pause();
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) audio.volume = volume;
|
||||
}, [volume]);
|
||||
|
||||
// System media session (lock screen / OS media controls)
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator) || !current) return;
|
||||
const artwork = current.coverUrl
|
||||
? [
|
||||
{
|
||||
src: new URL(current.coverUrl, window.location.origin).href,
|
||||
sizes: "500x500",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: current.title,
|
||||
artist:
|
||||
(current.artists?.length
|
||||
? current.artists.map((a) => a.name)
|
||||
: [current.artist?.name]
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(", ") || "Неизвестный исполнитель",
|
||||
album: current.album?.title ?? "",
|
||||
artwork,
|
||||
});
|
||||
}, [current?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
|
||||
}, [isPlaying]);
|
||||
|
||||
// (Re)register Media Session handlers. iOS Safari (and some browsers) drop the
|
||||
// action handlers when the audio source changes, so we register them on mount,
|
||||
// on every track change and again when playback starts.
|
||||
const registerMediaSession = useCallback(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
const ms = navigator.mediaSession;
|
||||
const s = () => usePlayer.getState();
|
||||
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
|
||||
["play", () => s().toggle()],
|
||||
["pause", () => s().toggle()],
|
||||
["stop", () => s().setPlaying(false)],
|
||||
["previoustrack", () => s().prev()],
|
||||
["nexttrack", () => s().next()],
|
||||
["seekbackward", (d) => s().seek(Math.max(0, s().currentTime - (d.seekOffset ?? 10)))],
|
||||
["seekforward", (d) => s().seek(s().currentTime + (d.seekOffset ?? 10))],
|
||||
[
|
||||
"seekto",
|
||||
(d) => {
|
||||
if (typeof d.seekTime === "number") s().seek(d.seekTime);
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [action, handler] of handlers) {
|
||||
try {
|
||||
ms.setActionHandler(action, handler);
|
||||
} catch {
|
||||
/* unsupported action */
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
registerMediaSession();
|
||||
return () => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
const actions: MediaSessionAction[] = [
|
||||
"play",
|
||||
"pause",
|
||||
"stop",
|
||||
"previoustrack",
|
||||
"nexttrack",
|
||||
"seekbackward",
|
||||
"seekforward",
|
||||
"seekto",
|
||||
];
|
||||
for (const action of actions) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [registerMediaSession]);
|
||||
|
||||
useEffect(() => {
|
||||
registerMediaSession();
|
||||
}, [current?.id, registerMediaSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator) || !duration) return;
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration,
|
||||
playbackRate: 1,
|
||||
position: Math.min(currentTime, duration),
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [currentTime, duration]);
|
||||
|
||||
const seek = (time: number) => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) audio.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
};
|
||||
|
||||
const uploader = current?.uploadedBy;
|
||||
const progressPct = duration ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => {
|
||||
const d = e.currentTarget.duration;
|
||||
if (Number.isFinite(d) && d > 0) setDuration(d);
|
||||
}}
|
||||
onEnded={() => next()}
|
||||
onPlay={registerMediaSession}
|
||||
preload="metadata"
|
||||
/>
|
||||
|
||||
{current && (
|
||||
<>
|
||||
{/* Mini player */}
|
||||
<div className="fixed inset-x-0 bottom-14 z-30 border-t border-white/10 bg-black/50 backdrop-blur-2xl md:bottom-0">
|
||||
<div className="flex items-center gap-3 px-3 py-2 md:px-4 md:py-3">
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
title="Развернуть плеер"
|
||||
>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-lg bg-white/5">
|
||||
<Cover
|
||||
src={current.coverUrl}
|
||||
name={current.title}
|
||||
className="h-11 w-11"
|
||||
/>
|
||||
<span className="absolute inset-0 hidden place-items-center bg-black/40 group-hover:grid">
|
||||
<Maximize2 size={14} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-white">
|
||||
{current.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-400">
|
||||
{(current.artists?.length
|
||||
? current.artists.map((a) => a.name)
|
||||
: [current.artist?.name]
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(", ") || "Неизвестный исполнитель"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="hidden flex-1 items-center gap-2 md:flex">
|
||||
<button
|
||||
className="text-zinc-300 hover:text-white"
|
||||
onClick={prev}
|
||||
title="Назад"
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="grid h-9 w-9 place-items-center rounded-full bg-white text-black transition hover:scale-105"
|
||||
onClick={toggle}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} />
|
||||
) : (
|
||||
<Play size={16} className="translate-x-[1px]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="text-zinc-300 hover:text-white"
|
||||
onClick={next}
|
||||
title="Вперёд"
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
<span className="ml-1 w-10 text-right text-[11px] tabular-nums text-zinc-500">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={1}
|
||||
value={currentTime}
|
||||
onChange={(e) => seek(Number(e.target.value))}
|
||||
className="range-track flex-1"
|
||||
/>
|
||||
<span className="w-10 text-[11px] tabular-nums text-zinc-500">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
<button
|
||||
className={cx(
|
||||
"hidden p-2 sm:inline-flex",
|
||||
shuffle ? "text-accent" : "text-zinc-400 hover:text-white",
|
||||
)}
|
||||
onClick={toggleShuffle}
|
||||
title="Перемешать"
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={cx(
|
||||
"hidden p-2 sm:inline-flex",
|
||||
repeat !== "off"
|
||||
? "text-accent"
|
||||
: "text-zinc-400 hover:text-white",
|
||||
)}
|
||||
onClick={cycleRepeat}
|
||||
title="Повтор"
|
||||
>
|
||||
{repeat === "one" ? (
|
||||
<Repeat1 size={16} />
|
||||
) : (
|
||||
<Repeat size={16} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="p-2 text-zinc-400 hover:text-white"
|
||||
onClick={() => setAddOpen(true)}
|
||||
title="Добавить в плейлист"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 text-zinc-400 hover:text-white"
|
||||
onClick={() => {
|
||||
setTab("comments");
|
||||
setExpanded(true);
|
||||
}}
|
||||
title="Комментарии"
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 text-zinc-400 hover:text-white"
|
||||
onClick={() => setQueueOpen((v) => !v)}
|
||||
title="Очередь"
|
||||
>
|
||||
<ListMusic size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="hidden p-2 text-zinc-400 hover:text-white md:inline-flex"
|
||||
onClick={toggle}
|
||||
>
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-0.5 w-full bg-white/10 md:hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent to-accent2"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{queueOpen && (
|
||||
<div className="absolute bottom-full right-2 mb-2 flex h-80 w-80 max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-white/10 bg-black/85 p-2 backdrop-blur-2xl">
|
||||
<QueuePanel onClose={() => setQueueOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded player */}
|
||||
{expanded && (
|
||||
<div className="fixed inset-0 z-40 flex flex-col animate-fade-in">
|
||||
{/* Background: blurred cover, or a gradient when there's no cover */}
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden bg-gradient-to-br from-accent via-[#0b0d14] to-accent2">
|
||||
{current.coverUrl && (
|
||||
<img
|
||||
src={current.coverUrl}
|
||||
alt=""
|
||||
className="h-full w-full scale-125 object-cover opacity-50 blur-3xl"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black to-black" />
|
||||
</div>
|
||||
|
||||
<header className="flex items-center justify-between px-5 py-4">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<ChevronDown size={16} /> Свернуть
|
||||
</button>
|
||||
<div className="text-xs uppercase tracking-widest text-zinc-400">
|
||||
Сейчас играет
|
||||
</div>
|
||||
<a
|
||||
className="btn-ghost"
|
||||
href={`/api/tracks/${current.id}/stream?download=1`}
|
||||
>
|
||||
<Download size={16} /> Скачать
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-hidden px-5 pb-8 md:flex-row md:items-stretch md:justify-center md:gap-12">
|
||||
{/* Left: artwork + info */}
|
||||
<div className="mx-auto w-full max-w-sm shrink-0 md:mx-0 md:my-auto">
|
||||
<div className="aspect-square overflow-hidden rounded-3xl border border-white/10 bg-white/5 shadow-2xl">
|
||||
{current.coverUrl ? (
|
||||
<img
|
||||
src={current.coverUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center bg-gradient-to-br from-accent/30 to-accent2/30 text-5xl text-white/40">
|
||||
AR
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 text-center md:text-left flex flex-col">
|
||||
<Link
|
||||
to={`/track/${current.id}`}
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-2xl font-bold tracking-tight text-white hover:underline"
|
||||
>
|
||||
{current.title}
|
||||
</Link>
|
||||
{current.album && (
|
||||
<Link
|
||||
to={`/album/${current.album.id}`}
|
||||
onClick={() => setExpanded(false)}
|
||||
className="block text-sm text-zinc-500 hover:text-zinc-300 hover:underline"
|
||||
>
|
||||
{current.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<div className="mt-1 flex items-center justify-center gap-2 md:justify-start">
|
||||
{current.artist && (
|
||||
<Cover
|
||||
src={current.artist.imageUrl}
|
||||
name={current.artist.name}
|
||||
rounded="rounded-full"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
)}
|
||||
<ArtistLinks
|
||||
artists={current.artists}
|
||||
fallback={current.artist?.name}
|
||||
className="text-zinc-300"
|
||||
onNavigate={() => setExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-center gap-2 md:justify-start border border-white/10 bg-white/5 shadow-2xl rounded-3xl p-1">
|
||||
{uploader ? (
|
||||
<>
|
||||
<Link
|
||||
to={`/u/${uploader.username}`}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<Cover
|
||||
src={uploader.avatarUrl}
|
||||
name={uploader.displayName || uploader.username}
|
||||
rounded="rounded-full"
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
</Link>
|
||||
<span className="text-xs text-zinc-500">
|
||||
Добавил:{" "}
|
||||
<Link
|
||||
to={`/u/${uploader.username}`}
|
||||
onClick={() => setExpanded(false)}
|
||||
className="link"
|
||||
>
|
||||
{uploader.displayName || uploader.username}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">
|
||||
Добавил: неизвестно
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: controls + tabbed panel */}
|
||||
<div className="flex min-h-0 w-full max-w-xl flex-1 flex-col">
|
||||
<div className="card shrink-0 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-10 text-right text-[11px] tabular-nums text-zinc-500">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={1}
|
||||
value={currentTime}
|
||||
onChange={(e) => seek(Number(e.target.value))}
|
||||
className="range-track flex-1"
|
||||
/>
|
||||
<span className="w-10 text-[11px] tabular-nums text-zinc-500">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-center gap-4">
|
||||
<button
|
||||
className={cx(
|
||||
shuffle
|
||||
? "text-accent"
|
||||
: "text-zinc-400 hover:text-white",
|
||||
)}
|
||||
onClick={toggleShuffle}
|
||||
title="Перемешать"
|
||||
>
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="text-zinc-200 hover:text-white"
|
||||
onClick={prev}
|
||||
title="Назад"
|
||||
>
|
||||
<SkipBack size={26} />
|
||||
</button>
|
||||
<button
|
||||
className="grid h-14 w-14 place-items-center rounded-full bg-white text-black transition hover:scale-105"
|
||||
onClick={toggle}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={22} />
|
||||
) : (
|
||||
<Play size={22} className="translate-x-[1px]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="text-zinc-200 hover:text-white"
|
||||
onClick={next}
|
||||
title="Вперёд"
|
||||
>
|
||||
<SkipForward size={26} />
|
||||
</button>
|
||||
<button
|
||||
className={cx(
|
||||
repeat !== "off"
|
||||
? "text-accent"
|
||||
: "text-zinc-400 hover:text-white",
|
||||
)}
|
||||
onClick={cycleRepeat}
|
||||
title="Повтор"
|
||||
>
|
||||
{repeat === "one" ? (
|
||||
<Repeat1 size={20} />
|
||||
) : (
|
||||
<Repeat size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 px-2">
|
||||
<Volume2 size={16} className="text-zinc-500" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
className="range-track w-32"
|
||||
/>
|
||||
<button
|
||||
className="btn-ghost ml-auto"
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
<PlusCircle size={15} /> В плейлист
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 gap-1 border-b border-white/10 p-2">
|
||||
<TabButton
|
||||
active={tab === "queue"}
|
||||
onClick={() => setTab("queue")}
|
||||
icon={<ListMusic size={14} />}
|
||||
>
|
||||
Очередь
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={tab === "lyrics"}
|
||||
onClick={() => setTab("lyrics")}
|
||||
icon={<Mic2 size={14} />}
|
||||
>
|
||||
Текст
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={tab === "comments"}
|
||||
onClick={() => setTab("comments")}
|
||||
icon={<MessageSquare size={14} />}
|
||||
>
|
||||
Комментарии
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{tab === "queue" && (
|
||||
<div className="h-full min-h-0 overflow-hidden">
|
||||
<QueuePanel />
|
||||
</div>
|
||||
)}
|
||||
{tab === "lyrics" && (
|
||||
<div className="h-full min-h-0 overflow-y-auto">
|
||||
<LyricsView trackId={current.id} />
|
||||
</div>
|
||||
)}
|
||||
{tab === "comments" && (
|
||||
<div className="h-full min-h-0 overflow-y-auto p-3">
|
||||
<Comments trackId={current.id} compact />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddToPlaylistModal
|
||||
track={current}
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
"flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition",
|
||||
active
|
||||
? "bg-white/10 text-white"
|
||||
: "text-zinc-400 hover:bg-white/5 hover:text-white",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/components/PlaylistCard.tsx
Normal file
21
apps/web/src/components/PlaylistCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Playlist } from "../lib/types";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
export function PlaylistCard({ playlist }: { playlist: Playlist }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/playlist/${playlist.id}`}
|
||||
className="card group p-3 transition hover:bg-white/[0.07]"
|
||||
>
|
||||
<Cover src={playlist.coverUrl} name={playlist.title} className="aspect-square w-full" />
|
||||
<div className="mt-3">
|
||||
<div className="truncate text-sm font-medium text-white">{playlist.title}</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
{playlist.owner?.displayName || playlist.owner?.username || "Плейлист"} ·{" "}
|
||||
{playlist.trackCount} треков
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
121
apps/web/src/components/QueuePanel.tsx
Normal file
121
apps/web/src/components/QueuePanel.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { ChevronDown, ChevronUp, Play, PlusCircle, Trash2, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import type { Track } from "../lib/types";
|
||||
import { usePlayer } from "../store/player";
|
||||
import { AddToPlaylistModal } from "./AddToPlaylistModal";
|
||||
import { ArtistLinks } from "./ArtistLinks";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
export function QueuePanel({ onClose }: { onClose?: () => void }) {
|
||||
const queue = usePlayer((s) => s.queue);
|
||||
const index = usePlayer((s) => s.index);
|
||||
const isPlaying = usePlayer((s) => s.isPlaying);
|
||||
const removeAt = usePlayer((s) => s.removeAt);
|
||||
const move = usePlayer((s) => s.move);
|
||||
const goTo = usePlayer((s) => s.goTo);
|
||||
const toggle = usePlayer((s) => s.toggle);
|
||||
|
||||
const [addTrack, setAddTrack] = useState<Track | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-xs uppercase tracking-wide text-zinc-400">Очередь · {queue.length}</span>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-white">
|
||||
<X size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<p className="px-3 py-6 text-sm text-zinc-500">Очередь пуста.</p>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto px-1 pb-2">
|
||||
{queue.map((track, i) => {
|
||||
const active = i === index;
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${i}`}
|
||||
className={
|
||||
"group flex items-center gap-2 rounded-xl px-2 py-1.5 text-sm transition " +
|
||||
(active ? "bg-white/10" : "hover:bg-white/5")
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="relative h-9 w-9 shrink-0 overflow-hidden rounded-lg"
|
||||
onClick={() => (active ? toggle() : goTo(i))}
|
||||
title="Играть"
|
||||
>
|
||||
<Cover src={track.coverUrl} name={track.title} className="h-9 w-9" />
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 transition group-hover:opacity-100">
|
||||
<Play size={13} className="text-white" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={`/track/${track.id}`}
|
||||
onClick={onClose}
|
||||
className={"block truncate hover:underline " + (active ? "text-white" : "text-zinc-200")}
|
||||
>
|
||||
{track.title}
|
||||
</Link>
|
||||
<div className="truncate text-[11px] text-zinc-500">
|
||||
<ArtistLinks
|
||||
artists={track.artists}
|
||||
fallback={track.artist?.name}
|
||||
linkClassName="hover:text-zinc-300"
|
||||
onNavigate={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-zinc-500">
|
||||
{formatDuration(track.durationMs)}
|
||||
</span>
|
||||
|
||||
<div className="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100">
|
||||
<button
|
||||
className="p-1 text-zinc-400 hover:text-white"
|
||||
onClick={() => setAddTrack(track)}
|
||||
title="Добавить в плейлист"
|
||||
>
|
||||
<PlusCircle size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-zinc-400 hover:text-white disabled:opacity-30"
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, i - 1)}
|
||||
title="Выше"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-zinc-400 hover:text-white disabled:opacity-30"
|
||||
disabled={i === queue.length - 1}
|
||||
onClick={() => move(i, i + 1)}
|
||||
title="Ниже"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-zinc-400 hover:text-red-300"
|
||||
onClick={() => removeAt(i)}
|
||||
title="Убрать из очереди"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddToPlaylistModal track={addTrack} open={Boolean(addTrack)} onClose={() => setAddTrack(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/components/ShareButton.tsx
Normal file
22
apps/web/src/components/ShareButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Check, Share2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ShareButton({ path, label = "Поделиться" }: { path: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
const url = `${window.location.origin}${path}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
window.prompt("Скопируйте ссылку:", url);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
};
|
||||
return (
|
||||
<button className="btn-ghost" onClick={copy}>
|
||||
{copied ? <Check size={15} /> : <Share2 size={15} />}
|
||||
{copied ? "Ссылка скопирована" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
165
apps/web/src/components/TrackRow.tsx
Normal file
165
apps/web/src/components/TrackRow.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Heart, ListPlus, MoreHorizontal, Play, PlusCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { tracksApi } from "../lib/api";
|
||||
import { cx, formatDuration } from "../lib/format";
|
||||
import type { Track } from "../lib/types";
|
||||
import { usePlayer } from "../store/player";
|
||||
import { AddToPlaylistModal } from "./AddToPlaylistModal";
|
||||
import { ArtistLinks } from "./ArtistLinks";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
export function TrackRow({
|
||||
track,
|
||||
list,
|
||||
index,
|
||||
showCover = true,
|
||||
showAlbum = true,
|
||||
showUploader = false,
|
||||
initialLiked = false,
|
||||
right,
|
||||
}: {
|
||||
track: Track;
|
||||
list?: Track[];
|
||||
index?: number;
|
||||
showCover?: boolean;
|
||||
showAlbum?: boolean;
|
||||
showUploader?: boolean;
|
||||
initialLiked?: boolean;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
const playNow = usePlayer((s) => s.playNow);
|
||||
const playNext = usePlayer((s) => s.playNext);
|
||||
const addToQueue = usePlayer((s) => s.addToQueue);
|
||||
const current = usePlayer((s) => s.queue[s.index] ?? null);
|
||||
const isPlaying = usePlayer((s) => s.isPlaying);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [liked, setLiked] = useState(initialLiked);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const like = useMutation({
|
||||
mutationFn: () => (liked ? tracksApi.unlike(track.id) : tracksApi.like(track.id)),
|
||||
onSuccess: () => {
|
||||
setLiked((v) => !v);
|
||||
void qc.invalidateQueries({ queryKey: ["library"] });
|
||||
},
|
||||
});
|
||||
|
||||
const active = current?.id === track.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"group flex items-center gap-3 rounded-xl px-2 py-2 transition hover:bg-white/[0.06]",
|
||||
active && "bg-white/[0.08]",
|
||||
)}
|
||||
>
|
||||
{index !== undefined && (
|
||||
<span className="w-5 shrink-0 text-right text-xs tabular-nums text-zinc-500">{index + 1}</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="relative grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg"
|
||||
onClick={() => playNow(track, list)}
|
||||
title="Играть"
|
||||
>
|
||||
{showCover ? (
|
||||
<Cover src={track.coverUrl} name={track.title} className="h-10 w-10" />
|
||||
) : (
|
||||
<span className="h-10 w-10 rounded-lg bg-white/5" />
|
||||
)}
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 transition group-hover:opacity-100">
|
||||
<Play size={15} className="text-white" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/track/${track.id}`} className="block truncate text-sm font-medium text-white hover:underline">
|
||||
{track.title}
|
||||
</Link>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
<ArtistLinks
|
||||
artists={track.artists}
|
||||
fallback={track.artist?.name}
|
||||
linkClassName="hover:text-zinc-300"
|
||||
/>
|
||||
{showUploader && track.uploadedBy && (
|
||||
<span className="text-zinc-600"> · загрузил {track.uploadedBy.username}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAlbum && track.album && (
|
||||
<Link
|
||||
to={`/album/${track.album.id}`}
|
||||
className="hidden max-w-[180px] truncate text-xs text-zinc-500 hover:text-zinc-300 lg:block"
|
||||
>
|
||||
{track.album.title}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{right}
|
||||
|
||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-zinc-500">
|
||||
{formatDuration(track.durationMs)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
className={cx("shrink-0 p-1.5", liked ? "text-rose-400" : "text-zinc-500 hover:text-white")}
|
||||
onClick={() => like.mutate()}
|
||||
title="В избранное"
|
||||
>
|
||||
<Heart size={15} fill={liked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
className="p-1.5 text-zinc-500 hover:text-white"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
title="Ещё"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-8 z-20 w-52 rounded-xl border border-white/10 bg-[#0d0f16]/95 p-1 shadow-2xl backdrop-blur-xl">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
|
||||
onClick={() => {
|
||||
addToQueue(track);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ListPlus size={14} /> В очередь
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
|
||||
onClick={() => {
|
||||
playNext(track);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Play size={14} /> Играть следующим
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
|
||||
onClick={() => {
|
||||
setModalOpen(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<PlusCircle size={14} /> Добавить в плейлист
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddToPlaylistModal track={track} open={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/index.css
Normal file
82
apps/web/src/index.css
Normal file
@@ -0,0 +1,82 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg text-zinc-100 font-sans antialiased;
|
||||
background-image:
|
||||
radial-gradient(1200px 600px at 80% -10%, rgba(139, 92, 246, 0.18), transparent 60%),
|
||||
radial-gradient(900px 500px at -10% 20%, rgba(34, 211, 238, 0.12), transparent 55%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply bg-white/[0.045] border border-white/10 backdrop-blur-xl;
|
||||
}
|
||||
.glass-strong {
|
||||
@apply bg-black/40 border border-white/10 backdrop-blur-2xl;
|
||||
}
|
||||
.card {
|
||||
@apply glass rounded-2xl;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition
|
||||
disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-gradient-to-r from-accent to-accent2 text-black hover:brightness-110;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn bg-white/5 hover:bg-white/10 text-zinc-100 border border-white/10;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-red-500/15 hover:bg-red-500/25 text-red-200 border border-red-400/20;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-sm text-zinc-100
|
||||
placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-accent/60;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1.5 block text-xs font-medium uppercase tracking-wide text-zinc-400;
|
||||
}
|
||||
.chip {
|
||||
@apply inline-flex items-center rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-zinc-300;
|
||||
}
|
||||
.link {
|
||||
@apply text-zinc-300 hover:text-white transition-colors;
|
||||
}
|
||||
.section-title {
|
||||
@apply text-lg font-semibold tracking-tight text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.range-track input[type="range"] {
|
||||
@apply h-1 w-full cursor-pointer appearance-none rounded-full bg-white/15;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
@apply h-3 w-3 appearance-none rounded-full bg-white;
|
||||
}
|
||||
209
apps/web/src/lib/api.ts
Normal file
209
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import axios from "axios";
|
||||
import type {
|
||||
Album,
|
||||
Artist,
|
||||
Comment,
|
||||
Lyrics,
|
||||
Playlist,
|
||||
PlaylistEntry,
|
||||
PublicUser,
|
||||
SelfUser,
|
||||
Track,
|
||||
UploadStatus,
|
||||
} from "./types";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: "/api",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export interface SearchResults {
|
||||
tracks: Track[];
|
||||
artists: Artist[];
|
||||
albums: Album[];
|
||||
playlists: Playlist[];
|
||||
users: PublicUser[];
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
session: () => api.get<{ user: SelfUser | null }>("/auth/session").then((r) => r.data.user),
|
||||
login: (login: string, password: string) =>
|
||||
api.post<{ user: SelfUser }>("/auth/login", { login, password }).then((r) => r.data.user),
|
||||
logout: () => api.post("/auth/logout").then((r) => r.data),
|
||||
};
|
||||
|
||||
export const meApi = {
|
||||
update: (data: Partial<Pick<SelfUser, "displayName" | "bio" | "avatarUrl">>) =>
|
||||
api.patch<{ user: SelfUser }>("/me", data).then((r) => r.data.user),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api.post("/me/password", { currentPassword, newPassword }).then((r) => r.data),
|
||||
uploadAvatar: (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("avatar", file);
|
||||
return api.post<{ user: SelfUser }>("/me/avatar", form).then((r) => r.data.user);
|
||||
},
|
||||
};
|
||||
|
||||
export const tracksApi = {
|
||||
random: (limit = 20) =>
|
||||
api.get<{ items: Track[] }>("/tracks/random", { params: { limit } }).then((r) => r.data.items),
|
||||
list: (params: { q?: string; artistId?: string; albumId?: string; limit?: number; offset?: number } = {}) =>
|
||||
api.get<{ items: Track[]; total: number }>("/tracks", { params }).then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
api
|
||||
.get<{ track: Track; lyrics: Lyrics; liked: boolean; canEdit: boolean }>(`/tracks/${id}`)
|
||||
.then((r) => r.data),
|
||||
update: (
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
artistId?: string | null;
|
||||
albumId?: string | null;
|
||||
trackNumber?: number | null;
|
||||
discNumber?: number | null;
|
||||
releaseDate?: string | null;
|
||||
coverUrl?: string | null;
|
||||
durationMs?: number | null;
|
||||
lyricsPlain?: string | null;
|
||||
artists?: string[];
|
||||
},
|
||||
) => api.patch<{ track: Track }>(`/tracks/${id}`, data).then((r) => r.data.track),
|
||||
uploadCover: (id: string, file: File, applyToAlbum = false) => {
|
||||
const form = new FormData();
|
||||
form.append("cover", file);
|
||||
return api
|
||||
.post<{ track: Track }>(`/tracks/${id}/cover`, form, {
|
||||
params: applyToAlbum ? { album: "1" } : {},
|
||||
})
|
||||
.then((r) => r.data.track);
|
||||
},
|
||||
remove: (id: string) => api.delete(`/tracks/${id}`).then((r) => r.data),
|
||||
like: (id: string) => api.post(`/tracks/${id}/like`).then((r) => r.data),
|
||||
unlike: (id: string) => api.delete(`/tracks/${id}/like`).then((r) => r.data),
|
||||
comments: (id: string) =>
|
||||
api.get<{ comments: Comment[]; total: number }>(`/tracks/${id}/comments`).then((r) => r.data),
|
||||
addComment: (id: string, body: string, parentId?: string | null) =>
|
||||
api.post<{ comment: Comment }>(`/tracks/${id}/comments`, { body, parentId }).then((r) => r.data),
|
||||
deleteComment: (commentId: string) => api.delete(`/comments/${commentId}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const uploadsApi = {
|
||||
mine: () => api.get<UploadStatus>("/uploads/mine").then((r) => r.data),
|
||||
upload: (file: File, onProgress?: (p: number) => void) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return api
|
||||
.post<{ track: Track }>("/uploads", form, {
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
},
|
||||
})
|
||||
.then((r) => r.data.track);
|
||||
},
|
||||
remove: (id: string) => api.delete(`/uploads/${id}`).then((r) => r.data),
|
||||
retry: (id: string) => api.post(`/uploads/${id}/retry`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const artistsApi = {
|
||||
get: (id: string) =>
|
||||
api
|
||||
.get<{
|
||||
artist: Artist;
|
||||
following: boolean;
|
||||
albums: Album[];
|
||||
canEdit: boolean;
|
||||
}>(`/artists/${id}`)
|
||||
.then((r) => r.data),
|
||||
tracks: (id: string) => api.get<{ items: Track[] }>(`/artists/${id}/tracks`).then((r) => r.data.items),
|
||||
follow: (id: string) => api.post(`/artists/${id}/follow`).then((r) => r.data),
|
||||
unfollow: (id: string) => api.delete(`/artists/${id}/follow`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const albumsApi = {
|
||||
get: (id: string) =>
|
||||
api
|
||||
.get<{ album: Album; tracks: Track[]; canEdit: boolean }>(`/albums/${id}`)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
export const playlistsApi = {
|
||||
mine: () => api.get<{ owned: Playlist[] }>("/me/playlists").then((r) => r.data.owned),
|
||||
get: (id: string) =>
|
||||
api
|
||||
.get<{
|
||||
playlist: Playlist;
|
||||
entries: PlaylistEntry[];
|
||||
collaborators: { userId: string; canEdit: boolean }[];
|
||||
inLibrary: boolean;
|
||||
libraryMode: "reference" | "copy" | null;
|
||||
}>(`/playlists/${id}`)
|
||||
.then((r) => r.data),
|
||||
create: (data: Partial<Playlist> & { title: string }) =>
|
||||
api.post<{ playlist: Playlist }>("/playlists", data).then((r) => r.data.playlist),
|
||||
update: (id: string, data: Partial<Playlist>) =>
|
||||
api.patch<{ playlist: Playlist }>(`/playlists/${id}`, data).then((r) => r.data.playlist),
|
||||
remove: (id: string) => api.delete(`/playlists/${id}`).then((r) => r.data),
|
||||
addTrack: (id: string, trackId: string, position?: number) =>
|
||||
api.post(`/playlists/${id}/tracks`, { trackId, position }).then((r) => r.data),
|
||||
removeTrack: (id: string, entryId: string) =>
|
||||
api.delete(`/playlists/${id}/tracks/${entryId}`).then((r) => r.data),
|
||||
reorder: (id: string, entryIds: string[]) =>
|
||||
api.put(`/playlists/${id}/order`, { entryIds }).then((r) => r.data),
|
||||
addToLibrary: (id: string, mode: "reference" | "copy") =>
|
||||
api.post(`/playlists/${id}/library`, { mode }).then((r) => r.data),
|
||||
removeFromLibrary: (id: string) => api.delete(`/playlists/${id}/library`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const libraryApi = {
|
||||
get: () =>
|
||||
api
|
||||
.get<{
|
||||
ownedPlaylists: Playlist[];
|
||||
savedPlaylists: {
|
||||
mode: string;
|
||||
canEdit: boolean;
|
||||
originalPlaylistId: string;
|
||||
playlist: Playlist;
|
||||
}[];
|
||||
followedArtists: Artist[];
|
||||
likedTracks: Track[];
|
||||
}>("/library")
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
export const searchApi = {
|
||||
query: (q: string, type = "all") =>
|
||||
api.get<SearchResults>("/search", { params: { q, type } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const usersApi = {
|
||||
get: (username: string) =>
|
||||
api
|
||||
.get<{ user: PublicUser; playlists: Playlist[]; trackCount: number }>(`/users/${username}`)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
export const adminApi = {
|
||||
stats: () => api.get("/admin/stats").then((r) => r.data),
|
||||
users: () => api.get<{ items: SelfUser[] }>("/admin/users").then((r) => r.data.items),
|
||||
createUser: (data: {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
permissions?: Partial<SelfUser["permissions"]>;
|
||||
}) => api.post<{ user: SelfUser }>("/admin/users", data).then((r) => r.data.user),
|
||||
updateUser: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<{ user: SelfUser }>(`/admin/users/${id}`, data).then((r) => r.data.user),
|
||||
resetPassword: (id: string, password: string) =>
|
||||
api.post(`/admin/users/${id}/password`, { password }).then((r) => r.data),
|
||||
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
|
||||
moderation: () =>
|
||||
api.get<{ items: Track[]; total: number }>("/admin/moderation").then((r) => r.data),
|
||||
approve: (id: string) => api.post(`/admin/tracks/${id}/approve`).then((r) => r.data),
|
||||
reject: (id: string, reason?: string) =>
|
||||
api.post(`/admin/tracks/${id}/reject`, { reason }).then((r) => r.data),
|
||||
playlists: () => api.get<{ items: Playlist[]; total: number }>("/admin/playlists").then((r) => r.data),
|
||||
tracks: (params: { status?: string; q?: string } = {}) =>
|
||||
api.get<{ items: Track[]; total: number }>("/admin/tracks", { params }).then((r) => r.data),
|
||||
};
|
||||
35
apps/web/src/lib/format.ts
Normal file
35
apps/web/src/lib/format.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export function formatDuration(ms: number | null | undefined): string {
|
||||
if (!ms || ms <= 0) return "--:--";
|
||||
const total = Math.floor(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
|
||||
const total = Math.floor(seconds);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
export function initials(name: string | null | undefined): string {
|
||||
if (!name) return "?";
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function cx(...parts: (string | false | null | undefined)[]): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
16
apps/web/src/lib/permissions.ts
Normal file
16
apps/web/src/lib/permissions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Permissions, SelfUser } from "./types";
|
||||
|
||||
export function hasPerm(
|
||||
user: Pick<SelfUser, "isSuperAdmin" | "permissions"> | null | undefined,
|
||||
key: keyof Permissions,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (user.isSuperAdmin) return true;
|
||||
return Boolean(user.permissions?.[key]);
|
||||
}
|
||||
|
||||
export function canAccessAdmin(user: Pick<SelfUser, "isSuperAdmin" | "permissions">): boolean {
|
||||
if (user.isSuperAdmin) return true;
|
||||
const p = user.permissions;
|
||||
return Boolean(p && (p.canModerate || p.canManageUsers || p.canManageLibrary || p.canViewAllPlaylists));
|
||||
}
|
||||
130
apps/web/src/lib/types.ts
Normal file
130
apps/web/src/lib/types.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
export interface Permissions {
|
||||
canCreatePlaylists: boolean;
|
||||
canUploadTracks: boolean;
|
||||
canAutoApprove: boolean;
|
||||
canModerate: boolean;
|
||||
canManageUsers: boolean;
|
||||
canManageLibrary: boolean;
|
||||
canViewAllPlaylists: boolean;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
bio: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SelfUser extends PublicUser {
|
||||
email: string;
|
||||
isSuperAdmin: boolean;
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
export interface ArtistLite {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface TrackArtistCredit {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AlbumLite {
|
||||
id: string;
|
||||
title: string;
|
||||
coverUrl: string | null;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
durationMs: number | null;
|
||||
trackNumber: number | null;
|
||||
discNumber: number | null;
|
||||
releaseDate: string | null;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
rejectionReason: string | null;
|
||||
coverUrl: string | null;
|
||||
playCount: number;
|
||||
hasLyrics: boolean;
|
||||
lyricsSource: string | null;
|
||||
createdAt: string;
|
||||
approvedAt: string | null;
|
||||
artist: ArtistLite | null;
|
||||
artists: TrackArtistCredit[];
|
||||
album: AlbumLite | null;
|
||||
uploadedBy?: PublicUser | null;
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
sortName: string | null;
|
||||
description: string | null;
|
||||
imageUrl: string | null;
|
||||
country: string | null;
|
||||
mbArtistId: string | null;
|
||||
followersCount: number;
|
||||
trackCount?: number;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
releaseDate: string | null;
|
||||
coverUrl: string | null;
|
||||
artist: ArtistLite | null;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
coverUrl: string | null;
|
||||
visibility: "public" | "unlisted" | "private";
|
||||
isShareable: boolean;
|
||||
allowCollaboratorEdit: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
owner: PublicUser | null;
|
||||
trackCount: number;
|
||||
canEdit: boolean;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export interface PlaylistEntry {
|
||||
entryId: string;
|
||||
position: number;
|
||||
addedBy: PublicUser | null;
|
||||
track: Track | null;
|
||||
}
|
||||
|
||||
export interface Lyrics {
|
||||
plain: string | null;
|
||||
synced: { timeMs: number; text: string }[] | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
trackId: string;
|
||||
parentId: string | null;
|
||||
body: string | null;
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: PublicUser | null;
|
||||
replies: Comment[];
|
||||
}
|
||||
|
||||
export interface UploadStatus {
|
||||
pending: Track[];
|
||||
rejected: Track[];
|
||||
}
|
||||
29
apps/web/src/main.tsx
Normal file
29
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
import { AuthProvider } from "./store/auth";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
190
apps/web/src/pages/Account.tsx
Normal file
190
apps/web/src/pages/Account.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { meApi } from "../lib/api";
|
||||
import { hasPerm } from "../lib/permissions";
|
||||
import { useAuth } from "../store/auth";
|
||||
import { Cover } from "../components/Cover";
|
||||
|
||||
export function Account() {
|
||||
const { user, setUser, logout } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [profile, setProfile] = useState({
|
||||
displayName: user?.displayName ?? "",
|
||||
bio: user?.bio ?? "",
|
||||
});
|
||||
const [pw, setPw] = useState({ current: "", next: "" });
|
||||
const [msg, setMsg] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const saveProfile = useMutation({
|
||||
mutationFn: () => meApi.update({ displayName: profile.displayName, bio: profile.bio }),
|
||||
onSuccess: (u) => {
|
||||
setUser(u);
|
||||
setMsg("Профиль сохранён");
|
||||
setErr("");
|
||||
},
|
||||
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
|
||||
});
|
||||
|
||||
const changePw = useMutation({
|
||||
mutationFn: () => meApi.changePassword(pw.current, pw.next),
|
||||
onSuccess: () => {
|
||||
setPw({ current: "", next: "" });
|
||||
setMsg("Пароль изменён");
|
||||
setErr("");
|
||||
},
|
||||
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
|
||||
});
|
||||
|
||||
const uploadAvatar = useMutation({
|
||||
mutationFn: (file: File) => meApi.uploadAvatar(file),
|
||||
onSuccess: (u) => {
|
||||
setUser(u);
|
||||
setMsg("Аватар обновлён");
|
||||
},
|
||||
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const permList: { key: keyof typeof user.permissions; label: string }[] = [
|
||||
{ key: "canCreatePlaylists", label: "Создание плейлистов" },
|
||||
{ key: "canUploadTracks", label: "Загрузка треков" },
|
||||
{ key: "canAutoApprove", label: "Автопринятие без модерации" },
|
||||
{ key: "canModerate", label: "Модерация" },
|
||||
{ key: "canManageUsers", label: "Управление пользователями" },
|
||||
{ key: "canManageLibrary", label: "Управление библиотекой" },
|
||||
{ key: "canViewAllPlaylists", label: "Просмотр всех плейлистов" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl animate-fade-in">
|
||||
<h1 className="mb-5 text-2xl font-bold tracking-tight text-white">Аккаунт</h1>
|
||||
|
||||
{(msg || err) && (
|
||||
<div
|
||||
className={
|
||||
"mb-4 rounded-xl px-3 py-2 text-sm " +
|
||||
(err
|
||||
? "border border-red-400/20 bg-red-500/10 text-red-200"
|
||||
: "border border-emerald-400/20 bg-emerald-500/10 text-emerald-200")
|
||||
}
|
||||
>
|
||||
{err || msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mb-5 flex flex-col items-center gap-4 p-6 sm:flex-row sm:items-start">
|
||||
<button className="relative" onClick={() => fileRef.current?.click()} title="Сменить аватар">
|
||||
<Cover src={user.avatarUrl} name={user.displayName || user.username} rounded="rounded-full" className="h-24 w-24" />
|
||||
<span className="absolute -bottom-1 right-0 chip bg-black/70">изменить</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) uploadAvatar.mutate(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<div className="text-xl font-semibold text-white">{user.displayName || user.username}</div>
|
||||
<div className="text-sm text-zinc-500">@{user.username}</div>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-1 sm:justify-start">
|
||||
{user.isSuperAdmin && <span className="chip text-accent">супер-админ</span>}
|
||||
{permList
|
||||
.filter((p) => hasPerm(user, p.key))
|
||||
.map((p) => (
|
||||
<span key={p.key} className="chip">
|
||||
{p.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-danger" onClick={logout}>
|
||||
<LogOut size={15} /> Выйти
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<section className="card p-5">
|
||||
<h2 className="section-title mb-4">Профиль</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Отображаемое имя</label>
|
||||
<input
|
||||
className="input"
|
||||
value={profile.displayName}
|
||||
onChange={(e) => setProfile({ ...profile, displayName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">О себе</label>
|
||||
<textarea
|
||||
className="input min-h-24"
|
||||
value={profile.bio}
|
||||
onChange={(e) => setProfile({ ...profile, bio: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Почта</label>
|
||||
<input className="input opacity-60" value={user.email} disabled />
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => saveProfile.mutate()} disabled={saveProfile.isPending}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<h2 className="section-title mb-4">Смена пароля</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Текущий пароль</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={pw.current}
|
||||
onChange={(e) => setPw({ ...pw, current: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Новый пароль</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={pw.next}
|
||||
onChange={(e) => setPw({ ...pw, next: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => changePw.mutate()}
|
||||
disabled={changePw.isPending || !pw.current || pw.next.length < 6}
|
||||
>
|
||||
Изменить пароль
|
||||
</button>
|
||||
<p className="text-xs text-zinc-600">
|
||||
При смене пароля все другие сессии завершаются автоматически.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 card p-5">
|
||||
<h2 className="section-title mb-3">Ссылки</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a className="btn-ghost" href={`/u/${user.username}`}>
|
||||
Мой публичный профиль
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
apps/web/src/pages/AddTrack.tsx
Normal file
262
apps/web/src/pages/AddTrack.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, CheckCircle2, Clock, RefreshCw, Trash2, UploadCloud } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { uploadsApi } from "../lib/api";
|
||||
import { cx, formatDuration } from "../lib/format";
|
||||
import { hasPerm } from "../lib/permissions";
|
||||
import type { Track } from "../lib/types";
|
||||
import { useAuth } from "../store/auth";
|
||||
import { Cover } from "../components/Cover";
|
||||
|
||||
export function AddTrack() {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [duplicate, setDuplicate] = useState<{ id: string; title: string } | null>(null);
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const canUpload = hasPerm(user, "canUploadTracks");
|
||||
const autoApprove = hasPerm(user, "canAutoApprove");
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["uploads-mine"],
|
||||
queryFn: uploadsApi.mine,
|
||||
enabled: canUpload,
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: (files: File[]) => {
|
||||
const uploadNext = (i: number): Promise<void> => {
|
||||
if (i >= files.length) return Promise.resolve();
|
||||
return uploadsApi
|
||||
.upload(files[i], setProgress)
|
||||
.then(() => {
|
||||
setSuccess(`«${files[i].name}» загружен и отправлен на обработку`);
|
||||
return uploadNext(i + 1);
|
||||
});
|
||||
};
|
||||
return uploadNext(0);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setProgress(null);
|
||||
void qc.invalidateQueries({ queryKey: ["uploads-mine"] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setProgress(null);
|
||||
const details = err?.response?.data?.details;
|
||||
if (details?.trackId) {
|
||||
setDuplicate({ id: details.trackId, title: details.title });
|
||||
setError("");
|
||||
} else {
|
||||
setDuplicate(null);
|
||||
setError(err?.response?.data?.error ?? "Ошибка загрузки");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => uploadsApi.remove(id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ["uploads-mine"] }),
|
||||
});
|
||||
|
||||
const retry = useMutation({
|
||||
mutationFn: (id: string) => uploadsApi.retry(id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ["uploads-mine"] }),
|
||||
});
|
||||
|
||||
if (!canUpload) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl animate-fade-in">
|
||||
<div className="card p-8 text-center">
|
||||
<AlertTriangle className="mx-auto mb-3 text-amber-400" />
|
||||
<h2 className="text-lg font-semibold text-white">Загрузка недоступна</h2>
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
У вашей учётной записи нет разрешения на загрузку треков. Обратитесь к администратору.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pending = data?.pending ?? [];
|
||||
const rejected = data?.rejected ?? [];
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setDuplicate(null);
|
||||
upload.mutate(Array.from(files));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl animate-fade-in">
|
||||
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Добавить музыку</h1>
|
||||
<p className="mb-5 text-sm text-zinc-500">
|
||||
Файл загрузится, определится дубликат, затем из интернета подтянутся название, исполнитель,
|
||||
обложка, альбом и текст песни. После этого трек уйдёт на модерацию.
|
||||
{autoApprove && " Вам разрешено автопринятие — треки попадут в библиотеку сразу."}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="card mb-6 grid cursor-pointer place-items-center border-dashed p-8 text-center transition hover:bg-white/[0.06]"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<UploadCloud className="mb-3 text-accent" size={32} />
|
||||
<p className="text-sm text-zinc-300">Перетащите аудиофайлы сюда или нажмите для выбора</p>
|
||||
<p className="mt-1 text-xs text-zinc-600">MP3, FLAC, WAV, OGG, M4A, AAC, OPUS · до 150 МБ</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{progress !== null && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex justify-between text-xs text-zinc-400">
|
||||
<span>Загрузка...</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div className="h-full bg-gradient-to-r from-accent to-accent2 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{duplicate && (
|
||||
<div className="mb-4 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-100">
|
||||
Дубликат: «{duplicate.title}» уже есть в библиотеке.{" "}
|
||||
<Link to={`/track/${duplicate.id}`} className="underline hover:text-white">
|
||||
Открыть трек →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-4 rounded-xl border border-emerald-400/20 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="section-title flex items-center gap-2">
|
||||
<Clock size={16} /> На обработке / модерации
|
||||
</h2>
|
||||
<button className="btn-ghost" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? "animate-spin" : ""} /> Обновить
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-zinc-500">Загрузка...</p>
|
||||
) : pending.length ? (
|
||||
<div className="space-y-2">
|
||||
{pending.map((t) => (
|
||||
<StatusRow key={t.id} track={t} kind="pending" onRemove={() => remove.mutate(t.id)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Нет треков в обработке.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="section-title mb-3 flex items-center gap-2 text-red-200">
|
||||
<AlertTriangle size={16} /> Отклонённые
|
||||
</h2>
|
||||
{rejected.length ? (
|
||||
<div className="space-y-2">
|
||||
{rejected.map((t) => (
|
||||
<StatusRow
|
||||
key={t.id}
|
||||
track={t}
|
||||
kind="rejected"
|
||||
onRemove={() => remove.mutate(t.id)}
|
||||
onRetry={() => retry.mutate(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Отклонённых треков нет.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
track,
|
||||
kind,
|
||||
onRemove,
|
||||
onRetry,
|
||||
}: {
|
||||
track: Track;
|
||||
kind: "pending" | "rejected";
|
||||
onRemove: () => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const processing = (track as any).status === "pending";
|
||||
return (
|
||||
<div className="card flex items-center gap-3 p-3">
|
||||
<Cover src={track.coverUrl} name={track.title} className="h-12 w-12 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-white">{track.title}</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
{track.artist?.name ?? "Исполнитель определяется..."}
|
||||
{track.durationMs ? ` · ${formatDuration(track.durationMs)}` : ""}
|
||||
</div>
|
||||
{kind === "rejected" && track.rejectionReason && (
|
||||
<div className="mt-1 text-xs text-red-300">Причина: {track.rejectionReason}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cx(
|
||||
"chip hidden sm:inline-flex",
|
||||
kind === "rejected" ? "text-red-200" : "text-amber-200",
|
||||
)}
|
||||
>
|
||||
{kind === "rejected" ? (
|
||||
<>
|
||||
<AlertTriangle size={12} className="mr-1" /> Отклонён
|
||||
</>
|
||||
) : processing ? (
|
||||
<>
|
||||
<Clock size={12} className="mr-1" /> Обработка
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={12} className="mr-1" /> На модерации
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{onRetry && (
|
||||
<button className="btn-ghost" onClick={onRetry} title="Повторить обработку">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-danger" onClick={onRemove} title="Удалить">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
652
apps/web/src/pages/Admin.tsx
Normal file
652
apps/web/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,652 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BarChart3,
|
||||
Check,
|
||||
Library,
|
||||
ListMusic,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Trash2,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { adminApi, playlistsApi, tracksApi } from "../lib/api";
|
||||
import { cx, formatDuration } from "../lib/format";
|
||||
import { canAccessAdmin, hasPerm } from "../lib/permissions";
|
||||
import type { SelfUser, Track } from "../lib/types";
|
||||
import { useAuth } from "../store/auth";
|
||||
import { Cover } from "../components/Cover";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { TrackRow } from "../components/TrackRow";
|
||||
|
||||
type Section = "overview" | "moderation" | "users" | "playlists" | "library";
|
||||
|
||||
export function Admin() {
|
||||
const { user } = useAuth();
|
||||
const { section } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!user || !canAccessAdmin(user)) return <Navigate to="/" replace />;
|
||||
|
||||
const tabs: { id: Section; label: string; icon: any; show: boolean }[] = [
|
||||
{ id: "overview", label: "Обзор", icon: BarChart3, show: true },
|
||||
{ id: "moderation", label: "Модерация", icon: Shield, show: hasPerm(user, "canModerate") },
|
||||
{ id: "library", label: "Библиотека", icon: Library, show: hasPerm(user, "canManageLibrary") },
|
||||
{ id: "users", label: "Пользователи", icon: Users, show: hasPerm(user, "canManageUsers") },
|
||||
{ id: "playlists", label: "Плейлисты", icon: ListMusic, show: hasPerm(user, "canViewAllPlaylists") },
|
||||
];
|
||||
const visible = tabs.filter((t) => t.show);
|
||||
const current = (visible.find((t) => t.id === section)?.id ?? "overview") as Section;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl animate-fade-in">
|
||||
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Админ-панель</h1>
|
||||
<p className="mb-5 text-sm text-zinc-500">
|
||||
Управление модерацией, пользователями, разрешениями и всей библиотекой сервиса.
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{visible.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => navigate(`/admin/${t.id}`)}
|
||||
className={cx(
|
||||
"flex items-center gap-2 rounded-full px-4 py-1.5 text-sm transition",
|
||||
current === t.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<t.icon size={15} /> {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{current === "overview" && <Overview />}
|
||||
{current === "moderation" && <Moderation />}
|
||||
{current === "library" && <LibraryAdmin />}
|
||||
{current === "users" && <UsersAdmin currentUser={user} />}
|
||||
{current === "playlists" && <PlaylistsAdmin />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Overview() {
|
||||
const { data } = useQuery({ queryKey: ["admin-stats"], queryFn: adminApi.stats });
|
||||
const cards = [
|
||||
{ label: "Всего треков", value: data?.tracks.total, sub: `${data?.tracks.approved ?? 0} одобрено` },
|
||||
{ label: "На модерации", value: data?.tracks.pending, sub: "ожидают решения" },
|
||||
{ label: "Отклонено", value: data?.tracks.rejected, sub: "не в библиотеке" },
|
||||
{ label: "Пользователи", value: data?.users, sub: "аккаунтов" },
|
||||
{ label: "Плейлисты", value: data?.playlists, sub: "всего" },
|
||||
{ label: "Артисты", value: data?.artists, sub: "в базе" },
|
||||
{ label: "Комментарии", value: data?.comments, sub: "всего" },
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="card p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">{c.label}</div>
|
||||
<div className="mt-2 text-3xl font-bold text-white">{c.value ?? "—"}</div>
|
||||
<div className="text-xs text-zinc-500">{c.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Moderation() {
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState<Track | null>(null);
|
||||
const { data, isLoading, refetch } = useQuery({ queryKey: ["admin-moderation"], queryFn: adminApi.moderation });
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin-moderation"] });
|
||||
void qc.invalidateQueries({ queryKey: ["admin-stats"] });
|
||||
};
|
||||
|
||||
const approve = useMutation({ mutationFn: (id: string) => adminApi.approve(id), onSuccess: invalidate });
|
||||
const reject = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) => adminApi.reject(id, reason),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="section-title">Очередь модерации ({data?.total ?? 0})</h2>
|
||||
<button className="btn-ghost" onClick={() => refetch()}>
|
||||
<RefreshCw size={14} /> Обновить
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-zinc-500">Загрузка...</p>
|
||||
) : data?.items.length ? (
|
||||
<div className="space-y-2">
|
||||
{data.items.map((t) => (
|
||||
<div key={t.id} className="card flex flex-wrap items-center gap-3 p-3">
|
||||
<Cover src={t.coverUrl} name={t.title} className="h-14 w-14 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-white">{t.title}</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
{t.artist?.name ?? "—"}
|
||||
{t.album ? ` · ${t.album.title}` : ""}
|
||||
{t.durationMs ? ` · ${formatDuration(t.durationMs)}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-600">
|
||||
загрузил {t.uploadedBy?.username ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-ghost" onClick={() => setEditing(t)}>
|
||||
<Pencil size={14} /> Правки
|
||||
</button>
|
||||
<button className="btn-primary" onClick={() => approve.mutate(t.id)}>
|
||||
<Check size={14} /> Принять
|
||||
</button>
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => {
|
||||
const reason = prompt("Причина отклонения:", "Не соответствует качеству") ?? "";
|
||||
reject.mutate({ id: t.id, reason });
|
||||
}}
|
||||
>
|
||||
<X size={14} /> Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Очередь пуста.</p>
|
||||
)}
|
||||
|
||||
<TrackEditModal track={editing} onClose={() => setEditing(null)} onSaved={invalidate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryAdmin() {
|
||||
const qc = useQueryClient();
|
||||
const [status, setStatus] = useState("approved");
|
||||
const [q, setQ] = useState("");
|
||||
const [editing, setEditing] = useState<Track | null>(null);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin-tracks", status, q],
|
||||
queryFn: () => adminApi.tracks({ status: status || undefined, q }),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => tracksApi.remove(id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin-tracks"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<select className="input max-w-40" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="approved">Одобренные</option>
|
||||
<option value="pending">На модерации</option>
|
||||
<option value="rejected">Отклонённые</option>
|
||||
</select>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
placeholder="Название..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-zinc-500">Загрузка...</p>
|
||||
) : (
|
||||
<div className="card p-2">
|
||||
{data?.items.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.id}
|
||||
track={t}
|
||||
index={i}
|
||||
showUploader
|
||||
right={
|
||||
<div className="flex items-center">
|
||||
<button className="p-1 text-zinc-500 hover:text-white" onClick={() => setEditing(t)} title="Правки">
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-zinc-500 hover:text-red-300"
|
||||
onClick={() => confirm("Удалить трек?") && remove.mutate(t.id)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{data?.items.length === 0 && <p className="p-4 text-sm text-zinc-500">Нет треков.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TrackEditModal
|
||||
track={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => void qc.invalidateQueries({ queryKey: ["admin-tracks"] })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackEditModal({
|
||||
track,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
track: Track | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
title: track?.title ?? "",
|
||||
releaseDate: track?.releaseDate ?? "",
|
||||
coverUrl: track?.coverUrl ?? "",
|
||||
trackNumber: track?.trackNumber ?? 0,
|
||||
durationMs: track?.durationMs ?? 0,
|
||||
artistsText: track
|
||||
? track.artists?.length
|
||||
? track.artists.map((a) => a.name).join(", ")
|
||||
: track.artist?.name ?? ""
|
||||
: "",
|
||||
});
|
||||
// reset when track changes
|
||||
const [lastId, setLastId] = useState(track?.id);
|
||||
if (track && track.id !== lastId) {
|
||||
setLastId(track.id);
|
||||
setForm({
|
||||
title: track.title,
|
||||
releaseDate: track.releaseDate ?? "",
|
||||
coverUrl: track.coverUrl ?? "",
|
||||
trackNumber: track.trackNumber ?? 0,
|
||||
durationMs: track.durationMs ?? 0,
|
||||
artistsText: track.artists?.length
|
||||
? track.artists.map((a) => a.name).join(", ")
|
||||
: track.artist?.name ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
tracksApi.update(track!.id, {
|
||||
title: form.title,
|
||||
releaseDate: form.releaseDate || null,
|
||||
coverUrl: form.coverUrl || null,
|
||||
trackNumber: Number(form.trackNumber) || null,
|
||||
durationMs: Number(form.durationMs) || null,
|
||||
artists: form.artistsText
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onSaved();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
const [applyToAlbum, setApplyToAlbum] = useState(false);
|
||||
const uploadCover = useMutation({
|
||||
mutationFn: (file: File) => tracksApi.uploadCover(track!.id, file, applyToAlbum),
|
||||
onSuccess: (updated) => {
|
||||
setForm((f) => ({ ...f, coverUrl: updated.coverUrl ?? "" }));
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={Boolean(track)} onClose={onClose} title="Редактирование метаданных">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Название</label>
|
||||
<input className="input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Исполнители (через запятую)</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Исполнитель 1, Исполнитель 2"
|
||||
value={form.artistsText}
|
||||
onChange={(e) => setForm({ ...form, artistsText: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
Каждое имя станет отдельным артистом со своей страницей.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Дата выхода</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="YYYY-MM-DD"
|
||||
value={form.releaseDate}
|
||||
onChange={(e) => setForm({ ...form, releaseDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Номер трека</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.trackNumber}
|
||||
onChange={(e) => setForm({ ...form, trackNumber: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Обложка</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Cover src={form.coverUrl} name={form.title} className="h-16 w-16 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="URL обложки"
|
||||
value={form.coverUrl}
|
||||
onChange={(e) => setForm({ ...form, coverUrl: e.target.value })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-ghost" onClick={() => coverInputRef.current?.click()}>
|
||||
<Upload size={14} /> Загрузить с устройства
|
||||
</button>
|
||||
<label className="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToAlbum}
|
||||
onChange={(e) => setApplyToAlbum(e.target.checked)}
|
||||
/>
|
||||
на весь альбом
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
ref={coverInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f && track) uploadCover.mutate(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Длительность (мс)</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.durationMs}
|
||||
onChange={(e) => setForm({ ...form, durationMs: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-primary w-full" onClick={() => save.mutate()}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const PERMS: { key: keyof SelfUser["permissions"]; label: string }[] = [
|
||||
{ key: "canCreatePlaylists", label: "Создание плейлистов" },
|
||||
{ key: "canUploadTracks", label: "Загрузка треков" },
|
||||
{ key: "canAutoApprove", label: "Автопринятие" },
|
||||
{ key: "canModerate", label: "Модерация" },
|
||||
{ key: "canManageUsers", label: "Пользователи" },
|
||||
{ key: "canManageLibrary", label: "Библиотека" },
|
||||
{ key: "canViewAllPlaylists", label: "Все плейлисты" },
|
||||
];
|
||||
|
||||
function UsersAdmin({ currentUser }: { currentUser: SelfUser }) {
|
||||
const qc = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const { data, isLoading } = useQuery({ queryKey: ["admin-users"], queryFn: adminApi.users });
|
||||
|
||||
const invalidate = () => void qc.invalidateQueries({ queryKey: ["admin-users"] });
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => adminApi.updateUser(id, data),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const remove = useMutation({ mutationFn: (id: string) => adminApi.deleteUser(id), onSuccess: invalidate });
|
||||
const resetPw = useMutation({
|
||||
mutationFn: ({ id, password }: { id: string; password: string }) => adminApi.resetPassword(id, password),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="section-title">Пользователи ({data?.length ?? 0})</h2>
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} /> Создать пользователя
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-zinc-500">Загрузка...</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data?.map((u) => (
|
||||
<div key={u.id} className="card p-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Cover src={u.avatarUrl} name={u.displayName || u.username} rounded="rounded-full" className="h-10 w-10" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-white">
|
||||
{u.displayName || u.username}
|
||||
{u.isSuperAdmin && <span className="ml-2 chip text-accent">супер</span>}
|
||||
{u.id === currentUser.id && <span className="ml-2 chip">это вы</span>}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
@{u.username} · {u.email}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-ghost" onClick={() => setExpanded(expanded === u.id ? null : u.id)}>
|
||||
<Shield size={14} /> Права
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
const password = prompt("Новый пароль (мин. 6):");
|
||||
if (password && password.length >= 6) resetPw.mutate({ id: u.id, password });
|
||||
}}
|
||||
>
|
||||
Сбросить пароль
|
||||
</button>
|
||||
{u.id !== currentUser.id && (
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => confirm(`Удалить ${u.username}?`) && remove.mutate(u.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded === u.id && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-white/10 pt-3 sm:grid-cols-4">
|
||||
{PERMS.map((p) => {
|
||||
const active = u.isSuperAdmin || u.permissions[p.key];
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
disabled={u.isSuperAdmin}
|
||||
onClick={() =>
|
||||
update.mutate({ id: u.id, data: { permissions: { [p.key]: !active } } })
|
||||
}
|
||||
className={cx(
|
||||
"rounded-xl border px-3 py-2 text-left text-xs transition",
|
||||
active
|
||||
? "border-accent/40 bg-accent/15 text-white"
|
||||
: "border-white/10 bg-white/5 text-zinc-400 hover:bg-white/10",
|
||||
u.isSuperAdmin && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentUser.isSuperAdmin && (
|
||||
<button
|
||||
onClick={() => update.mutate({ id: u.id, data: { isSuperAdmin: !u.isSuperAdmin } })}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs text-zinc-300 hover:bg-white/10"
|
||||
>
|
||||
Супер-админ: {u.isSuperAdmin ? "да" : "нет"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateUserModal open={createOpen} onClose={() => setCreateOpen(false)} onCreated={invalidate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
displayName: "",
|
||||
});
|
||||
const [perms, setPerms] = useState<Record<string, boolean>>({
|
||||
canCreatePlaylists: true,
|
||||
canUploadTracks: true,
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
adminApi.createUser({
|
||||
...form,
|
||||
permissions: perms as Partial<SelfUser["permissions"]>,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onCreated();
|
||||
onClose();
|
||||
setForm({ email: "", username: "", password: "", displayName: "" });
|
||||
},
|
||||
onError: (e: any) => setError(e?.response?.data?.error ?? "Ошибка"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Создать пользователя">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Имя пользователя</label>
|
||||
<input className="input" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Отображаемое имя</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Почта</label>
|
||||
<input className="input" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Пароль</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Разрешения</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PERMS.map((p) => (
|
||||
<label key={p.key} className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2 text-xs text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(perms[p.key])}
|
||||
onChange={(e) => setPerms({ ...perms, [p.key]: e.target.checked })}
|
||||
/>
|
||||
{p.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-300">{error}</p>}
|
||||
<button
|
||||
className="btn-primary w-full"
|
||||
onClick={() => create.mutate()}
|
||||
disabled={!form.email || !form.username || form.password.length < 6}
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaylistsAdmin() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ["admin-playlists"], queryFn: adminApi.playlists });
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => playlistsApi.remove(id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin-playlists"] }),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{data?.items.map((p) => (
|
||||
<div key={p.id} className="card flex flex-wrap items-center gap-3 p-3">
|
||||
<Cover src={p.coverUrl} name={p.title} className="h-12 w-12 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<a href={`/playlist/${p.id}`} className="truncate text-sm font-medium text-white hover:underline">
|
||||
{p.title}
|
||||
</a>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
{p.owner?.username ?? "—"} · {p.trackCount} треков ·{" "}
|
||||
{p.visibility === "public" ? "публичный" : p.visibility === "unlisted" ? "по ссылке" : "приватный"}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-ghost" onClick={() => (window.location.href = `/playlist/${p.id}`)}>
|
||||
Открыть
|
||||
</button>
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => confirm("Удалить плейлист?") && remove.mutate(p.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{data?.items.length === 0 && <p className="text-sm text-zinc-500">Плейлистов нет.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/pages/AlbumPage.tsx
Normal file
58
apps/web/src/pages/AlbumPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Play } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { albumsApi } from "../lib/api";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { usePlayer } from "../store/player";
|
||||
import { Cover } from "../components/Cover";
|
||||
import { ShareButton } from "../components/ShareButton";
|
||||
import { TrackRow } from "../components/TrackRow";
|
||||
|
||||
export function AlbumPage() {
|
||||
const { id = "" } = useParams();
|
||||
const playQueue = usePlayer((s) => s.playQueue);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["album", id],
|
||||
queryFn: () => albumsApi.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
|
||||
if (!data) return <p className="text-sm text-zinc-500">Альбом не найден.</p>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl animate-fade-in">
|
||||
<div className="card mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-end">
|
||||
<Cover src={data.album.coverUrl} name={data.album.title} className="h-40 w-40 shrink-0" rounded="rounded-2xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="chip">Альбом</span>
|
||||
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">{data.album.title}</h1>
|
||||
{data.album.artist && (
|
||||
<Link to={`/artist/${data.album.artist.id}`} className="link mt-1 block text-sm">
|
||||
{data.album.artist.name}
|
||||
</Link>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{formatDate(data.album.releaseDate)} · {data.tracks.length} треков
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button className="btn-primary" onClick={() => playQueue(data.tracks, 0)} disabled={!data.tracks.length}>
|
||||
<Play size={15} /> Играть
|
||||
</button>
|
||||
<ShareButton path={`/album/${id}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.tracks.length ? (
|
||||
<div className="card p-2">
|
||||
{data.tracks.map((t, i) => (
|
||||
<TrackRow key={t.id} track={t} list={data.tracks} index={i} showCover={false} showAlbum={false} showUploader />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">В альбоме пока нет треков.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/pages/ArtistPage.tsx
Normal file
95
apps/web/src/pages/ArtistPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Play, UserCheck, UserPlus } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { artistsApi } from "../lib/api";
|
||||
import { usePlayer } from "../store/player";
|
||||
import { Cover } from "../components/Cover";
|
||||
import { ShareButton } from "../components/ShareButton";
|
||||
import { TrackRow } from "../components/TrackRow";
|
||||
|
||||
export function ArtistPage() {
|
||||
const { id = "" } = useParams();
|
||||
const qc = useQueryClient();
|
||||
const playQueue = usePlayer((s) => s.playQueue);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["artist", id],
|
||||
queryFn: () => artistsApi.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
const { data: tracks = [] } = useQuery({
|
||||
queryKey: ["artist-tracks", id],
|
||||
queryFn: () => artistsApi.tracks(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const follow = useMutation({
|
||||
mutationFn: () => (data?.following ? artistsApi.unfollow(id) : artistsApi.follow(id)),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["artist", id] });
|
||||
void qc.invalidateQueries({ queryKey: ["library"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
|
||||
if (!data) return <p className="text-sm text-zinc-500">Артист не найден.</p>;
|
||||
const { artist } = data;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl animate-fade-in">
|
||||
<div className="card mb-6 flex flex-col items-center gap-5 p-6 text-center sm:flex-row sm:items-end sm:text-left">
|
||||
<Cover src={artist.imageUrl} name={artist.name} rounded="rounded-full" className="h-36 w-36 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="chip">Артист</span>
|
||||
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">{artist.name}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{artist.followersCount} подписчиков
|
||||
{artist.trackCount !== undefined && ` · ${artist.trackCount} треков`}
|
||||
{artist.country && ` · ${artist.country}`}
|
||||
</p>
|
||||
{artist.description && (
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-zinc-400">{artist.description}</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2 sm:justify-start">
|
||||
<button className="btn-primary" onClick={() => playQueue(tracks, 0)} disabled={!tracks.length}>
|
||||
<Play size={15} /> Слушать
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={() => follow.mutate()}>
|
||||
{data.following ? <UserCheck size={15} /> : <UserPlus size={15} />}
|
||||
{data.following ? "Вы подписаны" : "Подписаться"}
|
||||
</button>
|
||||
<ShareButton path={`/artist/${id}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.albums.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="section-title mb-3">Альбомы</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{data.albums.map((al) => (
|
||||
<Link key={al.id} to={`/album/${al.id}`} className="card p-3 hover:bg-white/[0.07]">
|
||||
<Cover src={al.coverUrl} name={al.title} className="aspect-square w-full" />
|
||||
<div className="mt-2 truncate text-sm text-white">{al.title}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{al.releaseDate?.slice(0, 4)}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Треки</h2>
|
||||
{tracks.length ? (
|
||||
<div className="card p-2">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow key={t.id} track={t} list={tracks} index={i} showCover={false} showUploader />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Треков пока нет.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
apps/web/src/pages/Home.tsx
Normal file
59
apps/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Play, RefreshCw } from "lucide-react";
|
||||
import { tracksApi } from "../lib/api";
|
||||
import { useAuth } from "../store/auth";
|
||||
import { usePlayer } from "../store/player";
|
||||
import { TrackRow } from "../components/TrackRow";
|
||||
|
||||
export function Home() {
|
||||
const { user } = useAuth();
|
||||
const playQueue = usePlayer((s) => s.playQueue);
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["random-tracks"],
|
||||
queryFn: () => tracksApi.random(25),
|
||||
});
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl animate-fade-in">
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">
|
||||
Привет{user?.displayName ? `, ${user.displayName}` : ""} 👋
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Случайные рекомендации из общей библиотеки. Персональные рекомендации появятся позже.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-ghost" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={15} className={isFetching ? "animate-spin" : ""} />
|
||||
Обновить
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => items.length && playQueue(items, 0)}
|
||||
disabled={!items.length}
|
||||
>
|
||||
<Play size={15} /> Играть всё
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-zinc-500">Загрузка рекомендаций...</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="card p-8 text-center text-sm text-zinc-500">
|
||||
В библиотеке пока нет одобренных треков. Загрузите музыку на вкладке «Добавить».
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-2">
|
||||
{items.map((track, i) => (
|
||||
<TrackRow key={track.id} track={track} list={items} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
apps/web/src/pages/Library.tsx
Normal file
138
apps/web/src/pages/Library.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Play, Plus } from "lucide-react";
|
||||
import { libraryApi } from "../lib/api";
|
||||
import { cx } from "../lib/format";
|
||||
import { usePlayer } from "../store/player";
|
||||
import { Cover } from "../components/Cover";
|
||||
import { CreatePlaylistModal } from "../components/CreatePlaylistModal";
|
||||
import { PlaylistCard } from "../components/PlaylistCard";
|
||||
import { TrackRow } from "../components/TrackRow";
|
||||
|
||||
type Tab = "playlists" | "artists" | "tracks";
|
||||
|
||||
export function Library() {
|
||||
const [tab, setTab] = useState<Tab>("playlists");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const playQueue = usePlayer((s) => s.playQueue);
|
||||
const { data, isLoading } = useQuery({ queryKey: ["library"], queryFn: libraryApi.get });
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "playlists", label: "Плейлисты" },
|
||||
{ id: "artists", label: "Артисты" },
|
||||
{ id: "tracks", label: "Треки" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl animate-fade-in">
|
||||
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Библиотека</h1>
|
||||
<p className="mb-5 text-sm text-zinc-500">Ваши плейлисты, подписки и избранные треки.</p>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} /> Создать плейлист
|
||||
</button>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cx(
|
||||
"rounded-full px-4 py-1.5 text-sm transition",
|
||||
tab === t.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-zinc-500">Загрузка...</p>
|
||||
) : tab === "playlists" ? (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Мои плейлисты</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="card grid aspect-auto place-items-center border-dashed p-3 text-zinc-400 transition hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 py-6">
|
||||
<Plus size={26} />
|
||||
<span className="text-sm">Создать плейлист</span>
|
||||
</div>
|
||||
</button>
|
||||
{data?.ownedPlaylists.map((p) => (
|
||||
<PlaylistCard key={p.id} playlist={p} />
|
||||
))}
|
||||
</div>
|
||||
{!isLoading && !data?.ownedPlaylists.length && (
|
||||
<p className="mt-3 text-sm text-zinc-500">Пока нет своих плейлистов.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Сохранённые плейлисты</h2>
|
||||
{data?.savedPlaylists.length ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{data.savedPlaylists.map((s) => (
|
||||
<div key={s.originalPlaylistId} className="relative">
|
||||
<PlaylistCard playlist={s.playlist} />
|
||||
<span className="absolute right-2 top-2 chip">
|
||||
{s.mode === "copy" ? "копия" : "ссылка"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Вы ещё не добавляли чужие плейлисты.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : tab === "artists" ? (
|
||||
data?.followedArtists.length ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{data.followedArtists.map((a) => (
|
||||
<Link key={a.id} to={`/artist/${a.id}`} className="card p-3 hover:bg-white/[0.07]">
|
||||
<Cover src={a.imageUrl} name={a.name} rounded="rounded-full" className="mx-auto aspect-square w-24" />
|
||||
<div className="mt-3 truncate text-center text-sm font-medium text-white">{a.name}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Вы пока ни на кого не подписаны.</p>
|
||||
)
|
||||
) : data?.likedTracks.length ? (
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<button className="btn-primary" onClick={() => playQueue(data.likedTracks, 0)}>
|
||||
<Play size={15} /> Играть всё
|
||||
</button>
|
||||
</div>
|
||||
<div className="card p-2">
|
||||
{data.likedTracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.id}
|
||||
track={t}
|
||||
list={data.likedTracks}
|
||||
index={i}
|
||||
initialLiked
|
||||
showUploader
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Нет избранных треков.</p>
|
||||
)}
|
||||
|
||||
<CreatePlaylistModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={(id) => navigate(`/playlist/${id}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/pages/Login.tsx
Normal file
76
apps/web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "../store/auth";
|
||||
|
||||
export function Login() {
|
||||
const { login } = useAuth();
|
||||
const [form, setForm] = useState({ login: "", password: "" });
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(form.login, form.password);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error ?? "Не удалось войти");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid min-h-full place-items-center p-4">
|
||||
<div className="w-full max-w-md animate-fade-in">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-accent to-accent2 text-lg font-black text-black">
|
||||
AR
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">ADC Retivo</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Самохостинговая музыка. Вход доступен только по аккаунту, созданному администратором.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="card space-y-4 p-6">
|
||||
<div>
|
||||
<label className="label">Почта или имя пользователя</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.login}
|
||||
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Пароль</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? "Входим..." : "Войти"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-zinc-600">
|
||||
Регистрация новых пользователей открыта только из админ-панели.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
apps/web/src/pages/PlaylistPage.tsx
Normal file
365
apps/web/src/pages/PlaylistPage.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronUp, Pencil, Play, Plus, Search, Shuffle, Trash2, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { playlistsApi, tracksApi } from "../lib/api";
|
||||
import { cx, formatDuration } from "../lib/format";
|
||||
import type { Playlist, Track } from "../lib/types";
|
||||
import { usePlayer } from "../store/player";
|
||||
import { Cover } from "../components/Cover";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { ShareButton } from "../components/ShareButton";
|
||||
import { TrackRow } from "../components/TrackRow";
|
||||
|
||||
export function PlaylistPage() {
|
||||
const { id = "" } = useParams();
|
||||
const qc = useQueryClient();
|
||||
const playQueue = usePlayer((s) => s.playQueue);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [libOpen, setLibOpen] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["playlist", id],
|
||||
queryFn: () => playlistsApi.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const playlist = data?.playlist;
|
||||
const entries = data?.entries ?? [];
|
||||
const tracks = entries.map((e) => e.track).filter(Boolean) as Track[];
|
||||
const totalMs = tracks.reduce((sum, t) => sum + (t.durationMs ?? 0), 0);
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ["playlist", id] });
|
||||
void qc.invalidateQueries({ queryKey: ["library"] });
|
||||
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
|
||||
};
|
||||
|
||||
const removeEntry = useMutation({
|
||||
mutationFn: (entryId: string) => playlistsApi.removeTrack(id, entryId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const removePlaylist = useMutation({
|
||||
mutationFn: () => playlistsApi.remove(id),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
history.back();
|
||||
},
|
||||
});
|
||||
|
||||
const addToLibrary = useMutation({
|
||||
mutationFn: (mode: "reference" | "copy") => playlistsApi.addToLibrary(id, mode),
|
||||
onSuccess: () => {
|
||||
setLibOpen(false);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const removeFromLibrary = useMutation({
|
||||
mutationFn: () => playlistsApi.removeFromLibrary(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const reorder = useMutation({
|
||||
mutationFn: (entryIds: string[]) => playlistsApi.reorder(id, entryIds),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= entries.length) return;
|
||||
const ids = entries.map((e) => e.entryId);
|
||||
const [moved] = ids.splice(from, 1);
|
||||
ids.splice(to, 0, moved);
|
||||
reorder.mutate(ids);
|
||||
};
|
||||
|
||||
const shufflePlay = () => {
|
||||
if (!tracks.length) return;
|
||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||
playQueue(shuffled, 0);
|
||||
};
|
||||
|
||||
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка плейлиста...</p>;
|
||||
if (!playlist)
|
||||
return <p className="text-sm text-zinc-500">Плейлист не найден или недоступен.</p>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl animate-fade-in">
|
||||
<div className="card mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-end">
|
||||
<Cover src={playlist.coverUrl} name={playlist.title} className="h-40 w-40 shrink-0" rounded="rounded-2xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
|
||||
<span className="chip">Плейлист</span>
|
||||
<span className="chip">
|
||||
{playlist.visibility === "public"
|
||||
? "публичный"
|
||||
: playlist.visibility === "unlisted"
|
||||
? "по ссылке"
|
||||
: "приватный"}
|
||||
</span>
|
||||
{playlist.isShareable && <span className="chip">можно делиться</span>}
|
||||
{playlist.allowCollaboratorEdit && <span className="chip">можно редактировать из библиотеки</span>}
|
||||
</div>
|
||||
<h1 className="truncate text-3xl font-bold tracking-tight text-white">{playlist.title}</h1>
|
||||
{playlist.description && <p className="mt-1 text-sm text-zinc-400">{playlist.description}</p>}
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
{playlist.owner && (
|
||||
<>
|
||||
Автор:{" "}
|
||||
<Link to={`/u/${playlist.owner.username}`} className="link">
|
||||
{playlist.owner.displayName || playlist.owner.username}
|
||||
</Link>
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
{tracks.length} треков · {formatDuration(totalMs)}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button className="btn-primary" onClick={() => playQueue(tracks, 0)} disabled={!tracks.length}>
|
||||
<Play size={15} /> Играть
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={shufflePlay} disabled={!tracks.length}>
|
||||
<Shuffle size={15} /> Перемешать
|
||||
</button>
|
||||
<ShareButton path={`/playlist/${id}`} />
|
||||
{playlist.canEdit && (
|
||||
<>
|
||||
<button className="btn-ghost" onClick={() => setEditOpen(true)}>
|
||||
<Pencil size={15} /> Редактировать
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={() => setAddOpen(true)}>
|
||||
<Plus size={15} /> Добавить треки
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{data?.inLibrary ? (
|
||||
<button className="btn-ghost" onClick={() => removeFromLibrary.mutate()}>
|
||||
<X size={15} /> Убрать из библиотеки
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn-ghost" onClick={() => setLibOpen(true)}>
|
||||
<Plus size={15} /> В библиотеку
|
||||
</button>
|
||||
)}
|
||||
{playlist.isOwner && (
|
||||
<button className="btn-danger" onClick={() => confirm("Удалить плейлист?") && removePlaylist.mutate()}>
|
||||
<Trash2 size={15} /> Удалить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tracks.length === 0 ? (
|
||||
<div className="card p-8 text-center text-sm text-zinc-500">
|
||||
В плейлисте пока нет треков.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-2">
|
||||
{entries.map((entry, i) =>
|
||||
entry.track ? (
|
||||
<TrackRow
|
||||
key={entry.entryId}
|
||||
track={entry.track}
|
||||
list={tracks}
|
||||
index={i}
|
||||
showUploader
|
||||
right={
|
||||
playlist.canEdit ? (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="p-1 text-zinc-500 hover:text-white disabled:opacity-30"
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, i - 1)}
|
||||
title="Выше"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-zinc-500 hover:text-white disabled:opacity-30"
|
||||
disabled={i === entries.length - 1}
|
||||
onClick={() => move(i, i + 1)}
|
||||
title="Ниже"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-zinc-500 hover:text-red-300"
|
||||
onClick={() => removeEntry.mutate(entry.entryId)}
|
||||
title="Убрать"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playlist && (
|
||||
<EditPlaylistModal
|
||||
open={editOpen}
|
||||
onClose={() => setEditOpen(false)}
|
||||
playlist={playlist}
|
||||
onSaved={invalidate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddTracksModal open={addOpen} onClose={() => setAddOpen(false)} playlistId={id} onChanged={invalidate} />
|
||||
|
||||
<Modal open={libOpen} onClose={() => setLibOpen(false)} title="Добавить плейлист в библиотеку">
|
||||
<div className="space-y-3 text-sm">
|
||||
<button className="card w-full p-4 text-left hover:bg-white/[0.07]" onClick={() => addToLibrary.mutate("reference")}>
|
||||
<div className="font-medium text-white">Ссылка на оригинал</div>
|
||||
<p className="mt-1 text-zinc-500">
|
||||
Плейлист останется общим. Изменения автора будут видны вам. Редактирование — если автор
|
||||
разрешил.
|
||||
</p>
|
||||
</button>
|
||||
<button className="card w-full p-4 text-left hover:bg-white/[0.07]" onClick={() => addToLibrary.mutate("copy")}>
|
||||
<div className="font-medium text-white">Сохранить копию</div>
|
||||
<p className="mt-1 text-zinc-500">
|
||||
Создастся личная редактируемая копия, независимая от оригинала.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPlaylistModal({
|
||||
open,
|
||||
onClose,
|
||||
playlist,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
playlist: Playlist;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
title: playlist.title,
|
||||
description: playlist.description ?? "",
|
||||
visibility: playlist.visibility,
|
||||
isShareable: playlist.isShareable,
|
||||
allowCollaboratorEdit: playlist.allowCollaboratorEdit,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => playlistsApi.update(playlist.id, form),
|
||||
onSuccess: () => {
|
||||
onSaved();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Редактировать плейлист">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Название</label>
|
||||
<input className="input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Описание</label>
|
||||
<textarea
|
||||
className="input min-h-20"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Доступ</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.visibility}
|
||||
onChange={(e) => setForm({ ...form, visibility: e.target.value as Playlist["visibility"] })}
|
||||
>
|
||||
<option value="private">Приватный</option>
|
||||
<option value="unlisted">По ссылке</option>
|
||||
<option value="public">Публичный</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isShareable}
|
||||
onChange={(e) => setForm({ ...form, isShareable: e.target.checked })}
|
||||
/>
|
||||
Разрешить делиться ссылкой
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.allowCollaboratorEdit}
|
||||
onChange={(e) => setForm({ ...form, allowCollaboratorEdit: e.target.checked })}
|
||||
/>
|
||||
Разрешить редактирование тем, кто добавил плейлист в библиотеку
|
||||
</label>
|
||||
<button className="btn-primary w-full" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddTracksModal({
|
||||
open,
|
||||
onClose,
|
||||
playlistId,
|
||||
onChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
playlistId: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const { data } = useQuery({
|
||||
queryKey: ["track-search", q],
|
||||
queryFn: () => tracksApi.list({ q, limit: 20 }),
|
||||
enabled: open && q.length > 1,
|
||||
});
|
||||
const add = useMutation({
|
||||
mutationFn: (trackId: string) => playlistsApi.addTrack(playlistId, trackId),
|
||||
onSuccess: onChanged,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Добавить треки" wide>
|
||||
<div className="relative mb-3">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input className="input pl-9" placeholder="Поиск по библиотеке..." value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto">
|
||||
{data?.items.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left hover:bg-white/5"
|
||||
onClick={() => add.mutate(t.id)}
|
||||
>
|
||||
<Cover src={t.coverUrl} name={t.title} className="h-9 w-9" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-white">{t.title}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{t.artist?.name}</div>
|
||||
</div>
|
||||
<Plus size={15} className="text-zinc-500" />
|
||||
</button>
|
||||
))}
|
||||
{q.length > 1 && data?.items.length === 0 && (
|
||||
<p className="px-2 text-sm text-zinc-500">Ничего не найдено.</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
140
apps/web/src/pages/SearchPage.tsx
Normal file
140
apps/web/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { searchApi } from "../lib/api";
|
||||
import { cx } from "../lib/format";
|
||||
import { Cover } from "../components/Cover";
|
||||
import { TrackRow } from "../components/TrackRow";
|
||||
|
||||
const filters = [
|
||||
{ id: "all", label: "Всё" },
|
||||
{ id: "tracks", label: "Треки" },
|
||||
{ id: "artists", label: "Артисты" },
|
||||
{ id: "albums", label: "Альбомы" },
|
||||
{ id: "playlists", label: "Плейлисты" },
|
||||
{ id: "users", label: "Люди" },
|
||||
];
|
||||
|
||||
export function SearchPage() {
|
||||
const [params] = useSearchParams();
|
||||
const q = params.get("q") ?? "";
|
||||
const [type, setType] = useState("all");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["search", q, type],
|
||||
queryFn: () => searchApi.query(q, type),
|
||||
enabled: q.length > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl animate-fade-in">
|
||||
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">
|
||||
Поиск{q && `: «${q}»`}
|
||||
</h1>
|
||||
<p className="mb-4 text-sm text-zinc-500">Треки, артисты, альбомы, публичные плейлисты и люди.</p>
|
||||
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setType(f.id)}
|
||||
className={cx(
|
||||
"rounded-full px-3.5 py-1.5 text-sm transition",
|
||||
type === f.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!q ? (
|
||||
<p className="text-sm text-zinc-500">Введите запрос в строке поиска сверху.</p>
|
||||
) : isLoading ? (
|
||||
<p className="text-sm text-zinc-500">Поиск...</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{data!.tracks.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Треки</h2>
|
||||
<div className="card p-2">
|
||||
{data!.tracks.map((t, i) => (
|
||||
<TrackRow key={t.id} track={t} list={data!.tracks} index={i} showUploader />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data!.artists.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Артисты</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{data!.artists.map((a) => (
|
||||
<Link key={a.id} to={`/artist/${a.id}`} className="card p-3 text-center hover:bg-white/[0.07]">
|
||||
<Cover src={a.imageUrl} name={a.name} rounded="rounded-full" className="mx-auto aspect-square w-20" />
|
||||
<div className="mt-2 truncate text-sm text-white">{a.name}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data!.albums.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Альбомы</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{data!.albums.map((al) => (
|
||||
<Link key={al.id} to={`/album/${al.id}`} className="card p-3 hover:bg-white/[0.07]">
|
||||
<Cover src={al.coverUrl} name={al.title} className="aspect-square w-full" />
|
||||
<div className="mt-2 truncate text-sm text-white">{al.title}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{al.artist?.name}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data!.playlists.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Плейлисты</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{data!.playlists.map((p) => (
|
||||
<Link key={p.id} to={`/playlist/${p.id}`} className="card p-3 hover:bg-white/[0.07]">
|
||||
<Cover src={p.coverUrl} name={p.title} className="aspect-square w-full" />
|
||||
<div className="mt-2 truncate text-sm text-white">{p.title}</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
{p.owner?.username} · {p.trackCount} треков
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data!.users.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Люди</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{data!.users.map((u) => (
|
||||
<Link key={u.id} to={`/u/${u.username}`} className="card p-3 text-center hover:bg-white/[0.07]">
|
||||
<Cover
|
||||
src={u.avatarUrl}
|
||||
name={u.displayName || u.username}
|
||||
rounded="rounded-full"
|
||||
className="mx-auto aspect-square w-20"
|
||||
/>
|
||||
<div className="mt-2 truncate text-sm text-white">{u.displayName || u.username}</div>
|
||||
<div className="truncate text-xs text-zinc-500">@{u.username}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data!.tracks.length + data!.artists.length + data!.albums.length + data!.playlists.length + data!.users.length ===
|
||||
0 && <p className="text-sm text-zinc-500">Ничего не найдено.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
apps/web/src/pages/TrackPage.tsx
Normal file
108
apps/web/src/pages/TrackPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Heart, Play, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { tracksApi } from "../lib/api";
|
||||
import { formatDate, formatDuration } from "../lib/format";
|
||||
import { usePlayer } from "../store/player";
|
||||
import { AddToPlaylistModal } from "../components/AddToPlaylistModal";
|
||||
import { ArtistLinks } from "../components/ArtistLinks";
|
||||
import { Comments } from "../components/Comments";
|
||||
import { Cover } from "../components/Cover";
|
||||
import { LyricsView } from "../components/LyricsView";
|
||||
import { ShareButton } from "../components/ShareButton";
|
||||
|
||||
export function TrackPage() {
|
||||
const { id = "" } = useParams();
|
||||
const playNow = usePlayer((s) => s.playNow);
|
||||
const addToQueue = usePlayer((s) => s.addToQueue);
|
||||
const qc = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["track", id],
|
||||
queryFn: () => tracksApi.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const like = useMutation({
|
||||
mutationFn: () => (data?.liked ? tracksApi.unlike(id) : tracksApi.like(id)),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["track", id] });
|
||||
void qc.invalidateQueries({ queryKey: ["library"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
|
||||
if (!data) return <p className="text-sm text-zinc-500">Трек не найден.</p>;
|
||||
const { track } = data;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl animate-fade-in">
|
||||
<div className="card mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-end">
|
||||
<Cover src={track.coverUrl} name={track.title} className="h-40 w-40 shrink-0" rounded="rounded-2xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="chip">Трек</span>
|
||||
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">{track.title}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
<ArtistLinks artists={track.artists} fallback={track.artist?.name} linkClassName="link" />
|
||||
{track.album && (
|
||||
<>
|
||||
{" · "}
|
||||
<Link to={`/album/${track.album.id}`} className="link">
|
||||
{track.album.title}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{formatDuration(track.durationMs)}
|
||||
{track.releaseDate && ` · ${formatDate(track.releaseDate)}`}
|
||||
{` · ${track.playCount} прослушиваний`}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
Добавил в библиотеку:{" "}
|
||||
{track.uploadedBy ? (
|
||||
<Link to={`/u/${track.uploadedBy.username}`} className="link">
|
||||
{track.uploadedBy.displayName || track.uploadedBy.username}
|
||||
</Link>
|
||||
) : (
|
||||
"неизвестно"
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button className="btn-primary" onClick={() => playNow(track, [track])}>
|
||||
<Play size={15} /> Играть
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={() => addToQueue(track)}>
|
||||
<Plus size={15} /> В очередь
|
||||
</button>
|
||||
<button className={data.liked ? "btn-danger" : "btn-ghost"} onClick={() => like.mutate()}>
|
||||
<Heart size={15} fill={data.liked ? "currentColor" : "none"} />
|
||||
{data.liked ? "В избранном" : "В избранное"}
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={() => setAddOpen(true)}>
|
||||
В плейлист
|
||||
</button>
|
||||
<a className="btn-ghost" href={`/api/tracks/${id}/stream?download=1`}>
|
||||
Скачать
|
||||
</a>
|
||||
<ShareButton path={`/track/${id}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="card max-h-[60vh] overflow-hidden p-2">
|
||||
<h2 className="section-title px-3 pb-2 pt-3">Текст песни</h2>
|
||||
<LyricsView trackId={id} />
|
||||
</section>
|
||||
|
||||
<Comments trackId={id} />
|
||||
</div>
|
||||
|
||||
<AddToPlaylistModal track={track} open={addOpen} onClose={() => setAddOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/pages/UserPage.tsx
Normal file
62
apps/web/src/pages/UserPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { usersApi } from "../lib/api";
|
||||
import { Cover } from "../components/Cover";
|
||||
import { PlaylistCard } from "../components/PlaylistCard";
|
||||
import { ShareButton } from "../components/ShareButton";
|
||||
|
||||
export function UserPage() {
|
||||
const { username = "" } = useParams();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user", username],
|
||||
queryFn: () => usersApi.get(username),
|
||||
enabled: Boolean(username),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
|
||||
if (!data) return <p className="text-sm text-zinc-500">Пользователь не найден.</p>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl animate-fade-in">
|
||||
<div className="card mb-6 flex flex-col items-center gap-5 p-6 text-center sm:flex-row sm:items-end sm:text-left">
|
||||
<Cover
|
||||
src={data.user.avatarUrl}
|
||||
name={data.user.displayName || data.user.username}
|
||||
rounded="rounded-full"
|
||||
className="h-32 w-32 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="chip">Профиль</span>
|
||||
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">
|
||||
{data.user.displayName || data.user.username}
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-500">@{data.user.username}</p>
|
||||
{data.user.bio && <p className="mt-2 max-w-2xl text-sm text-zinc-400">{data.user.bio}</p>}
|
||||
<p className="mt-1 text-xs text-zinc-600">Загружено треков: {data.trackCount}</p>
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2 sm:justify-start">
|
||||
<ShareButton path={`/u/${data.user.username}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="section-title mb-3">Публичные плейлисты</h2>
|
||||
{data.playlists.length ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{data.playlists.map((p) => (
|
||||
<PlaylistCard key={p.id} playlist={p} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Публичных плейлистов нет.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="mt-6 text-xs text-zinc-600">
|
||||
<Link to="/" className="link">
|
||||
← На главную
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/store/auth.tsx
Normal file
57
apps/web/src/store/auth.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { authApi } from "../lib/api";
|
||||
import type { SelfUser } from "../lib/types";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: SelfUser | null;
|
||||
loading: boolean;
|
||||
setUser: (user: SelfUser | null) => void;
|
||||
login: (login: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<SelfUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const u = await authApi.session();
|
||||
setUser(u);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (loginValue: string, password: string) => {
|
||||
const u = await authApi.login(loginValue, password);
|
||||
setUser(u);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await authApi.logout();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, loading, setUser, login, logout, refresh }),
|
||||
[user, loading, login, logout, refresh],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
170
apps/web/src/store/player.tsx
Normal file
170
apps/web/src/store/player.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { Track } from "../lib/types";
|
||||
|
||||
export type RepeatMode = "off" | "all" | "one";
|
||||
|
||||
interface PlayerState {
|
||||
queue: Track[];
|
||||
index: number;
|
||||
isPlaying: boolean;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
expanded: boolean;
|
||||
showLyrics: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
|
||||
playNow: (track: Track, list?: Track[]) => void;
|
||||
playQueue: (tracks: Track[], startIndex?: number) => void;
|
||||
setQueue: (tracks: Track[]) => void;
|
||||
toggle: () => void;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
goTo: (index: number) => void;
|
||||
seek: (time: number) => void;
|
||||
addToQueue: (track: Track) => void;
|
||||
playNext: (track: Track) => void;
|
||||
removeAt: (index: number) => void;
|
||||
move: (from: number, to: number) => void;
|
||||
clearQueue: () => void;
|
||||
toggleShuffle: () => void;
|
||||
cycleRepeat: () => void;
|
||||
setExpanded: (v: boolean) => void;
|
||||
setShowLyrics: (v: boolean) => void;
|
||||
setPlaying: (v: boolean) => void;
|
||||
setCurrentTime: (t: number) => void;
|
||||
setDuration: (d: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
}
|
||||
|
||||
export const usePlayer = create<PlayerState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
queue: [],
|
||||
index: 0,
|
||||
isPlaying: false,
|
||||
shuffle: false,
|
||||
repeat: "off",
|
||||
expanded: false,
|
||||
showLyrics: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
|
||||
playNow: (track, list) =>
|
||||
set(() => {
|
||||
const queue = list && list.length ? list : [track];
|
||||
const index = Math.max(
|
||||
0,
|
||||
queue.findIndex((t) => t.id === track.id),
|
||||
);
|
||||
return { queue, index, isPlaying: true, currentTime: 0, duration: 0 };
|
||||
}),
|
||||
|
||||
playQueue: (tracks, startIndex = 0) =>
|
||||
set(() => ({ queue: tracks, index: startIndex, isPlaying: true, currentTime: 0, duration: 0 })),
|
||||
|
||||
setQueue: (tracks) => set({ queue: tracks }),
|
||||
|
||||
toggle: () => set((s) => (s.queue.length ? { isPlaying: !s.isPlaying } : {})),
|
||||
|
||||
next: () => {
|
||||
const { queue, index, shuffle, repeat } = get();
|
||||
if (!queue.length) return;
|
||||
if (repeat === "one") {
|
||||
set({ currentTime: 0, isPlaying: true });
|
||||
return;
|
||||
}
|
||||
let nextIndex: number;
|
||||
if (shuffle) {
|
||||
nextIndex = Math.floor(Math.random() * queue.length);
|
||||
if (queue.length > 1) {
|
||||
while (nextIndex === index) nextIndex = Math.floor(Math.random() * queue.length);
|
||||
}
|
||||
} else {
|
||||
nextIndex = index + 1;
|
||||
}
|
||||
if (nextIndex >= queue.length) {
|
||||
if (repeat === "all") nextIndex = 0;
|
||||
else {
|
||||
set({ isPlaying: false, index: queue.length - 1 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
set({ index: nextIndex, currentTime: 0, duration: 0, isPlaying: true });
|
||||
},
|
||||
|
||||
prev: () => {
|
||||
const { index, currentTime } = get();
|
||||
if (currentTime > 3 && index >= 0) {
|
||||
set({ currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const nextIndex = index > 0 ? index - 1 : 0;
|
||||
set({ index: nextIndex, currentTime: 0, duration: 0, isPlaying: true });
|
||||
},
|
||||
|
||||
goTo: (index) => set({ index, isPlaying: true, currentTime: 0, duration: 0 }),
|
||||
|
||||
seek: (time) => set({ currentTime: time }),
|
||||
|
||||
addToQueue: (track) => set((s) => ({ queue: [...s.queue, track] })),
|
||||
|
||||
playNext: (track) =>
|
||||
set((s) => {
|
||||
const queue = [...s.queue];
|
||||
queue.splice(s.index + 1, 0, track);
|
||||
return { queue };
|
||||
}),
|
||||
|
||||
removeAt: (index) =>
|
||||
set((s) => {
|
||||
const queue = s.queue.filter((_, i) => i !== index);
|
||||
let newIndex = s.index;
|
||||
if (index < s.index) newIndex = s.index - 1;
|
||||
if (newIndex >= queue.length) newIndex = Math.max(0, queue.length - 1);
|
||||
return { queue, index: newIndex };
|
||||
}),
|
||||
|
||||
move: (from, to) =>
|
||||
set((s) => {
|
||||
const queue = [...s.queue];
|
||||
const [item] = queue.splice(from, 1);
|
||||
queue.splice(to, 0, item);
|
||||
let newIndex = s.index;
|
||||
if (s.index === from) newIndex = to;
|
||||
else if (from < s.index && to >= s.index) newIndex = s.index - 1;
|
||||
else if (from > s.index && to <= s.index) newIndex = s.index + 1;
|
||||
return { queue, index: newIndex };
|
||||
}),
|
||||
|
||||
clearQueue: () => set({ queue: [], index: 0, isPlaying: false, currentTime: 0, duration: 0 }),
|
||||
|
||||
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
|
||||
|
||||
cycleRepeat: () =>
|
||||
set((s) => ({ repeat: s.repeat === "off" ? "all" : s.repeat === "all" ? "one" : "off" })),
|
||||
|
||||
setExpanded: (v) => set({ expanded: v }),
|
||||
setShowLyrics: (v) => set({ showLyrics: v }),
|
||||
setPlaying: (v) => set({ isPlaying: v }),
|
||||
setCurrentTime: (t) => set({ currentTime: t }),
|
||||
setDuration: (d) => set({ duration: d }),
|
||||
setVolume: (v) => set({ volume: v }),
|
||||
}),
|
||||
{
|
||||
name: "adc-retivo-player",
|
||||
partialize: (s) => ({
|
||||
queue: s.queue,
|
||||
index: s.index,
|
||||
volume: s.volume,
|
||||
shuffle: s.shuffle,
|
||||
repeat: s.repeat,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const selectCurrentTrack = (s: PlayerState): Track | null => s.queue[s.index] ?? null;
|
||||
35
apps/web/tailwind.config.js
Normal file
35
apps/web/tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: "#07080c",
|
||||
panel: "rgba(255,255,255,0.04)",
|
||||
accent: "#8b5cf6",
|
||||
accent2: "#22d3ee",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "Segoe UI", "sans-serif"],
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: "2px",
|
||||
},
|
||||
keyframes: {
|
||||
"fade-in": {
|
||||
from: { opacity: "0", transform: "translateY(6px)" },
|
||||
to: { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
"pulse-slow": {
|
||||
"0%,100%": { opacity: "0.6" },
|
||||
"50%": { opacity: "1" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fade-in .35s ease both",
|
||||
"pulse-slow": "pulse-slow 2.4s ease-in-out infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
22
apps/web/tsconfig.json
Normal file
22
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/components/addtoplaylistmodal.tsx","./src/components/artistlinks.tsx","./src/components/comments.tsx","./src/components/cover.tsx","./src/components/createplaylistmodal.tsx","./src/components/layout.tsx","./src/components/lyricsview.tsx","./src/components/modal.tsx","./src/components/player.tsx","./src/components/playlistcard.tsx","./src/components/queuepanel.tsx","./src/components/sharebutton.tsx","./src/components/trackrow.tsx","./src/lib/api.ts","./src/lib/format.ts","./src/lib/permissions.ts","./src/lib/types.ts","./src/pages/account.tsx","./src/pages/addtrack.tsx","./src/pages/admin.tsx","./src/pages/albumpage.tsx","./src/pages/artistpage.tsx","./src/pages/home.tsx","./src/pages/library.tsx","./src/pages/login.tsx","./src/pages/playlistpage.tsx","./src/pages/searchpage.tsx","./src/pages/trackpage.tsx","./src/pages/userpage.tsx","./src/store/auth.tsx","./src/store/player.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
22
apps/web/vite.config.ts
Normal file
22
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const proxyTarget = process.env.VITE_PROXY_TARGET || "http://localhost:3001";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user