├── .gitignore ├── .prettierrc.json ├── Api ├── .dockerignore ├── Dockerfile ├── global.d.ts ├── nodemon.json ├── package.json ├── pnpm-lock.yaml ├── prisma │ └── schema.prisma ├── src │ ├── index.ts │ ├── lib │ │ ├── ffmpeg.ts │ │ ├── generate.ts │ │ ├── index.ts │ │ ├── metadata.ts │ │ ├── prisma.ts │ │ └── validate.ts │ ├── plugins │ │ └── autoTagRoutes.ts │ └── routes │ │ ├── attribute.ts │ │ ├── bookmark.ts │ │ ├── category.ts │ │ ├── generate.ts │ │ ├── home.ts │ │ ├── index.ts │ │ ├── location.ts │ │ ├── search.ts │ │ ├── star.ts │ │ ├── video.ts │ │ └── website.ts └── tsconfig.json └── Client ├── .eslintrc.cjs ├── FEATURES.md ├── LICENSE ├── README.md ├── index.html ├── nodemon.json ├── package.json ├── pnpm-lock.yaml ├── src ├── components │ ├── badge │ │ ├── badge.module.scss │ │ └── index.tsx │ ├── dropbox │ │ ├── dropbox.module.css │ │ └── index.tsx │ ├── error.tsx │ ├── icon.tsx │ ├── image │ │ └── missing.tsx │ ├── indeterminate │ │ ├── index.tsx │ │ └── regular-item.tsx │ ├── modal │ │ ├── index.tsx │ │ └── modal.module.scss │ ├── navbar │ │ ├── index.tsx │ │ └── navbar.module.scss │ ├── not-found.tsx │ ├── retired.tsx │ ├── ribbon │ │ ├── index.tsx │ │ └── ribbon.module.scss │ ├── search │ │ ├── filter.module.css │ │ ├── filter.tsx │ │ └── sort.tsx │ ├── settings.ts │ ├── spinner │ │ ├── index.tsx │ │ └── spinner.module.css │ ├── text-field-form.tsx │ ├── video │ │ ├── header.module.scss │ │ ├── header.tsx │ │ ├── index.ts │ │ ├── player.tsx │ │ ├── timeline.module.css │ │ └── timeline.tsx │ ├── vidstack │ │ ├── index.tsx │ │ ├── storage.ts │ │ └── vidstack.css │ └── virtualized │ │ ├── virtuoso.module.css │ │ └── virtuoso.tsx ├── config │ ├── api.ts │ ├── index.ts │ └── server.ts ├── context │ └── modalContext.tsx ├── hooks │ ├── search.ts │ ├── useCollision.ts │ ├── useFocus.ts │ ├── useOptimistic.ts │ └── useRetired.ts ├── index.css ├── interface.ts ├── keys │ ├── attribute.ts │ ├── category.ts │ ├── index.ts │ ├── location.ts │ ├── search.ts │ ├── star.ts │ ├── video.ts │ └── website.ts ├── main.tsx ├── routeTree.gen.ts ├── routes │ ├── __root.tsx │ ├── config.tsx │ ├── editor │ │ ├── editor.module.css │ │ └── index.tsx │ ├── index.tsx │ ├── settings.tsx │ ├── star │ │ ├── $starId.tsx │ │ ├── index.tsx │ │ ├── search │ │ │ ├── -stars.tsx │ │ │ ├── index.tsx │ │ │ └── search.module.scss │ │ └── star.module.scss │ └── video │ │ ├── $videoId.tsx │ │ ├── add.module.css │ │ ├── add.tsx │ │ ├── search │ │ ├── -videos.tsx │ │ ├── index.tsx │ │ └── search.module.scss │ │ └── video.module.css ├── service │ ├── attribute.ts │ ├── bookmark.ts │ ├── category.ts │ ├── generate.ts │ ├── home.ts │ ├── index.ts │ ├── location.ts │ ├── search.ts │ ├── star.ts │ ├── video.ts │ └── website.ts ├── utils.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .env 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "none", 6 | "printWidth": 120, 7 | "useTabs": false, 8 | "semi": false, 9 | "arrowParens": "avoid", 10 | "importOrder": [ 11 | "^(fastify|@?fastify[/-](.*))$", 12 | "(^react(-dom/(.*))?$)", 13 | "^@mui/(.*)", 14 | "", 15 | "path|fs", 16 | "^@/components/(.*)", 17 | "^(?!.*\\.(css|scss)$)[./]", 18 | "^@/(.*)", 19 | "\\.(css|scss)" 20 | ], 21 | "importOrderSeparation": true, 22 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 23 | } -------------------------------------------------------------------------------- /Api/.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | Dockerfile 3 | .dockerignore 4 | node_modules 5 | README.md 6 | dist 7 | .git 8 | media -------------------------------------------------------------------------------- /Api/Dockerfile: -------------------------------------------------------------------------------- 1 | # Define the base image 2 | FROM node:20-alpine3.18 AS base 3 | 4 | ##### DEPENDENCIES 5 | FROM base AS deps 6 | WORKDIR /app 7 | RUN npm install -g pnpm 8 | COPY package.json pnpm-lock.yaml ./ 9 | RUN pnpm install --frozen-lockfile 10 | 11 | ##### BUILDER 12 | FROM deps AS builder 13 | COPY . . 14 | RUN pnpm run build 15 | 16 | ##### RUNNER 17 | FROM base AS runner 18 | WORKDIR /app 19 | ENV NODE_ENV production 20 | RUN apk add ffmpeg 21 | 22 | RUN addgroup node users 23 | USER node 24 | 25 | COPY --from=builder /app/dist ./dist 26 | COPY --from=builder /app/node_modules ./node_modules 27 | COPY --from=builder /app/package.json ./package.json 28 | 29 | EXPOSE 3000 30 | ENV PORT 3000 31 | 32 | CMD ["sh", "-c", "umask 0000 && node dist/index.js"] -------------------------------------------------------------------------------- /Api/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'get-video-dimensions' { 2 | function placeholder(file: string): Promise<{ width: number; height: number }> 3 | export = placeholder 4 | } 5 | 6 | declare module 'ffmpeg-generate-video-preview' { 7 | type Args = { 8 | input: string 9 | output: string 10 | width?: number 11 | height?: number 12 | quality?: number 13 | numFrames?: number 14 | numFramesPercent?: number 15 | rows?: number 16 | cols?: number 17 | padding?: number 18 | margin?: number 19 | color?: number 20 | gifski?: { 21 | fps?: number 22 | quality?: number 23 | fast?: boolean 24 | } 25 | log?: (message: string) => void 26 | } 27 | 28 | function placeholder(args: Args): Promise<{ 29 | output: string 30 | numFrames: number 31 | width: number 32 | height: number 33 | rows: number 34 | cols: number 35 | }> 36 | export = placeholder 37 | } 38 | -------------------------------------------------------------------------------- /Api/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "*", 3 | "delay": 300 4 | } 5 | -------------------------------------------------------------------------------- /Api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pornts-fastify", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "start": "ts-node-dev --respawn --transpile-only src/index.ts", 9 | "build": "prisma generate && tsc", 10 | "format": "prettier --write .", 11 | "docker:build": "docker build -t asusguy94/pornts:fastify .", 12 | "docker:push": "docker push asusguy94/pornts:fastify", 13 | "docker:remove": "docker image prune -f", 14 | "docker": "pnpm docker:build && pnpm docker:push && pnpm docker:remove", 15 | "docker:watch": "nodemon --exec \"pnpm docker\"" 16 | }, 17 | "dependencies": { 18 | "@fastify/cors": "^9.0.1", 19 | "@fastify/swagger": "^8.14.0", 20 | "@fastify/swagger-ui": "^3.1.0", 21 | "@prisma/client": "^5.15.0", 22 | "axios": "^1.7.2", 23 | "capitalize": "^2.0.4", 24 | "dayjs": "^1.11.10", 25 | "fastify": "^4.26.2", 26 | "fastify-plugin": "^4.5.1", 27 | "ffmpeg-generate-video-preview": "^1.0.3", 28 | "fluent-ffmpeg": "^2.1.2", 29 | "get-video-dimensions": "^1.0.0", 30 | "prisma": "^5.15.0", 31 | "sharp": "^0.32", 32 | "zod": "^3.22.4" 33 | }, 34 | "devDependencies": { 35 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 36 | "@types/capitalize": "^2.0.2", 37 | "@types/fluent-ffmpeg": "^2.1.24", 38 | "@types/node": "^20.14.2", 39 | "@types/sharp": "^0.32.0", 40 | "fastify-tsconfig": "^2.0.0", 41 | "nodemon": "^3.1.3", 42 | "prettier": "^3.3.1", 43 | "ts-node-dev": "^2.0.0", 44 | "typescript": "^5.4.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Api/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "mysql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model Attribute { 11 | id Int @id @default(autoincrement()) 12 | name String @unique 13 | 14 | videos VideoAttributes[] 15 | 16 | @@map("attribute") 17 | } 18 | 19 | model Bookmark { 20 | id Int @id @default(autoincrement()) 21 | start Int 22 | videoID Int 23 | categoryID Int 24 | 25 | video Video @relation(fields: [videoID], references: [id], onDelete: Cascade, onUpdate: Cascade) 26 | category Category @relation(fields: [categoryID], references: [id], onDelete: Restrict, onUpdate: Cascade) 27 | 28 | @@unique([videoID, start]) 29 | @@map("bookmark") 30 | } 31 | 32 | model Category { 33 | id Int @id @default(autoincrement()) 34 | name String @unique 35 | 36 | bookmarks Bookmark[] 37 | 38 | @@map("category") 39 | } 40 | 41 | model Location { 42 | id Int @id @default(autoincrement()) 43 | name String @unique 44 | 45 | videos VideoLocations[] 46 | 47 | @@map("location") 48 | } 49 | 50 | model Plays { 51 | time DateTime @default(now()) 52 | videoID Int 53 | 54 | video Video @relation(fields: [videoID], references: [id], onDelete: Cascade, onUpdate: Cascade) 55 | 56 | @@id([videoID, time]) 57 | @@map("plays") 58 | } 59 | 60 | model Site { 61 | id Int @id @default(autoincrement()) 62 | name String @unique 63 | websiteID Int 64 | 65 | website Website @relation(fields: [websiteID], references: [id], onDelete: Restrict, onUpdate: Cascade) 66 | 67 | videos Video[] 68 | 69 | @@map("site") 70 | } 71 | 72 | model StarAlias { 73 | id Int @id @default(autoincrement()) 74 | name String @unique 75 | starID Int 76 | 77 | star Star @relation(fields: [starID], references: [id], onDelete: Cascade, onUpdate: Cascade) 78 | 79 | @@map("staralias") 80 | } 81 | 82 | model Haircolor { 83 | name String @id 84 | 85 | stars StarHaircolors[] 86 | 87 | @@map("haircolor") 88 | } 89 | 90 | model StarHaircolors { 91 | hair String 92 | starId Int 93 | 94 | haircolor Haircolor @relation(fields: [hair], references: [name], onDelete: Restrict, onUpdate: Cascade) 95 | star Star @relation(fields: [starId], references: [id], onDelete: Cascade, onUpdate: Cascade) 96 | 97 | @@id([starId, hair]) 98 | @@map("starhaircolors") 99 | } 100 | 101 | model Star { 102 | id Int @id @default(autoincrement()) 103 | name String @unique 104 | image String? @unique 105 | breast String? 106 | ethnicity String? 107 | birthdate DateTime? @db.Date 108 | height Int? 109 | weight Int? 110 | autoTaggerIgnore Boolean @default(false) 111 | api String? @unique @db.VarChar(36) 112 | retired Boolean @default(false) 113 | 114 | haircolors StarHaircolors[] 115 | alias StarAlias[] 116 | videos Video[] 117 | 118 | @@map("star") 119 | } 120 | 121 | model VideoAttributes { 122 | attributeID Int 123 | videoID Int 124 | 125 | attribute Attribute @relation(fields: [attributeID], references: [id], onDelete: Restrict, onUpdate: Cascade) 126 | video Video @relation(fields: [videoID], references: [id], onDelete: Cascade, onUpdate: Cascade) 127 | 128 | @@id([attributeID, videoID]) 129 | @@map("videoattributes") 130 | } 131 | 132 | model VideoLocations { 133 | locationID Int 134 | videoID Int 135 | 136 | location Location @relation(fields: [locationID], references: [id], onDelete: Restrict, onUpdate: Cascade) 137 | video Video @relation(fields: [videoID], references: [id], onDelete: Cascade, onUpdate: Cascade) 138 | 139 | @@id([locationID, videoID]) 140 | @@map("videolocations") 141 | } 142 | 143 | model Video { 144 | id Int @id @default(autoincrement()) 145 | name String 146 | path String @unique 147 | date DateTime @db.Date 148 | apiDate String? @db.Char(10) 149 | duration Int @default(0) 150 | height Int @default(0) 151 | width Int @default(0) 152 | starAge Int? 153 | siteID Int? 154 | websiteID Int 155 | starID Int? 156 | thumbnail Int @default(100) 157 | added DateTime @default(now()) @db.Date 158 | api String? @unique @db.VarChar(36) 159 | cover String? @unique 160 | ignoreMeta Boolean @default(false) 161 | validated Boolean @default(false) 162 | 163 | site Site? @relation(fields: [siteID], references: [id], onDelete: Restrict, onUpdate: Cascade) 164 | website Website @relation(fields: [websiteID], references: [id], onDelete: Restrict, onUpdate: Cascade) 165 | star Star? @relation(fields: [starID], references: [id], onDelete: SetNull, onUpdate: Cascade) 166 | 167 | attributes VideoAttributes[] 168 | locations VideoLocations[] 169 | bookmarks Bookmark[] 170 | plays Plays[] 171 | 172 | @@map("video") 173 | } 174 | 175 | model Website { 176 | id Int @id @default(autoincrement()) 177 | name String @unique 178 | 179 | sites Site[] 180 | videos Video[] 181 | 182 | @@map("website") 183 | } 184 | -------------------------------------------------------------------------------- /Api/src/index.ts: -------------------------------------------------------------------------------- 1 | import fastifyCors from '@fastify/cors' 2 | import fastifySwagger from '@fastify/swagger' 3 | import fastifySwaggerUi from '@fastify/swagger-ui' 4 | import fastify from 'fastify' 5 | 6 | import autoTagRoutes from './plugins/autoTagRoutes' 7 | import routes from './routes' 8 | 9 | const server = fastify() 10 | 11 | // Setup CORS 12 | server.register(fastifyCors, { origin: '*' }) 13 | 14 | // Setup Swagger 15 | server.register(fastifySwagger) 16 | server.register(fastifySwaggerUi, { 17 | routePrefix: '/docs', 18 | uiConfig: { docExpansion: 'list' } 19 | }) 20 | 21 | // Register routes 22 | server.register(autoTagRoutes, { prefix: '/api' }) 23 | server.register(routes, { prefix: '/api' }) 24 | 25 | server.listen({ port: Number(process.env.PORT ?? '3000'), host: '0.0.0.0' }, (err, address) => { 26 | if (err) { 27 | console.error(err) 28 | process.exit(1) 29 | } 30 | 31 | console.info(`server listening on ${address}`) 32 | }) 33 | -------------------------------------------------------------------------------- /Api/src/lib/ffmpeg.ts: -------------------------------------------------------------------------------- 1 | import generatePreview from 'ffmpeg-generate-video-preview' 2 | import ffmpeg from 'fluent-ffmpeg' 3 | import getDimensions from 'get-video-dimensions' 4 | import sharp from 'sharp' 5 | 6 | import { generateVTTData, getDividableWidth } from './' 7 | 8 | const getRawHeight = async (file: string) => (await getDimensions(file)).height 9 | const getRawWidth = async (file: string) => (await getDimensions(file)).width 10 | 11 | async function getRawDuration(file: string) { 12 | return new Promise((resolve, reject) => { 13 | ffmpeg.ffprobe(file, (err, data) => { 14 | if (err) { 15 | reject(err as Error) 16 | return 17 | } 18 | 19 | const format = data.format.duration 20 | if (format === undefined) { 21 | reject(new Error('Duration is undefined')) 22 | return 23 | } 24 | 25 | resolve(format) 26 | }) 27 | }) 28 | } 29 | 30 | export async function resizeImage(src: string, width: number) { 31 | sharp.cache(false) 32 | 33 | const buffer = await sharp(src).resize(width).toBuffer() 34 | await sharp(buffer).toFile(src) 35 | } 36 | 37 | export const getDuration = async (file: string) => Math.round(await getRawDuration(file)) 38 | export const getHeight = async (file: string) => await getRawHeight(file) 39 | export const getWidth = async (file: string) => await getRawWidth(file) 40 | 41 | export async function extractVtt(src: string, dest: string, videoID: number) { 42 | const duration = await getRawDuration(src) // in seconds 43 | 44 | const cols = 8 // images per row 45 | const rows = 40 // images per column 46 | 47 | const delay = duration / (rows * cols) 48 | 49 | /* Generate Preview */ 50 | const { 51 | width: calcWidth, 52 | height: calcHeight, 53 | rows: numRows, 54 | cols: numCols 55 | } = await generatePreview({ 56 | input: src, 57 | output: dest, 58 | width: getDividableWidth(await getRawWidth(src)), 59 | quality: 3, 60 | rows: rows, 61 | cols: cols 62 | }) 63 | 64 | /* Generate VTT output */ 65 | await generateVTTData( 66 | videoID, 67 | delay, 68 | { rows: numRows, cols: numCols }, 69 | { 70 | width: calcWidth, 71 | height: calcHeight 72 | } 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /Api/src/lib/generate.ts: -------------------------------------------------------------------------------- 1 | import { dirOnly } from './' 2 | 3 | const regex = /^(\d{4}-\d{2}-\d{2}) - \[(.*)\] - (.*?)_(.*)$/ 4 | 5 | export function generateDate(path: string): string { 6 | if (!isValidFormat(path)) throw new Error('Invalid format') 7 | 8 | return dirOnly(path).match(regex)?.at(1) ?? '' 9 | } 10 | 11 | export function generateSite(path: string): string { 12 | if (!isValidFormat(path)) throw new Error('Invalid format') 13 | 14 | return dirOnly(path).match(regex)?.at(2) ?? '' 15 | } 16 | 17 | export function generateStarName(path: string): string { 18 | if (!isValidFormat(path)) throw new Error('Invalid format') 19 | 20 | return dirOnly(path).match(regex)?.at(3) ?? '' 21 | } 22 | 23 | export function generateTitle(path: string): string { 24 | if (!isValidFormat(path)) throw new Error('Invalid format') 25 | 26 | return dirOnly(path).match(regex)?.at(4) ?? '' 27 | } 28 | 29 | export function generateWebsite(path: string): string { 30 | return dirOnly(path) 31 | } 32 | 33 | function isValidFormat(path: string): boolean { 34 | return regex.test(dirOnly(path)) 35 | } 36 | -------------------------------------------------------------------------------- /Api/src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const globalForPrisma = global as unknown as { prisma?: PrismaClient } 4 | 5 | const prisma = 6 | globalForPrisma.prisma ?? 7 | new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['info', 'warn', 'error'] : [] }) 8 | if (process.env.NODE_ENV === 'production') globalForPrisma.prisma = prisma 9 | 10 | export { prisma as db } 11 | -------------------------------------------------------------------------------- /Api/src/lib/validate.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export default function validate(schema: z.ZodType, body: unknown) { 4 | if (body === undefined) throw new Error('Request-body is undefined') 5 | 6 | const result = schema.safeParse(body) 7 | if (!result.success) { 8 | const issue = result.error.issues[0] 9 | 10 | if (issue.path.length === 1) { 11 | throw new Error(`${issue.path[0]} -> ${issue.message}`) 12 | } else if (issue.path.length > 1) { 13 | throw new Error(`${issue.path.filter(p => typeof p === 'string').join('.')} -> ${issue.message}`) 14 | } 15 | 16 | throw new Error(issue.message) 17 | } 18 | 19 | return result.data 20 | } 21 | 22 | export { z } 23 | -------------------------------------------------------------------------------- /Api/src/plugins/autoTagRoutes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | import fp from 'fastify-plugin' 3 | 4 | import capitalize from 'capitalize' 5 | 6 | import { getUnique } from '../lib' 7 | 8 | async function autoTagRoutes(fastify: FastifyInstance, opts: { prefix: string }) { 9 | fastify.addHook('onRoute', routeOptions => { 10 | if (routeOptions.schema?.hide) return // Skip hidden routes 11 | 12 | const prefixTag = capitalize(routeOptions.prefix.replace(new RegExp(`^${opts.prefix}/`), '')) 13 | 14 | if (!routeOptions.schema) routeOptions.schema = {} 15 | if (!routeOptions.schema.tags) { 16 | routeOptions.schema.tags = [prefixTag] 17 | } else { 18 | routeOptions.schema.tags = getUnique([...routeOptions.schema.tags, prefixTag]) 19 | } 20 | }) 21 | } 22 | 23 | export default fp(autoTagRoutes, { name: 'autoTagRoutes' }) 24 | -------------------------------------------------------------------------------- /Api/src/routes/attribute.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { db } from '../lib/prisma' 4 | import validate, { z } from '../lib/validate' 5 | 6 | export default async function attributeRoutes(fastify: FastifyInstance) { 7 | fastify.get('/', async () => { 8 | return await db.attribute.findMany({ 9 | orderBy: { id: 'asc' } 10 | }) 11 | }) 12 | 13 | fastify.post('/', async req => { 14 | const { name } = validate( 15 | z.object({ 16 | name: z.string().min(3) 17 | }), 18 | req.body 19 | ) 20 | 21 | return await db.attribute.create({ 22 | data: { name } 23 | }) 24 | }) 25 | 26 | fastify.put('/:id', async req => { 27 | const { id } = validate( 28 | z.object({ 29 | id: z.coerce.number() 30 | }), 31 | req.params 32 | ) 33 | 34 | const { name } = validate( 35 | z.object({ 36 | name: z.string() 37 | }), 38 | req.body 39 | ) 40 | 41 | return await db.attribute.update({ 42 | where: { id }, 43 | data: { name } 44 | }) 45 | }) 46 | 47 | fastify.delete('/:id/:videoId', async req => { 48 | const { id, videoId } = validate( 49 | z.object({ 50 | id: z.coerce.number(), 51 | videoId: z.coerce.number() 52 | }), 53 | req.params 54 | ) 55 | 56 | return await db.videoAttributes.delete({ 57 | where: { attributeID_videoID: { attributeID: id, videoID: videoId } } 58 | }) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /Api/src/routes/bookmark.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { db } from '../lib/prisma' 4 | import validate, { z } from '../lib/validate' 5 | 6 | export default async function bookmarkRoutes(fastify: FastifyInstance) { 7 | fastify.put('/:id', async (req, res) => { 8 | const { id } = validate( 9 | z.object({ 10 | id: z.coerce.number() 11 | }), 12 | req.params 13 | ) 14 | const { time, categoryID } = validate( 15 | z.object({ 16 | time: z.number().int().positive().optional(), 17 | categoryID: z.number().int().positive().optional() 18 | }), 19 | req.body 20 | ) 21 | if (time !== undefined) { 22 | return await db.bookmark.update({ 23 | where: { id }, 24 | data: { start: time } 25 | }) 26 | } else if (categoryID !== undefined) { 27 | return await db.bookmark.update({ 28 | where: { id }, 29 | data: { category: { connect: { id: categoryID } } } 30 | }) 31 | } 32 | }) 33 | 34 | fastify.delete('/:id', async (req, res) => { 35 | const { id } = validate( 36 | z.object({ 37 | id: z.coerce.number() 38 | }), 39 | req.params 40 | ) 41 | 42 | return await db.bookmark.delete({ 43 | where: { id } 44 | }) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /Api/src/routes/category.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { db } from '../lib/prisma' 4 | import validate, { z } from '../lib/validate' 5 | 6 | export default async function categoryRoutes(fastify: FastifyInstance) { 7 | fastify.get('/', async () => { 8 | return await db.category.findMany({ 9 | orderBy: { name: 'asc' } 10 | }) 11 | }) 12 | 13 | fastify.post('/', async req => { 14 | const { name } = validate( 15 | z.object({ 16 | name: z.string().min(3) 17 | }), 18 | req.body 19 | ) 20 | 21 | return await db.category.create({ 22 | data: { name } 23 | }) 24 | }) 25 | 26 | fastify.put('/:id', async req => { 27 | const { id } = validate( 28 | z.object({ 29 | id: z.coerce.number() 30 | }), 31 | req.params 32 | ) 33 | 34 | const { name } = validate( 35 | z.object({ 36 | name: z.string().min(3) 37 | }), 38 | req.body 39 | ) 40 | 41 | return await db.category.update({ 42 | where: { id }, 43 | data: { name } 44 | }) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /Api/src/routes/generate.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { Star, Video } from '@prisma/client' 4 | 5 | import { downloader, fileExists, getPercent, sleep } from '../lib' 6 | import { extractVtt, getDuration, getHeight, getWidth, resizeImage } from '../lib/ffmpeg' 7 | import { generateStarName } from '../lib/generate' 8 | import { findSceneSlug, getSceneData } from '../lib/metadata' 9 | import { db } from '../lib/prisma' 10 | 11 | export default async function generateRoutes(fastify: FastifyInstance) { 12 | fastify.post('/meta', async () => { 13 | async function starIsIgnored(star: string): Promise { 14 | return (await db.star.count({ where: { name: star, autoTaggerIgnore: true } })) > 0 15 | } 16 | 17 | async function aliasExists(alias: string): Promise { 18 | return (await db.starAlias.count({ where: { name: alias } })) > 0 19 | } 20 | 21 | async function aliasIsIgnored(alias: string): Promise { 22 | return ( 23 | await db.starAlias.findFirstOrThrow({ 24 | where: { name: alias }, 25 | include: { star: true } 26 | }) 27 | ).star.autoTaggerIgnore 28 | } 29 | 30 | async function starExists(star: string): Promise { 31 | return (await db.star.count({ where: { name: star } })) > 0 32 | } 33 | 34 | async function getAliasAsStar(alias: string): Promise { 35 | return (await db.starAlias.findFirstOrThrow({ where: { name: alias }, include: { star: true } })).star 36 | } 37 | 38 | const checkStarRelation = async (video: Video) => { 39 | const star = generateStarName(video.path) 40 | 41 | // if video-star-relation is missing and video-starAlias-relation is missing 42 | if (!(await videoStarExists(video.id, star)) && !(await videoStarAliasExists(video.id, star))) { 43 | // check if star is ignored 44 | if (await starIsIgnored(star)) { 45 | return 46 | } 47 | 48 | // check if alias is ignored 49 | if ((await aliasExists(star)) && (await aliasIsIgnored(star))) { 50 | return 51 | } 52 | 53 | // add star or alias to video 54 | if (await starExists(star)) { 55 | await addVideoStar( 56 | video.id, 57 | ( 58 | await db.star.findFirstOrThrow({ 59 | where: { name: star } 60 | }) 61 | ).id 62 | ) 63 | } else if (await starAliasExists(star)) { 64 | await addVideoStar(video.id, (await getAliasAsStar(star)).id) 65 | } 66 | } 67 | } 68 | 69 | const videoStarExists = async (videoID: number, star: string) => { 70 | return (await db.video.count({ where: { id: videoID, star: { name: star } } })) > 0 71 | } 72 | 73 | const videoStarAliasExists = async (videoID: number, alias: string) => { 74 | return ( 75 | (await db.video.count({ 76 | where: { id: videoID, star: { alias: { every: { name: alias } } } } 77 | })) > 0 78 | ) 79 | } 80 | 81 | const starAliasExists = async (alias: string) => { 82 | return (await db.starAlias.count({ where: { name: alias } })) > 0 83 | } 84 | 85 | const addVideoStar = async (videoID: number, starID: number) => { 86 | await db.video.update({ 87 | where: { id: videoID }, 88 | data: { star: { connect: { id: starID } } } 89 | }) 90 | } 91 | 92 | const fixVideos = await db.video.findMany({ where: { OR: [{ duration: 0 }, { height: 0 }, { width: 0 }] } }) 93 | for (let i = 0; i < fixVideos.length; i++) { 94 | const video = fixVideos[i] 95 | 96 | const videoPath = `videos/${video.path}` 97 | const absoluteVideoPath = `./media/${videoPath}` 98 | 99 | if (await fileExists(absoluteVideoPath)) { 100 | console.log(`(${getPercent(i + 1, fixVideos.length)}%) Rebuilding ${video.path}`) 101 | const width = await getWidth(absoluteVideoPath) 102 | const height = await getHeight(absoluteVideoPath) 103 | const duration = await getDuration(absoluteVideoPath) 104 | 105 | await db.video.update({ 106 | where: { id: video.id }, 107 | data: { duration, height, width } 108 | }) 109 | } 110 | } 111 | 112 | const infoVideos = await db.video.findMany({ 113 | where: { api: null, ignoreMeta: false }, 114 | include: { site: true, website: true } 115 | }) 116 | for await (const video of infoVideos) { 117 | await sleep(400) // 400ms between requests 118 | await findSceneSlug(generateStarName(video.path), video.name, video.site?.name ?? video.website.name) 119 | .then(async slug => { 120 | await db.video.update({ 121 | where: { id: video.id }, 122 | data: { api: slug, ignoreMeta: true } 123 | }) 124 | }) 125 | .catch(async err => { 126 | if (err instanceof Error) { 127 | if (err.cause === 'too few slugs' || err.cause === 'too many slugs') { 128 | await db.video.update({ where: { id: video.id }, data: { ignoreMeta: true } }) 129 | } 130 | console.log(`${err.message} - ${err.cause}`) 131 | return 132 | } 133 | 134 | console.log(err) 135 | }) 136 | } 137 | 138 | const videos = await db.video.findMany({ 139 | where: { starID: null } 140 | }) 141 | for (const video of videos) { 142 | // Only check relation if the video has not been moved! 143 | if (await fileExists(`./media/videos/${video.path}`)) { 144 | await checkStarRelation(video) 145 | } 146 | } 147 | 148 | console.log('Finished generating Metadata') 149 | }) 150 | 151 | fastify.post('/vtt', async () => { 152 | const videos = await db.video.findMany({ 153 | where: { duration: { gt: 0 }, height: { gt: 0 }, width: { gt: 0 } } 154 | }) 155 | 156 | const generatePath = (video: (typeof videos)[number]) => { 157 | const videoPath = `videos/${video.path}` 158 | const imagePath = `vtt/${video.id}.jpg` 159 | const vttPath = `vtt/${video.id}.vtt` 160 | 161 | const absoluteVideoPath = `./media/${videoPath}` 162 | const absoluteImagePath = `./media/${imagePath}` 163 | const absoluteVttPath = `./media/${vttPath}` 164 | 165 | return { videoPath: absoluteVideoPath, imagePath: absoluteImagePath, vttPath: absoluteVttPath } 166 | } 167 | 168 | const missingVtt: typeof videos = [] 169 | for await (const video of videos) { 170 | const { videoPath, imagePath, vttPath } = generatePath(video) 171 | 172 | if ((await fileExists(videoPath)) && (!(await fileExists(vttPath)) || !(await fileExists(imagePath)))) { 173 | missingVtt.push(video) 174 | } 175 | } 176 | 177 | for (let i = 0; i < missingVtt.length; i++) { 178 | const video = missingVtt[i] 179 | 180 | const { imagePath, videoPath } = generatePath(video) 181 | 182 | console.log(`(${getPercent(i + 1, missingVtt.length)}%) Generating VTT path=${video.path}`) 183 | if (await fileExists(videoPath)) { 184 | await extractVtt(videoPath, imagePath, video.id) 185 | } 186 | } 187 | 188 | console.log('Finished generating VTT') 189 | }) 190 | 191 | fastify.post('/thumb', async () => { 192 | const videos = await db.video.findMany({ 193 | where: { api: { not: null }, cover: null } 194 | }) 195 | 196 | for (let i = 0; i < videos.length; i++) { 197 | const video = videos[i] 198 | 199 | if (video.api !== null) { 200 | const image = (await getSceneData(video.api, true)).image 201 | if (image) { 202 | const imagePath = `images/videos/${video.id}.jpg` 203 | const imagePath_low = `images/videos/${video.id}-${290}.jpg` 204 | 205 | console.log(`(${getPercent(i + 1, videos.length)}%) Generating Thumbnail id=${video.id}`) 206 | 207 | // download file (if missing) 208 | if (!(await fileExists(`./media/${imagePath}`))) { 209 | await downloader(image, `media/${imagePath}`, 'URL') 210 | 211 | // upscale image to w=1920 212 | await resizeImage(`./media/${imagePath}`, 1920) 213 | } 214 | 215 | { 216 | await downloader(`media/${imagePath}`, `media/${imagePath_low}`, 'FILE') // copy file 217 | 218 | // downscale image to w=290 219 | await resizeImage(`./media/${imagePath_low}`, 290) 220 | } 221 | 222 | await db.video.update({ where: { id: video.id }, data: { cover: `${video.id}.jpg` } }) 223 | } 224 | } 225 | } 226 | 227 | console.log('Finished generating Thumbnails') 228 | }) 229 | } 230 | -------------------------------------------------------------------------------- /Api/src/routes/home.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { db } from '../lib/prisma' 4 | import validate, { z } from '../lib/validate' 5 | 6 | export default async function homeRoutes(fastify: FastifyInstance) { 7 | fastify.get('/:label/:limit', async req => { 8 | const { label, limit } = validate( 9 | z.object({ 10 | label: z.enum(['recent', 'newest', 'popular']), 11 | limit: z.coerce.number().int().min(1) 12 | }), 13 | req.params 14 | ) 15 | 16 | switch (label) { 17 | case 'recent': 18 | return ( 19 | await db.video.findMany({ 20 | select: { id: true, name: true, cover: true }, 21 | orderBy: { id: 'desc' }, 22 | take: limit 23 | }) 24 | ).map(({ cover, ...video }) => ({ 25 | ...video, 26 | image: cover 27 | })) 28 | case 'newest': 29 | return ( 30 | await db.video.findMany({ 31 | select: { id: true, name: true, cover: true }, 32 | orderBy: { date: 'desc' }, 33 | take: limit 34 | }) 35 | ).map(({ cover, ...video }) => ({ 36 | ...video, 37 | image: cover 38 | })) 39 | case 'popular': 40 | return ( 41 | await db.video.findMany({ 42 | include: { _count: { select: { plays: true } } }, 43 | orderBy: [{ plays: { _count: 'desc' } }, { date: 'desc' }], 44 | take: limit 45 | }) 46 | ).map(({ cover, _count, ...video }) => ({ 47 | ...video, 48 | image: cover, 49 | total: _count.plays 50 | })) 51 | } 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /Api/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import attributeRoutes from './attribute' 4 | import bookmarkRoutes from './bookmark' 5 | import categoryRoutes from './category' 6 | import generateRoutes from './generate' 7 | import homeRoutes from './home' 8 | import locationRoutes from './location' 9 | import searchRoutes from './search' 10 | import starRoutes from './star' 11 | import videoRoutes from './video' 12 | import websiteRoutes from './website' 13 | 14 | export default async function routes(fastify: FastifyInstance) { 15 | fastify.register(attributeRoutes, { prefix: '/attribute' }) 16 | fastify.register(bookmarkRoutes, { prefix: '/bookmark' }) 17 | fastify.register(categoryRoutes, { prefix: '/category' }) 18 | fastify.register(homeRoutes, { prefix: '/home' }) 19 | fastify.register(generateRoutes, { prefix: '/generate' }) 20 | fastify.register(locationRoutes, { prefix: '/location' }) 21 | fastify.register(searchRoutes, { prefix: '/search' }) 22 | fastify.register(starRoutes, { prefix: '/star' }) 23 | fastify.register(videoRoutes, { prefix: '/video' }) 24 | fastify.register(websiteRoutes, { prefix: '/website' }) 25 | } 26 | -------------------------------------------------------------------------------- /Api/src/routes/location.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { db } from '../lib/prisma' 4 | import validate, { z } from '../lib/validate' 5 | 6 | export default async function locationRoutes(fastify: FastifyInstance) { 7 | fastify.get('/', async () => { 8 | return await db.location.findMany({ 9 | orderBy: { id: 'asc' } 10 | }) 11 | }) 12 | 13 | fastify.post('/', async req => { 14 | const { name } = validate( 15 | z.object({ 16 | name: z.string().min(3) 17 | }), 18 | req.body 19 | ) 20 | 21 | return await db.location.create({ 22 | data: { name } 23 | }) 24 | }) 25 | 26 | fastify.put('/:id', async req => { 27 | const { id } = validate( 28 | z.object({ 29 | id: z.coerce.number() 30 | }), 31 | req.params 32 | ) 33 | 34 | const { name } = validate( 35 | z.object({ 36 | name: z.string().min(3) 37 | }), 38 | req.body 39 | ) 40 | 41 | return await db.location.update({ 42 | where: { id }, 43 | data: { name } 44 | }) 45 | }) 46 | 47 | fastify.delete('/:id/:videoId', async (request, reply) => { 48 | const { id, videoId } = validate( 49 | z.object({ 50 | id: z.coerce.number(), 51 | videoId: z.coerce.number() 52 | }), 53 | request.params 54 | ) 55 | 56 | return await db.videoLocations.delete({ 57 | where: { locationID_videoID: { locationID: id, videoID: videoId } } 58 | }) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /Api/src/routes/search.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { Site, Website } from '@prisma/client' 4 | 5 | import { dateDiff, formatDate, getUnique } from '../lib' 6 | import { db } from '../lib/prisma' 7 | 8 | export default async function searchRoutes(fastify: FastifyInstance) { 9 | fastify.get('/star', async () => { 10 | const calculateScore = (websitesWithSites: (Website & { sites: Site[] })[], sites: Site[]): number => { 11 | const calculateSiteScore = (website: Website & { sites: Site[] }, sites: Site[]): number => { 12 | // Get all the sites with the same websiteID as the current website 13 | const filteredSites = sites.filter(site => site.websiteID === website.id) 14 | 15 | // If the website has no sites, return 0 16 | if (website.sites.length === 0) return 0 17 | 18 | // Return the percentage of sites that are in the filteredSites array 19 | return filteredSites.length / website.sites.length 20 | } 21 | 22 | // Map over all websites and calculate the score for each 23 | return websitesWithSites.map(website => calculateSiteScore(website, sites)).reduce((sum, score) => sum + score, 0) 24 | } 25 | 26 | return ( 27 | await db.star.findMany({ 28 | orderBy: { name: 'asc' }, 29 | include: { videos: { include: { website: { include: { sites: true } }, site: true } }, haircolors: true } 30 | }) 31 | ).map(star => { 32 | const websites = getUnique( 33 | star.videos.map(({ website }) => website), 34 | 'id' 35 | ) 36 | const sites = getUnique( 37 | star.videos.flatMap(({ site }) => (site !== null ? [site] : [])), 38 | 'id' 39 | ) 40 | 41 | return { 42 | id: star.id, 43 | name: star.name, 44 | image: star.image, 45 | breast: star.breast, 46 | haircolor: star.haircolors.map(haircolor => haircolor.hair), 47 | ethnicity: star.ethnicity, 48 | age: dateDiff(star.birthdate), 49 | videoCount: star.videos.length, 50 | lastDate: star.videos 51 | .map(v => v.date.getTime()) 52 | .sort((a, b) => b - a) 53 | .at(0), 54 | retired: star.retired, 55 | score: calculateScore(websites, sites), 56 | websites: websites.map(website => website.name), 57 | sites: sites.map(site => site.name) 58 | } 59 | }) 60 | }) 61 | 62 | fastify.get('/video', async () => { 63 | return ( 64 | await db.video.findMany({ 65 | select: { 66 | id: true, 67 | height: true, 68 | date: true, 69 | api: true, 70 | cover: true, 71 | name: true, 72 | star: { select: { name: true, birthdate: true } }, 73 | website: { select: { name: true } }, 74 | site: { select: { name: true } }, 75 | _count: { select: { plays: true } }, 76 | bookmarks: { select: { category: { select: { name: true } } } }, 77 | attributes: { select: { attribute: { select: { name: true } } } }, 78 | locations: { select: { location: { select: { name: true } } } } 79 | }, 80 | orderBy: { name: 'asc' } 81 | }) 82 | ).map(({ height, cover, bookmarks, _count, ...video }) => ({ 83 | ...video, 84 | quality: height, 85 | date: formatDate(video.date), 86 | image: cover, 87 | star: video.star?.name ?? null, 88 | ageInVideo: dateDiff(video.star?.birthdate, video.date), 89 | website: video.website.name, 90 | site: video.site?.name ?? null, 91 | plays: _count.plays, 92 | categories: getUnique(bookmarks.map(({ category }) => category.name)), 93 | attributes: video.attributes.map(({ attribute }) => attribute.name), 94 | locations: video.locations.map(({ location }) => location.name) 95 | })) 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /Api/src/routes/website.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { db } from '../lib/prisma' 4 | 5 | export default async function websiteRoutes(fastify: FastifyInstance) { 6 | fastify.get('/', async () => { 7 | const websites = await db.website.findMany({ 8 | include: { _count: { select: { videos: true } }, sites: { orderBy: { name: 'asc' } } }, 9 | orderBy: { name: 'asc' } 10 | }) 11 | 12 | return websites.map(website => ({ 13 | ...website, 14 | sites: website.sites.map(site => site.name) 15 | })) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /Api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "fastify-tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src/**/*.ts", "global.d.ts"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /Client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended-type-checked', 7 | 'plugin:@typescript-eslint/stylistic-type-checked', 8 | 'plugin:react/recommended', 9 | 'plugin:react/jsx-runtime', 10 | 'plugin:react-hooks/recommended', 11 | 'prettier' 12 | ], 13 | ignorePatterns: ['dist', '.eslintrc.cjs'], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 'latest', 17 | sourceType: 'module', 18 | project: ['./tsconfig.json', './tsconfig.node.json'], 19 | tsconfigRootDir: __dirname 20 | }, 21 | plugins: ['react-refresh'], 22 | settings: { react: { version: 'detect' } }, 23 | rules: { 24 | // Common eslint 25 | 'import/no-anonymous-default-export': 'off', 26 | eqeqeq: 'warn', 27 | 'no-duplicate-imports': 'warn', 28 | 29 | // Typescript Eslint 30 | '@typescript-eslint/no-floating-promises': 'off', 31 | '@typescript-eslint/no-confusing-void-expression': 'off', 32 | '@typescript-eslint/unified-signatures': 'off', 33 | '@typescript-eslint/no-unnecessary-condition': 'error', 34 | '@typescript-eslint/consistent-type-definitions': ['error', 'type'], 35 | 36 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Client/FEATURES.md: -------------------------------------------------------------------------------- 1 | # Feature implementation status (OUTDATED) 2 | 3 | ## Priority Explanation 4 | 5 | | Priority | Explanation | 6 | | :--------: | ------------------------------------------------------------- | 7 | | HIGH | Feature will be implemented as soon as possible | 8 | | LOW | Feature will take some time to implement, or might be removed | 9 | | FUNCTIONAL | Feature is working, but not optimal | 10 | 11 | ## Issue Explanation 12 | 13 | | Name | Description | 14 | | --------------- | ------------------------------------------------------------------------ | 15 | | VideoFileRename | Solution breaks the VideoPlayer, so reloading is still the best solution | 16 | | StarVideosHover | Throws an error when hovering, still works though | 17 | | StarIgnoreIcon | Icon is not changed, unless page is refreshed | 18 | 19 | ## :heavy_check_mark: Home Page 20 | 21 | | Name | Status | 22 | | -------------- | ------------------ | 23 | | Recent Videos | :heavy_check_mark: | 24 | | Newest Videos | :heavy_check_mark: | 25 | | Popular Videos | :heavy_check_mark: | 26 | 27 | ## :x: Import Videos 28 | 29 | | Name | Status | Priority | 30 | | ------------------- | ------------------ | :------: | 31 | | Import Videos | :heavy_check_mark: | | 32 | | Generate Thumbnails | :heavy_check_mark: | | 33 | | Generate WebVTT | :x: | LOW | 34 | 35 | ## :warning: Video Search 36 | 37 | ### :heavy_check_mark: Main Section 38 | 39 | | Name | Status | 40 | | ------------- | ------------------ | 41 | | Video | :heavy_check_mark: | 42 | | Video Ribbon | :heavy_check_mark: | 43 | | Video Counter | :heavy_check_mark: | 44 | 45 | ### :warning: Sidebar 46 | 47 | | Name | Status | Priority | 48 | | -------------------- | ------------------------------------ | :------: | 49 | | Title Search _INPUT_ | :heavy_check_mark: | | 50 | | Sort | :heavy_check_mark: | | 51 | | Website _DROPDOWN_ | :x: Should filter websites and sites | LOW | 52 | | Category _CHECKBOX_ | :heavy_check_mark: | | 53 | | Attribute _CHECKBOX_ | :heavy_check_mark: | | 54 | | Location _CHECKBOX_ | :heavy_check_mark: | | 55 | 56 | ## :heavy_check_mark: Star Search 57 | 58 | ### :heavy_check_mark: Main Section 59 | 60 | | Name | Status | 61 | | ------------ | ------------------ | 62 | | Star | :heavy_check_mark: | 63 | | Star Counter | :heavy_check_mark: | 64 | 65 | ### :heavy_check_mark: Sidebar 66 | 67 | | Name | Status | 68 | | ------------------- | ------------------ | 69 | | Name Search _INPUT_ | :heavy_check_mark: | 70 | | Sort | :heavy_check_mark: | 71 | | Breast _RADIO_ | :heavy_check_mark: | 72 | | Haircolor _RADIO_ | :heavy_check_mark: | 73 | | Ethnicity _RADIO_ | :heavy_check_mark: | 74 | | Country _DROPDOWN_ | :heavy_check_mark: | 75 | 76 | ## :warning: Video Page 77 | 78 | ### :heavy_check_mark: Heading 79 | 80 | | Name | Status | 81 | | ---------------- | ------------------ | 82 | | Rename Title | :heavy_check_mark: | 83 | | Add Attribute | :heavy_check_mark: | 84 | | Add Location | :heavy_check_mark: | 85 | | Copy Title | :heavy_check_mark: | 86 | | Copy Star | :heavy_check_mark: | 87 | | Remove Attribute | :heavy_check_mark: | 88 | | Remove Location | :heavy_check_mark: | 89 | | Refresh Date | :heavy_check_mark: | 90 | | Next ID | :heavy_check_mark: | 91 | 92 | ### :heavy_check_mark: Video 93 | 94 | | Name | Status | Priority | 95 | | ---------------- | --------------------------------------------- | :--------: | 96 | | Add Bookmark | :heavy_check_mark: | | 97 | | Set Age | :heavy_check_mark: | | 98 | | Rename File | :heavy_check_mark: [Info](#issue-explanation) | FUNCTIONAL | 99 | | Remove Bookmarks | :heavy_check_mark: | | 100 | | Remove Plays | :heavy_check_mark: | | 101 | | Delete Video | :heavy_check_mark: | | 102 | 103 | ### :heavy_check_mark: Bookmark 104 | 105 | | Name | Status | 106 | | --------------- | ------------------ | 107 | | Change Category | :heavy_check_mark: | 108 | | Change Time | :heavy_check_mark: | 109 | | Delete Bookmark | :heavy_check_mark: | 110 | 111 | ### :heavy_check_mark: Star 112 | 113 | | Name | Status | 114 | | ----------- | ------------------ | 115 | | Remove Star | :heavy_check_mark: | 116 | | [Hover] | :heavy_check_mark: | 117 | 118 | ### :x: Star-Form 119 | 120 | | Name | Status | Priority | 121 | | ---------------- | ------ | :------: | 122 | | Add Star _INPUT_ | :x: | LOW | 123 | 124 | ## :heavy_check_mark: Star Page 125 | 126 | ### :heavy_check_mark: Dropbox 127 | 128 | | Name | Status | 129 | | ------------ | ------------------ | 130 | | Drop Area | :heavy_check_mark: | 131 | | Delete Star | :heavy_check_mark: | 132 | | [ImageHover] | :heavy_check_mark: | 133 | 134 | ### :heavy_check_mark: Image 135 | 136 | | Name | Status | 137 | | ------------ | ------------------ | 138 | | Delete Image | :heavy_check_mark: | 139 | 140 | ### :heavy_check_mark: Star Name 141 | 142 | | Name | Status | 143 | | ------ | ------------------------------ | 144 | | Rename | :heavy_check_mark: | 145 | | Ignore | :x: [Info](#issue-explanation) | 146 | 147 | ### :heavy_check_mark: Input Fields 148 | 149 | | Name | Status | 150 | | ------------------ | ------------------ | 151 | | Breast _INPUT_ | :heavy_check_mark: | 152 | | Eye Color _INPUT_ | :heavy_check_mark: | 153 | | Hair Color _INPUT_ | :heavy_check_mark: | 154 | | Ethnicity _INPUT_ | :heavy_check_mark: | 155 | | Country _INPUT_ | :heavy_check_mark: | 156 | | Birthdate _INPUT_ | :heavy_check_mark: | 157 | | Height _INPUT_ | :heavy_check_mark: | 158 | | Weight _INPUT_ | :heavy_check_mark: | 159 | | Start _INPUT_ | :heavy_check_mark: | 160 | | End _INPUT_ | :heavy_check_mark: | 161 | 162 | ### :heavy_check_mark: Video list 163 | 164 | | Name | Status | Priority | 165 | | --------------- | --------------------------------------------- | :--------: | 166 | | Video Thumbnail | :heavy_check_mark: | | 167 | | Video Title | :heavy_check_mark: | | 168 | | [Hover] | :heavy_check_mark: [Info](#issue-explanation) | FUNCTIONAL | 169 | -------------------------------------------------------------------------------- /Client/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 asusguy94 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Client/README.md: -------------------------------------------------------------------------------- 1 | # Porn NextJS 2 | 3 | ## Note about thumbnails 4 | 5 | The image server is currently broken, ~~I recommend not generating cover-images, deleting all files in `images/videos`, and running `UPDATE VIDEO SET COVER = NULL` on the database to prevent issues. I have disabled the generation logic from the browser, but if you still want to do it, you can clone this repo and revert the last change.~~ I've created a simple upscaling algorithm, so cover-images should now work, and I re-enabled the genaration logic. Tiny images will hovever not look quite as good as before, and if the server ever is updated fully, I will change it back. 6 | 7 | ## Requirements 8 | 9 | 1. Modern Web Browser 10 | 2. Browser resolution set to 1920x1080 (sotf-requirement) 11 | - This should not be an issue any more 12 | 3. Pnpm package manger 13 | 4. Database (preferable mariaDB) 14 | - host 15 | - username 16 | - password 17 | - database 18 | 5. Docker application (eg. Docker Desktop) 19 | 20 | ## Installation 21 | 22 | Any setting ending with `*` is required 23 | 24 | ### List of settings 25 | 26 | | Keyword | Description | 27 | | -------------- | -------------------------------------------------------------------------------------------------- | 28 | | DATABASE_URL\* | The database URL for your chosen database | 29 | | THEPORNDB_API | The API-KEY used, for getting data | 30 | | PORT\* | _Only required for docker._ The port used for the application (default=`3000`) | 31 | | PATH\* | _Only docker._ The path to map to `app/media` (this directory should contain a `videos`-directory) | 32 | 33 | ### With Docker 34 | 35 | Map the required path, port, and variables, [see the table above](#list-of-settings) 36 | 37 | ### Without Docker 38 | 39 | Add a `videos`-directory inside a `media`-directory in the `root`-directory, and place all your videos within the `videos`-directory 40 | 41 | `sample.env` can be found in the root-directory, just rename it to `.env`, and change the data of the file. 42 | 43 | Change any required/optional variables [see the table above](#list-of-settings) 44 | 45 | Run the following command to generate your database structure 46 | 47 | ```bash 48 | pnpm prisma db push 49 | ``` 50 | 51 | ## Starting the app 52 | 53 | ### Starting the app with docker 54 | 55 | Start the docker container 56 | 57 | ### Starting the app without docker 58 | 59 | Run the following command 60 | 61 | ```bash 62 | pnpm run build && pnpm run start 63 | ``` 64 | 65 | ## Features 66 | 67 | Status of functionality can be found at [features.md](FEATURES.md) 68 | 69 | ## Recommendations 70 | 71 | ### Screen resolution 72 | 73 | | Resolution | Attributes / Locations / Categories | 74 | | ---------- | ----------------------------------- | 75 | | 1080p | 0-18 | 76 | | 1440p | 19-37 | 77 | | 2160p | 38+ | 78 | 79 | ## Notes about incompatible packages 80 | 81 | - [sharp@0.33.2](https://github.com/vercel/next.js/issues/59516) 82 | -------------------------------------------------------------------------------- /Client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Porn 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Client/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "*", 3 | "delay": 300 4 | } 5 | -------------------------------------------------------------------------------- /Client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pornts-vite", 3 | "version": "0.3.0", 4 | "license": "MIT", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "start": "pnpm build && pnpm preview", 12 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives", 13 | "format": "prettier --write .", 14 | "test": "jest" 15 | }, 16 | "dependencies": { 17 | "@emotion/react": "^11.10.6", 18 | "@emotion/styled": "^11.10.6", 19 | "@lukemorales/query-key-factory": "^1.3.4", 20 | "@mui/icons-material": "^5.15.19", 21 | "@mui/material": "^5.15.19", 22 | "@tanstack/react-query": "^5.40.1", 23 | "@tanstack/react-router": "^1.35.5", 24 | "@vidstack/react": "^1.11.22", 25 | "axios": "^1.7.2", 26 | "capitalize": "^2.0.4", 27 | "dayjs": "^1.11.7", 28 | "hls.js": "^1.5.11", 29 | "rctx-contextmenu": "^1.4.1", 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "react-flip-toolkit": "^7.0.17", 33 | "react-lazy-load-image-component": "^1.5.6", 34 | "react-scroll-to-top": "^3.0.0", 35 | "react-toastify": "^10.0.0", 36 | "react-use": "^17.4.0", 37 | "react-virtuoso": "^4.7.11", 38 | "sass": "^1.77.4", 39 | "usehooks-ts": "^3.0.1" 40 | }, 41 | "devDependencies": { 42 | "@tanstack/router-vite-plugin": "^1.35.4", 43 | "@trivago/prettier-plugin-sort-imports": "^4.1.1", 44 | "@types/capitalize": "^2.0.0", 45 | "@types/node": "^20.14.2", 46 | "@types/react": "^18.3.3", 47 | "@types/react-dom": "^18.0.11", 48 | "@types/react-lazy-load-image-component": "^1.5.2", 49 | "@typescript-eslint/eslint-plugin": "^7.13.0", 50 | "@typescript-eslint/parser": "^7.13.0", 51 | "@vitejs/plugin-react": "^4.3.1", 52 | "eslint": "^8.37.0", 53 | "eslint-config-prettier": "^9.1.0", 54 | "eslint-plugin-react": "^7.34.2", 55 | "eslint-plugin-react-hooks": "^4.6.0", 56 | "eslint-plugin-react-refresh": "^0.4.5", 57 | "prettier": "^3.3.1", 58 | "typescript": "^5.0.4", 59 | "vite": "^5.2.13" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Client/src/components/badge/badge.module.scss: -------------------------------------------------------------------------------- 1 | .badge[data-badge] { 2 | &:after { 3 | content: attr(data-badge); 4 | position: absolute; 5 | background: green; 6 | color: white; 7 | text-align: center; 8 | border-radius: 50%; 9 | box-shadow: 0 0 1px #333; 10 | 11 | left: 3px; 12 | top: 3px; 13 | 14 | // Cicrle size 15 | width: 5px; 16 | height: 5px; 17 | line-height: 5px; 18 | } 19 | 20 | // 1-9 Videos 21 | &.small:after { 22 | width: 23px; 23 | height: 23px; 24 | line-height: 23px; 25 | } 26 | 27 | // 10-99 videos 28 | &.large:after { 29 | width: 26px; 30 | height: 23px; 31 | line-height: 23px; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Client/src/components/badge/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './badge.module.scss' 2 | 3 | type BadgeProps = { 4 | content: number 5 | children: React.ReactNode 6 | } 7 | export default function Badge({ content, children }: BadgeProps) { 8 | const badgeClass = content < 10 ? styles.small : styles.large 9 | 10 | return ( 11 |
12 | {children} 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /Client/src/components/dropbox/dropbox.module.css: -------------------------------------------------------------------------------- 1 | #dropbox { 2 | width: 200px; 3 | height: 275px; 4 | border: 2px dashed rgba(255, 0, 0, 0.3); 5 | 6 | &.hover { 7 | border-color: rgba(255, 0, 0, 1); 8 | } 9 | 10 | .label { 11 | position: relative; 12 | text-align: center; 13 | top: 50%; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Client/src/components/dropbox/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import styles from './dropbox.module.css' 4 | 5 | type DropboxProps = { 6 | onDrop: (e: string) => void 7 | } 8 | export default function Dropbox({ onDrop }: DropboxProps) { 9 | const [hover, setHover] = useState(false) 10 | 11 | const handleDefault = (e: React.DragEvent) => { 12 | e.stopPropagation() 13 | e.preventDefault() 14 | } 15 | 16 | const handleEnter = (e: React.DragEvent) => { 17 | handleDefault(e) 18 | 19 | setHover(true) 20 | } 21 | 22 | const handleLeave = (e: React.DragEvent) => { 23 | handleDefault(e) 24 | 25 | setHover(false) 26 | } 27 | 28 | const handleDrop = (e: React.DragEvent) => { 29 | handleDefault(e) 30 | 31 | onDrop(e.dataTransfer.getData('text')) 32 | 33 | setHover(false) 34 | } 35 | 36 | return ( 37 |
45 |
Drop Image Here
46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /Client/src/components/error.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorComponent as DefaultErrorComponent, ErrorComponentProps } from '@tanstack/react-router' 2 | 3 | export default function ErrorComponent({ error }: ErrorComponentProps) { 4 | if (error instanceof Error && import.meta.env.DEV) { 5 | return ( 6 |
7 |

{error.message}

8 |
{error.stack}
9 |
10 | ) 11 | } 12 | 13 | return 14 | } 15 | -------------------------------------------------------------------------------- /Client/src/components/icon.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AccessTimeOutlined, 3 | AddLocationAltOutlined, 4 | AddOutlined, 5 | BlockOutlined, 6 | BorderColorOutlined, 7 | CheckOutlined, 8 | ContentCopyOutlined, 9 | DeleteOutline, 10 | EventAvailableOutlined, 11 | LocationOnOutlined, 12 | PersonOutline, 13 | SellOutlined, 14 | SlideshowOutlined, 15 | SyncOutlined 16 | } from '@mui/icons-material' 17 | import { Grid, SvgIconTypeMap } from '@mui/material' 18 | 19 | import { ContextMenuItem } from 'rctx-contextmenu' 20 | 21 | type Toggle = 'toggle-no' | 'toggle-yes' 22 | type Map = 'map' | 'add-map' 23 | type Basic = 'add' | 'edit' | 'delete' 24 | 25 | type IconProps = Omit & { 26 | code: Basic | Toggle | Map | 'copy' | 'time' | 'calendar' | 'film' | 'tag' | 'person' | 'sync' 27 | style?: React.CSSProperties 28 | } 29 | export default function Icon({ code, ...other }: IconProps) { 30 | switch (code) { 31 | case 'edit': 32 | return 33 | case 'add': 34 | return 35 | case 'toggle-yes': 36 | return 37 | case 'toggle-no': 38 | return 39 | case 'delete': 40 | return 41 | case 'copy': 42 | return 43 | case 'time': 44 | return 45 | case 'calendar': 46 | return 47 | case 'film': 48 | return 49 | case 'map': 50 | return 51 | case 'add-map': 52 | return 53 | case 'tag': 54 | return 55 | case 'person': 56 | return 57 | case 'sync': 58 | return 59 | } 60 | } 61 | 62 | type IconWithTextProps = Omit & { 63 | icon: IconProps['code'] 64 | text: string 65 | component: React.ElementType 66 | } 67 | export function IconWithText({ icon: code, text, component, ...other }: IconWithTextProps) { 68 | return ( 69 | 70 | {text} 71 | 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /Client/src/components/image/missing.tsx: -------------------------------------------------------------------------------- 1 | import ImageNotSupportedOutlinedIcon from '@mui/icons-material/ImageNotSupportedOutlined' 2 | 3 | type MissingImageProps = { 4 | scale?: number 5 | renderStyle?: 'height' | 'transform' 6 | } 7 | 8 | export default function MissingImage({ scale = 1, renderStyle }: MissingImageProps) { 9 | if (scale <= 0) throw new Error('Scale must be greater than zero') 10 | 11 | return ( 12 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /Client/src/components/indeterminate/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import { Checkbox, FormControlLabel, FormControlLabelProps } from '@mui/material' 4 | 5 | import RegularItem, { RegularHandlerProps } from './regular-item' 6 | 7 | export type HandlerProps = { 8 | checked: boolean 9 | indeterminate: boolean 10 | } 11 | function handler({ checked, indeterminate }: HandlerProps) { 12 | if (checked) { 13 | return { indeterminate: true, checked: false } 14 | } else if (indeterminate) { 15 | return { indeterminate: false, checked: false } 16 | } else { 17 | return { indeterminate: false, checked: true } 18 | } 19 | } 20 | type ItemProps = { 21 | label: FormControlLabelProps['label'] 22 | value: string 23 | item?: T 24 | callback: (result: HandlerProps, item?: T) => void 25 | } 26 | export default function Item({ label, value, item, callback }: ItemProps) { 27 | const [indeterminate, setIndeterminate] = useState(false) 28 | const [checked, setChecked] = useState(false) 29 | 30 | return ( 31 | { 39 | const result = handler({ checked, indeterminate }) 40 | 41 | setIndeterminate(result.indeterminate) 42 | setChecked(result.checked) 43 | callback(result, item) 44 | }} 45 | /> 46 | } 47 | /> 48 | ) 49 | } 50 | 51 | export { RegularItem, type RegularHandlerProps } 52 | -------------------------------------------------------------------------------- /Client/src/components/indeterminate/regular-item.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import { Checkbox, FormControlLabel, FormControlLabelProps } from '@mui/material' 4 | 5 | export type RegularHandlerProps = { 6 | checked: boolean 7 | } 8 | 9 | type ItemProps = { 10 | label: FormControlLabelProps['label'] 11 | value: string 12 | defaultChecked?: boolean 13 | disabled?: boolean 14 | softDisabled?: boolean 15 | } 16 | 17 | type WithItemProps = ItemProps & { 18 | item: T 19 | callback: (result: RegularHandlerProps, item: T) => void 20 | } 21 | 22 | type WithoutItemProps = ItemProps & { 23 | callback: (result: RegularHandlerProps) => void 24 | } 25 | 26 | export default function RegularItem(props: WithItemProps | WithoutItemProps) { 27 | if ('item' in props) { 28 | return 29 | } 30 | 31 | return 32 | } 33 | 34 | function RegularWithItem({ 35 | label, 36 | value, 37 | item, 38 | callback, 39 | defaultChecked = false, 40 | disabled, 41 | softDisabled = false 42 | }: WithItemProps) { 43 | const [checked, setChecked] = useState(defaultChecked) 44 | 45 | return ( 46 | { 55 | setChecked(checked => { 56 | const status = !checked 57 | 58 | callback({ checked: status }, item) 59 | return status 60 | }) 61 | }} 62 | /> 63 | } 64 | /> 65 | ) 66 | } 67 | 68 | function RegularWithoutItem({ 69 | label, 70 | value, 71 | callback, 72 | defaultChecked = false, 73 | disabled, 74 | softDisabled = false 75 | }: WithoutItemProps) { 76 | const [checked, setChecked] = useState(defaultChecked) 77 | 78 | return ( 79 | { 88 | setChecked(checked => { 89 | const status = !checked 90 | 91 | callback({ checked: status }) 92 | return status 93 | }) 94 | }} 95 | /> 96 | } 97 | /> 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /Client/src/components/modal/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | 3 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 4 | 5 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 6 | import React, { useEffect, useState } from 'react' 7 | 8 | import { Button, Card, Modal as MUIModal, Typography } from '@mui/material' 9 | 10 | import { useKey } from 'react-use' 11 | 12 | import { useModalContext } from '@/context/modalContext' 13 | 14 | import styles from './modal.module.scss' 15 | 16 | export default function ModalComponent() { 17 | const { modal, setModal } = useModalContext() 18 | 19 | const [query, setQuery] = useState('') 20 | 21 | const isLetter = (e: KeyboardEvent) => /^Key([A-Z])$/.test(e.code) 22 | const isSpace = (e: KeyboardEvent) => e.code === 'Space' 23 | const isBackspace = (e: KeyboardEvent) => e.code === 'Backspace' 24 | 25 | useEffect(() => setQuery(''), [modal.filter]) 26 | 27 | useKey( 28 | e => modal.filter && (isLetter(e) || isSpace(e) || isBackspace(e)), 29 | e => { 30 | if (isBackspace(e)) { 31 | setQuery(prevQuery => prevQuery.slice(0, -1)) 32 | } else { 33 | setQuery(prevQuery => prevQuery + e.key) 34 | } 35 | } 36 | ) 37 | 38 | if (!modal.visible) return null 39 | 40 | return ( 41 | 42 | {modal.data} 43 | 44 | ) 45 | } 46 | 47 | type ModalChildProps = { 48 | title: string 49 | children: React.ReactNode 50 | query: string 51 | filter: boolean 52 | onClose: () => void 53 | } 54 | 55 | function ModalChild({ title, filter, children, query, onClose }: ModalChildProps) { 56 | const lowerQuery = query.toLowerCase() 57 | 58 | const handleFilter = () => { 59 | return React.Children.toArray(children) 60 | .flatMap(child => { 61 | if (!React.isValidElement(child)) return [] 62 | 63 | return typeof child.props.children === 'string' ? [child as React.ReactElement] : [] 64 | }) 65 | .filter(item => item.props.children.toLowerCase().includes(lowerQuery)) 66 | .sort((a, b) => { 67 | const valA: string = a.props.children.toLowerCase() 68 | const valB: string = b.props.children.toLowerCase() 69 | 70 | if (query.length > 0) { 71 | if (valA.startsWith(lowerQuery) && valB.startsWith(lowerQuery)) return 0 72 | else if (valA.startsWith(lowerQuery)) return -1 73 | else if (valB.startsWith(lowerQuery)) return 1 74 | } 75 | 76 | return valA.localeCompare(valB) 77 | }) 78 | } 79 | 80 | return ( 81 | 82 | 83 |
84 | 85 | {title} 86 | 87 | {query.length > 0 && {query}} 88 |
89 | 90 |
91 |
{filter ? handleFilter() : children}
92 |
93 | 96 |
97 |
98 |
99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /Client/src/components/modal/modal.module.scss: -------------------------------------------------------------------------------- 1 | #modal { 2 | position: absolute; 3 | left: 50%; 4 | translate: -50%; 5 | outline: none; // prevent selection outline 6 | 7 | #header { 8 | padding: 6px 10px; 9 | background-color: lightgrey; 10 | text-align: center; 11 | 12 | #label { 13 | margin-bottom: 5px; 14 | } 15 | 16 | #query { 17 | display: inline-block; 18 | padding: 0 3px; 19 | background-color: orange; 20 | } 21 | } 22 | 23 | #body { 24 | padding: 10px; 25 | 26 | #content { 27 | button { 28 | margin-bottom: 6px; 29 | // padding: 5px 15px; 30 | 31 | width: 100%; 32 | display: block; 33 | } 34 | 35 | .wide input[type='text'] { 36 | width: 300px; 37 | } 38 | 39 | .wider input[type='text'] { 40 | width: 650px; 41 | } 42 | } 43 | 44 | #actions { 45 | margin-top: 20px; //TODO spacing above close button 46 | text-align: center; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Client/src/components/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@tanstack/react-router' 2 | 3 | import styles from './navbar.module.scss' 4 | 5 | export default function NavBar() { 6 | return ( 7 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /Client/src/components/navbar/navbar.module.scss: -------------------------------------------------------------------------------- 1 | #navbar { 2 | margin-bottom: 1em; 3 | display: flex; 4 | justify-content: space-between; 5 | background-color: green; 6 | 7 | ul { 8 | margin: 0; 9 | padding: 0; 10 | display: flex; 11 | 12 | li { 13 | list-style-type: none; 14 | position: relative; 15 | 16 | &:hover { 17 | background-color: forestgreen; 18 | 19 | .sub { 20 | display: block; 21 | } 22 | } 23 | 24 | a { 25 | display: inline-block; 26 | color: white; 27 | text-decoration: none; 28 | padding: 12px; 29 | } 30 | 31 | .sub { 32 | display: none; 33 | position: absolute; 34 | background-color: green; 35 | box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); 36 | z-index: 1; 37 | 38 | li { 39 | padding: 0 8px; 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Client/src/components/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, Typography } from '@mui/material' 2 | 3 | import { Link } from '@tanstack/react-router' 4 | 5 | export default function NotFound() { 6 | return ( 7 | 8 | Oops! 9 | Seems like this page is not created yet 10 | 11 | 12 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /Client/src/components/retired.tsx: -------------------------------------------------------------------------------- 1 | import Ribbon, { RibbonContainer } from './ribbon' 2 | 3 | type RetiredWrapperProps = { 4 | retired: boolean 5 | children: React.ReactNode 6 | } 7 | 8 | export default function RetiredWrapper({ retired, children }: RetiredWrapperProps) { 9 | return ( 10 | 11 | {children} 12 | {retired && } 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /Client/src/components/ribbon/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './ribbon.module.scss' 2 | 3 | type RibbonProps = { 4 | isFirst?: boolean 5 | isLast?: boolean 6 | align?: string 7 | label?: string | number 8 | } 9 | export default function Ribbon({ isFirst = false, isLast = false, align, label }: RibbonProps) { 10 | const className = `${styles.ribbon} ${align === 'left' ? `${styles.left} ${styles.purple}` : ''}` 11 | 12 | if (isFirst) { 13 | return First 14 | } else if (isLast) { 15 | return Latest 16 | } else if (label !== undefined) { 17 | return {label} 18 | } 19 | 20 | return null 21 | } 22 | 23 | type ContainerProps = { 24 | children: React.ReactNode 25 | component?: React.ElementType 26 | className?: string 27 | style?: React.CSSProperties 28 | } 29 | export function RibbonContainer({ 30 | children, 31 | component: Component = 'div', 32 | className = '', 33 | style = {} 34 | }: ContainerProps) { 35 | return ( 36 | 37 | {children} 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /Client/src/components/ribbon/ribbon.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | overflow: hidden; 4 | 5 | .ribbon { 6 | background: red; 7 | color: white; 8 | padding: 7px 0; 9 | position: absolute; 10 | top: 0; 11 | right: 0; 12 | 13 | translate: 30%; 14 | rotate: 45deg; 15 | transform-origin: top left; 16 | 17 | font-weight: bold; 18 | font-size: 0.9em; 19 | min-width: 20px; 20 | text-align: center; 21 | 22 | &:before, 23 | &:after { 24 | content: ''; 25 | position: absolute; 26 | top: 0; 27 | margin: 0 -1px; // tweak 28 | width: 300%; 29 | height: 100%; 30 | 31 | background-color: inherit; 32 | } 33 | 34 | &:before { 35 | right: 100%; 36 | } 37 | 38 | &:after { 39 | left: 100%; 40 | } 41 | 42 | &.left { 43 | left: -20px; 44 | right: auto; 45 | 46 | rotate: -45deg; 47 | transform-origin: top right; 48 | } 49 | 50 | &.purple { 51 | background-color: purple; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Client/src/components/search/filter.module.css: -------------------------------------------------------------------------------- 1 | .global { 2 | text-decoration: underline; 3 | } 4 | -------------------------------------------------------------------------------- /Client/src/components/search/filter.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormControlLabel, MenuItem, Radio, RadioGroup, Select, SelectChangeEvent } from '@mui/material' 2 | 3 | import capitalize from 'capitalize' 4 | 5 | import { RegularHandlerProps, RegularItem } from '@/components/indeterminate' 6 | 7 | import { DefaultObj } from './sort' 8 | 9 | import { useSearchParam } from '@/hooks/search' 10 | import { General } from '@/interface' 11 | 12 | import styles from './filter.module.css' 13 | 14 | type FilterRadioProps = { 15 | data: string[] 16 | label: string & keyof T 17 | callback: (item: string) => void 18 | globalCallback?: () => void 19 | nullCallback?: () => void 20 | defaultObj: T 21 | } 22 | export function FilterRadio({ 23 | data, 24 | label, 25 | callback, 26 | globalCallback, 27 | nullCallback, 28 | defaultObj 29 | }: FilterRadioProps) { 30 | const { currentValue, defaultValue } = useSearchParam(defaultObj, label) 31 | 32 | return ( 33 | <> 34 |

{capitalize(label, true)}

35 | 36 | 37 | 38 | {globalCallback !== undefined && ( 39 | ALL} 42 | onChange={globalCallback} 43 | control={} 44 | /> 45 | )} 46 | {nullCallback !== undefined && ( 47 | NULL} 50 | onChange={nullCallback} 51 | control={} 52 | /> 53 | )} 54 | {data.map(item => ( 55 | callback(item)} 59 | label={item} 60 | control={} 61 | /> 62 | ))} 63 | 64 | 65 | 66 | ) 67 | } 68 | 69 | type FilterCheckboxProps = { 70 | data: TData[] 71 | label: string & keyof TObj 72 | callback: (ref: RegularHandlerProps, item: TData) => void 73 | nullCallback?: (e: RegularHandlerProps) => void 74 | defaultNull?: boolean 75 | defaultObj: TObj 76 | } 77 | export function FilterCheckbox({ 78 | data, 79 | label, 80 | callback, 81 | nullCallback, 82 | defaultNull = false, 83 | defaultObj 84 | }: FilterCheckboxProps) { 85 | const { currentValue, defaultValue } = useSearchParam(defaultObj, label) 86 | const currentArrayValue = currentValue.split(',') 87 | 88 | return ( 89 | <> 90 |

{capitalize(label, true)}

91 | 92 | {nullCallback !== undefined && ( 93 | NULL} 95 | value='NULL' 96 | callback={nullCallback} 97 | defaultChecked={defaultNull} 98 | softDisabled={currentValue !== defaultValue} 99 | /> 100 | )} 101 | {data.map(item => { 102 | const key = typeof item === 'string' ? item : item.id 103 | const value = typeof item === 'string' ? item : item.name 104 | return ( 105 | 114 | ) 115 | })} 116 | 117 | 118 | ) 119 | } 120 | 121 | type FilterDropdownProps = { 122 | data?: TData[] 123 | label: string & keyof TObj 124 | callback: (e: SelectChangeEvent) => void 125 | defaultObj: TObj 126 | } 127 | export function FilterDropdown({ 128 | data, 129 | label, 130 | callback, 131 | defaultObj 132 | }: FilterDropdownProps) { 133 | const { currentValue } = useSearchParam(defaultObj, label) 134 | 135 | return ( 136 |
137 |

{capitalize(label, true)}

138 | 139 | 140 | 154 | 155 |
156 | ) 157 | } 158 | 159 | export function isDefault(value: string, defaultValue: T[keyof T]) { 160 | return value === defaultValue 161 | } 162 | -------------------------------------------------------------------------------- /Client/src/components/search/sort.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlLabel, Radio } from '@mui/material' 2 | 3 | import dayjs from 'dayjs' 4 | 5 | import { AllowString, StarSearch, VideoSearch } from '@/interface' 6 | 7 | const reverseChar = '-' 8 | function createReverse(sort: T) { 9 | return `${reverseChar}${sort}` as const 10 | } 11 | 12 | type SortObjProps = { 13 | labels: [string, string] 14 | id: WithoutReverse 15 | callback: (reversed: boolean) => void 16 | reversed?: boolean 17 | } 18 | function SortObj({ id, labels, callback, reversed = false }: SortObjProps) { 19 | return ( 20 | <> 21 | } 25 | onChange={() => callback(reversed)} 26 | /> 27 | } 31 | onChange={() => callback(!reversed)} 32 | /> 33 | 34 | ) 35 | } 36 | 37 | export function getSortString(sort: string, reverseSort = isReverseSort(sort)) { 38 | return reverseSort ? createReverse(sort) : sort 39 | } 40 | 41 | function isReverseSort(sort: string) { 42 | return sort.startsWith(reverseChar) 43 | } 44 | 45 | function getBaseSort(sort: T) { 46 | return sort.replace(new RegExp(`^${reverseChar}`), '') as WithoutReverse 47 | } 48 | 49 | export function SortObjVideo(params: SortObjProps) { 50 | return 51 | } 52 | 53 | export function SortObjStar(params: SortObjProps) { 54 | return 55 | } 56 | 57 | type SortMethod = (a: T, b: T) => number 58 | export type SortMethodVideo = SortMethod 59 | export type SortMethodStar = SortMethod 60 | 61 | type WithReverse = T | ReturnType> 62 | type WithoutReverse = T extends ReturnType> ? U : T 63 | 64 | type DefaultVideoObj = { 65 | category: string 66 | nullCategory: '0' | '1' 67 | attribute: string 68 | location: string 69 | website: string 70 | site: string 71 | query: string 72 | sort: WithReverse<'alphabetical' | 'added' | 'date' | 'age' | 'plays' | 'title-length'> 73 | } 74 | 75 | type DefaultStarObj = { 76 | breast: AllowString<'NULL'> 77 | haircolor: string 78 | ethnicity: string 79 | website: string 80 | query: string 81 | sort: WithReverse<'alphabetical' | 'added' | 'age' | 'videos' | 'score' | 'activity'> 82 | } 83 | 84 | export const defaultVideoObj: DefaultVideoObj = { 85 | category: '', 86 | nullCategory: '0', 87 | attribute: '', 88 | location: '', 89 | website: 'ALL', 90 | site: 'ALL', 91 | query: '', 92 | sort: 'date' 93 | } 94 | 95 | export const defaultStarObj: DefaultStarObj = { 96 | breast: '', 97 | haircolor: '', 98 | ethnicity: '', 99 | website: 'ALL', 100 | query: '', 101 | sort: 'alphabetical' 102 | } 103 | 104 | export type DefaultObj = DefaultVideoObj | DefaultStarObj 105 | 106 | export function getVideoSort(type: DefaultVideoObj['sort']): SortMethodVideo { 107 | let sortMethod: SortMethodVideo 108 | 109 | switch (getBaseSort(type)) { 110 | case 'added': 111 | sortMethod = (a, b) => a.id - b.id 112 | break 113 | case 'date': 114 | sortMethod = (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() 115 | break 116 | case 'age': 117 | sortMethod = (a, b) => a.ageInVideo - b.ageInVideo 118 | break 119 | case 'plays': 120 | sortMethod = (a, b) => a.plays - b.plays 121 | break 122 | case 'title-length': 123 | sortMethod = (a, b) => a.name.length - b.name.length 124 | break 125 | default: 126 | sortMethod = (a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'case' }) 127 | } 128 | 129 | return isReverseSort(type) ? (a, b) => sortMethod(b, a) : sortMethod 130 | } 131 | 132 | export function getStarSort(type: DefaultStarObj['sort']): SortMethodStar { 133 | let sortMethod: SortMethodStar 134 | 135 | switch (getBaseSort(type)) { 136 | case 'added': 137 | sortMethod = (a, b) => a.id - b.id 138 | break 139 | case 'age': 140 | sortMethod = (a, b) => a.age - b.age 141 | break 142 | case 'videos': 143 | sortMethod = (a, b) => a.videoCount - b.videoCount 144 | break 145 | case 'score': 146 | sortMethod = (a, b) => a.score - b.score 147 | break 148 | case 'activity': 149 | sortMethod = (a, b) => dayjs(a.lastDate ?? 0).diff(dayjs(b.lastDate ?? 0)) 150 | break 151 | default: 152 | sortMethod = (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase(), 'en') 153 | } 154 | 155 | return isReverseSort(type) ? (a, b) => sortMethod(b, a) : sortMethod 156 | } 157 | -------------------------------------------------------------------------------- /Client/src/components/settings.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from 'react-use' 2 | 3 | export const dropdownOptions = { 4 | set_thumbnail_action: ['reload', 'close'] as const, 5 | delete_video_action: ['redirect', 'close'] as const, 6 | delete_star_action: ['redirect', 'close'] as const 7 | } 8 | 9 | type OptionsType = { 10 | [K in keyof typeof dropdownOptions]: (typeof dropdownOptions)[K][number] 11 | } 12 | 13 | export type Settings = OptionsType & { 14 | bookmark_spacing: number 15 | max_retired_years: number 16 | disable_search_filter: boolean 17 | } 18 | 19 | export const defaultSettings: Settings = { 20 | bookmark_spacing: 0, 21 | max_retired_years: 1, 22 | disable_search_filter: false, 23 | set_thumbnail_action: 'reload', 24 | delete_video_action: 'redirect', 25 | delete_star_action: 'redirect' 26 | } 27 | 28 | export function useSettings() { 29 | const [storedSettings, setStoredSettings] = useLocalStorage>('settings', defaultSettings) 30 | 31 | const localSettings = { ...defaultSettings, ...storedSettings } 32 | 33 | const setLocalSettings = (newSettings: Partial) => { 34 | setStoredSettings(prev => { 35 | const updatedSettings = { ...prev, ...newSettings } 36 | 37 | Object.keys(updatedSettings).forEach(key => { 38 | const settingsKey = key as keyof Settings 39 | 40 | if (updatedSettings[settingsKey] === defaultSettings[settingsKey]) { 41 | delete updatedSettings[settingsKey] 42 | } 43 | }) 44 | 45 | return updatedSettings 46 | }) 47 | } 48 | 49 | return { localSettings, setLocalSettings } 50 | } 51 | -------------------------------------------------------------------------------- /Client/src/components/spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import styles from './spinner.module.css' 4 | 5 | type SpinnerProps = { 6 | delay?: number 7 | size?: 'large' | 'medium' | 'small' 8 | } 9 | 10 | export default function Spinner({ delay = 300, size = 'large' }: SpinnerProps) { 11 | const [showSpinner, setShowSpinner] = useState(false) 12 | 13 | useEffect(() => { 14 | const timer = setTimeout(() => setShowSpinner(true), delay) 15 | 16 | return () => clearTimeout(timer) 17 | }, [delay]) 18 | 19 | if (!showSpinner) return false 20 | 21 | return
22 | } 23 | -------------------------------------------------------------------------------- /Client/src/components/spinner/spinner.module.css: -------------------------------------------------------------------------------- 1 | #loader { 2 | border: 16px solid #f3f3f3; 3 | width: 120px; 4 | height: 120px; 5 | 6 | border-top-color: #3498db; 7 | border-bottom-color: #3498db; 8 | border-radius: 50%; 9 | animation: spin 2s linear infinite; 10 | margin: 0 auto; 11 | } 12 | 13 | .small { 14 | width: 30px; 15 | height: 30px; 16 | border-width: 4px; 17 | } 18 | 19 | .medium { 20 | width: 60px; 21 | height: 60px; 22 | border-width: 8px; 23 | } 24 | 25 | @keyframes spin { 26 | 0% { 27 | rotate: 0; 28 | } 29 | 30 | 100% { 31 | rotate: 360deg; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Client/src/components/text-field-form.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import { TextField, TextFieldProps } from '@mui/material' 4 | 5 | import { useModalContext } from '@/context/modalContext' 6 | 7 | type TextFieldFormProps = Omit & { 8 | callback: (value: string) => void 9 | resetModal?: boolean 10 | resetValue?: boolean 11 | defaultValue?: string 12 | } 13 | 14 | export default function TextFieldForm({ 15 | callback, 16 | defaultValue = '', 17 | resetModal = false, 18 | resetValue = false, 19 | ...props 20 | }: TextFieldFormProps) { 21 | const [value, setValue] = useState(defaultValue) 22 | 23 | const { setModal } = useModalContext() 24 | 25 | const handleSubmit = (e: React.FormEvent) => { 26 | e.preventDefault() 27 | 28 | callback(value) 29 | if (resetModal) setModal() 30 | if (resetValue) setValue('') 31 | } 32 | 33 | return ( 34 | setValue(e.target.value)} 39 | onSubmit={handleSubmit} 40 | /> 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /Client/src/components/video/header.module.scss: -------------------------------------------------------------------------------- 1 | #header { 2 | margin-bottom: 15px; 3 | 4 | button { 5 | &:not([disabled]) { 6 | color: black; 7 | } 8 | 9 | &.location, 10 | &.attribute { 11 | &:hover { 12 | text-decoration: line-through; 13 | } 14 | } 15 | 16 | margin-left: 15px; 17 | 18 | svg { 19 | margin-right: 5px !important; 20 | } 21 | } 22 | } 23 | 24 | .select-slug { 25 | max-height: 275px; 26 | max-width: 275px; 27 | object-fit: inherit; 28 | } 29 | 30 | #site { 31 | color: green; 32 | font-weight: 700; 33 | 34 | #wsite { 35 | color: red; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Client/src/components/video/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Header } from './header' 2 | export { default as Player } from './player' 3 | export { default as Timeline } from './timeline' 4 | -------------------------------------------------------------------------------- /Client/src/components/video/player.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@mui/material' 2 | 3 | import { useNavigate } from '@tanstack/react-router' 4 | import { ContextMenuTrigger, ContextMenu, ContextMenuItem } from 'rctx-contextmenu' 5 | 6 | import Player, { MediaPlayerInstance } from '@/components/vidstack' 7 | 8 | import { IconWithText } from '../icon' 9 | import { useSettings } from '../settings' 10 | import Spinner from '../spinner' 11 | import TextFieldForm from '../text-field-form' 12 | 13 | import { serverConfig } from '@/config' 14 | import { useModalContext } from '@/context/modalContext' 15 | import { General } from '@/interface' 16 | import { categoryService, videoService } from '@/service' 17 | 18 | type VideoPlayerProps = { 19 | videoId: number 20 | playerRef: React.RefObject 21 | onReady: () => void 22 | } 23 | 24 | export default function VideoPlayer({ videoId, playerRef, onReady }: VideoPlayerProps) { 25 | const navigate = useNavigate() 26 | 27 | const { data: star = null } = videoService.useStar(videoId) 28 | const { data: video } = videoService.useVideo(videoId) 29 | const { data: categories } = categoryService.useAll() 30 | const { data: bookmarks } = videoService.useBookmarks(videoId) 31 | const { mutate: mutateAddBookmark } = videoService.useAddBookmark(videoId) 32 | const { mutate: mutateClearBookmarks } = videoService.useClearBookmarks(videoId) 33 | 34 | const { setModal } = useModalContext() 35 | const { localSettings } = useSettings() 36 | 37 | if (video === undefined || bookmarks === undefined) return 38 | 39 | const deleteVideo = () => { 40 | videoService.delete(videoId).then(() => { 41 | if (localSettings.delete_video_action === 'close') { 42 | window.close() 43 | } else { 44 | navigate({ to: '/', replace: true }) 45 | } 46 | }) 47 | } 48 | 49 | const addBookmark = (category: General) => { 50 | if (playerRef.current === null) return null 51 | 52 | const time = Math.round(playerRef.current.currentTime) 53 | if (time) { 54 | mutateAddBookmark({ category, start: time }) 55 | } 56 | } 57 | 58 | const resetPlays = () => { 59 | videoService.removePlays(videoId).then(() => { 60 | location.reload() 61 | }) 62 | } 63 | 64 | const renameVideo = (path: string) => { 65 | videoService.rename(videoId, path).then(() => { 66 | location.reload() 67 | }) 68 | } 69 | 70 | if (video === undefined || bookmarks === undefined) return 71 | 72 | return ( 73 | <> 74 | 75 | 88 | 89 | 90 | 91 | { 96 | setModal( 97 | 'Add Bookmark', 98 | categories?.map(category => ( 99 | 110 | )), 111 | true 112 | ) 113 | }} 114 | /> 115 | 116 |
117 | 118 | { 123 | setModal( 124 | 'Rename Video', 125 | ) => { 132 | if (e.key === 'Enter') { 133 | setModal() 134 | 135 | renameVideo((e.target as HTMLInputElement).value) 136 | } 137 | }} 138 | className='wider' 139 | /> 140 | ) 141 | }} 142 | /> 143 | 144 |
145 | 146 | 153 | 154 | resetPlays()} /> 155 | 156 | 0} 161 | onClick={deleteVideo} 162 | /> 163 |
164 | 165 | ) 166 | } 167 | -------------------------------------------------------------------------------- /Client/src/components/video/timeline.module.css: -------------------------------------------------------------------------------- 1 | #timeline { 2 | position: relative; 3 | margin-left: 20px; 4 | margin-right: 25px; 5 | 6 | .bookmark { 7 | position: absolute; 8 | white-space: nowrap; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Client/src/components/video/timeline.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useRef, useState } from 'react' 2 | 3 | import { Button, Grid } from '@mui/material' 4 | 5 | import { useMediaRemote } from '@vidstack/react' 6 | import { ContextMenuTrigger, ContextMenu, ContextMenuItem } from 'rctx-contextmenu' 7 | import { useWindowSize } from 'usehooks-ts' 8 | 9 | import { MediaPlayerInstance } from '@/components/vidstack' 10 | 11 | import { IconWithText } from '../icon' 12 | import Spinner from '../spinner' 13 | 14 | import { useModalContext } from '@/context/modalContext' 15 | import useCollision from '@/hooks/useCollision' 16 | import { Bookmark as BookmarkType } from '@/interface' 17 | import { bookmarkService, categoryService, videoService } from '@/service' 18 | 19 | import styles from './timeline.module.css' 20 | 21 | const spacing = { top: 3, bookmarks: 36 } 22 | 23 | type TimelineProps = { 24 | videoId: number 25 | playerRef: React.RefObject 26 | playerReady: boolean 27 | } 28 | export default function Timeline({ videoId, playerRef, playerReady }: TimelineProps) { 29 | const windowSize = useWindowSize() 30 | const bookmarksRef = useRef([]) 31 | const [bookmarkLevels, setBookmarkLevels] = useState([]) 32 | 33 | const { data: video } = videoService.useVideo(videoId) 34 | const { data: bookmarks, optimisticAdd: optimisticBookmarks } = videoService.useBookmarks(videoId) 35 | 36 | const remote = useMediaRemote(playerRef) 37 | 38 | const { collisionCheck } = useCollision() 39 | 40 | useEffect(() => { 41 | const levels = Array(bookmarks?.length ?? 0 + optimisticBookmarks.length).fill(0) 42 | let maxLevel = 0 43 | 44 | for (let i = 0; i < bookmarksRef.current.length; i++) { 45 | let level = 1 46 | for (let j = 0; j < i; j++) { 47 | if (levels[j] === level && collisionCheck(bookmarksRef.current[j], bookmarksRef.current[i])) { 48 | level++ 49 | j = -1 50 | } 51 | } 52 | 53 | levels[i] = level 54 | if (level > maxLevel) maxLevel = level 55 | } 56 | 57 | setBookmarkLevels(levels) 58 | 59 | const videoElement = remote.getPlayer()?.el ?? null 60 | if (videoElement !== null) { 61 | const videoTop = videoElement.getBoundingClientRect().top 62 | videoElement.style.maxHeight = `calc(100vh - (${spacing.bookmarks}px * ${maxLevel}) - ${videoTop}px - ${spacing.top}px)` 63 | } 64 | }, [bookmarks, collisionCheck, remote, windowSize.width, playerReady, optimisticBookmarks.length]) 65 | 66 | if (video === undefined) return 67 | 68 | return ( 69 | 70 | {bookmarks?.map((bookmark, idx) => ( 71 | (bookmarksRef.current[idx] = ref)} 76 | playerRef={playerRef} 77 | style={{ 78 | left: `${(bookmark.start / video.duration) * 100}%`, 79 | top: `${(bookmarkLevels[idx] - 1) * spacing.bookmarks}px` 80 | }} 81 | /> 82 | ))} 83 | 84 | {optimisticBookmarks.map((bookmark, idx) => { 85 | const startIdx = bookmarks?.length ?? 0 86 | 87 | return ( 88 | (bookmarksRef.current[startIdx + idx] = ref)} 92 | style={{ 93 | left: `${(bookmark.start / video.duration) * 100}%`, 94 | top: `${(bookmarkLevels[startIdx + idx] - 1) * spacing.bookmarks}px`, 95 | opacity: 0.5 96 | }} 97 | /> 98 | ) 99 | })} 100 | 101 | ) 102 | } 103 | 104 | type BookmarkProps = { 105 | videoId: number 106 | bookmark: BookmarkType 107 | style: React.CSSProperties 108 | bookmarkRef: (btn: HTMLButtonElement) => void 109 | playerRef: React.RefObject 110 | } 111 | 112 | function Bookmark({ videoId, bookmark, style, bookmarkRef, playerRef }: BookmarkProps) { 113 | const { data: categories } = categoryService.useAll() 114 | const { mutate: mutateSetCategory } = bookmarkService.useSetCategory(videoId, bookmark.id) 115 | const { mutate: mutateSetTime } = bookmarkService.useSetTime(videoId, bookmark.id) 116 | const { mutate: mutateDelete } = bookmarkService.useDeleteBookmark(videoId, bookmark.id) 117 | 118 | const remote = useMediaRemote(playerRef) 119 | 120 | const { setModal } = useModalContext() 121 | 122 | const setTime = () => { 123 | const player = remote.getPlayer() 124 | 125 | if (player !== null) { 126 | const time = Math.round(player.currentTime) 127 | 128 | mutateSetTime({ time }) 129 | } 130 | } 131 | 132 | const playVideo = (time: number) => { 133 | remote.seek(time) 134 | remote.play() 135 | } 136 | 137 | const togglePause = () => { 138 | remote.togglePaused() 139 | } 140 | 141 | return ( 142 | 143 | 144 | 156 | 157 | 158 | 159 | { 164 | setModal( 165 | 'Change Category', 166 | categories 167 | ?.filter(category => category.id !== bookmark.category.id) 168 | .map(category => ( 169 | 180 | )), 181 | true 182 | ) 183 | }} 184 | /> 185 | 186 | 187 | 188 |
189 | 190 | 191 |
192 |
193 | ) 194 | } 195 | 196 | type OptimisticBookmarkProps = { 197 | bookmark: Omit 198 | style: React.CSSProperties 199 | bookmarkRef: (btn: HTMLButtonElement) => void 200 | } 201 | function OptimisticBookmark({ bookmark, style, bookmarkRef }: OptimisticBookmarkProps) { 202 | return ( 203 | 206 | ) 207 | } 208 | -------------------------------------------------------------------------------- /Client/src/components/vidstack/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react' 2 | 3 | import { 4 | HLSSrc, 5 | MediaPlayer, 6 | MediaPlayerInstance, 7 | MediaProvider, 8 | MediaProviderAdapter, 9 | Poster, 10 | Track, 11 | VTTContent, 12 | VideoSrc, 13 | isHLSProvider, 14 | useMediaRemote, 15 | useMediaState 16 | } from '@vidstack/react' 17 | import { DefaultVideoLayout, defaultLayoutIcons } from '@vidstack/react/player/layouts/default' 18 | import Hls, { ErrorData } from 'hls.js' 19 | import { toast } from 'react-toastify' 20 | 21 | import { CustomStorage } from './storage' 22 | 23 | import { useModalContext } from '@/context/modalContext' 24 | import { Bookmark, Video } from '@/interface' 25 | import { videoService } from '@/service' 26 | 27 | import './vidstack.css' 28 | 29 | type PlayerProps = { 30 | title: string 31 | src: { video: string; hls: string } 32 | poster?: string 33 | thumbnails?: string 34 | video: Video 35 | playerRef: React.RefObject 36 | bookmarks: Bookmark[] 37 | onReady: () => void 38 | } 39 | 40 | export default function Player({ src, poster, thumbnails, title, video, playerRef, bookmarks, onReady }: PlayerProps) { 41 | const [videoSrc, setVideoSrc] = useState({ src: src.hls, type: 'application/x-mpegurl' }) 42 | 43 | const { modal } = useModalContext() 44 | 45 | const remote = useMediaRemote(playerRef) 46 | const hlsRef = useRef() 47 | 48 | const currentTime = useMediaState('currentTime', playerRef) 49 | 50 | const lastPlayAddedRef = useRef(null) 51 | 52 | const memoizedChapters = useMemo(() => { 53 | return { 54 | cues: bookmarks.map((bookmark, idx, arr) => ({ 55 | startTime: bookmark.start, 56 | endTime: arr.at(idx + 1)?.start ?? video.duration, 57 | text: bookmark.category.name 58 | })) 59 | } 60 | }, [bookmarks, video.duration]) 61 | 62 | const onProviderChange = (provider: MediaProviderAdapter | null) => { 63 | if (provider === null) return 64 | 65 | if (isHLSProvider(provider)) { 66 | provider.library = () => import('hls.js') 67 | provider.config = { maxBufferLength: Infinity } 68 | } 69 | 70 | onReady() 71 | } 72 | 73 | const onHlsError = (data: ErrorData) => { 74 | if (data.fatal) { 75 | hlsRef.current?.destroy() 76 | setVideoSrc({ src: src.video, type: 'video/mp4' }) 77 | } 78 | } 79 | 80 | const customStorage = useMemo(() => new CustomStorage(), []) 81 | 82 | useEffect(() => { 83 | customStorage.updateVideoId(video.id) 84 | }, [customStorage, video.id]) 85 | 86 | const onPlay = () => { 87 | // TODO lastPlayAddedRef.current breaks if the user refreshes the page 88 | if (customStorage.canAddPlay() && lastPlayAddedRef.current !== video.id) { 89 | // get the time the toast was added 90 | const toastId = toast('Adding play', { 91 | type: 'info', 92 | autoClose: false 93 | }) 94 | 95 | videoService 96 | .addPlay(video.id) 97 | .then(() => { 98 | toast.update(toastId, { 99 | autoClose: 2000, 100 | type: 'success', 101 | render: 'Play added' 102 | }) 103 | 104 | lastPlayAddedRef.current = video.id 105 | customStorage.resetCanAddPlay() 106 | }) 107 | .catch(() => { 108 | toast.update(toastId, { 109 | autoClose: 1000, 110 | type: 'error', 111 | render: 'Failed to add play' 112 | }) 113 | }) 114 | } 115 | } 116 | 117 | const seekStep = 1 118 | const seekToTime = (offset: number, player = remote.getPlayer()) => { 119 | if (player !== null) { 120 | remote.seek(player.currentTime + offset) 121 | } 122 | } 123 | 124 | return ( 125 | seekToTime(10 * Math.sign(e.deltaY) * -1)} 135 | onHlsInstance={hls => (hlsRef.current = hls)} 136 | onHlsError={onHlsError} 137 | keyDisabled={modal.visible} 138 | keyTarget='document' 139 | keyShortcuts={{ 140 | toggleMuted: 'm', 141 | volumeUp: 'ArrowUp', 142 | volumeDown: 'ArrowDown', 143 | togglePaused: 'Space', 144 | seekBackward: { 145 | keys: 'ArrowLeft', 146 | onKeyDown({ player }) { 147 | seekToTime(-seekStep, player) 148 | } 149 | }, 150 | seekForward: { 151 | keys: 'ArrowRight', 152 | onKeyDown({ player }) { 153 | seekToTime(seekStep, player) 154 | } 155 | } 156 | }} 157 | > 158 | 159 | {poster !== undefined && currentTime < 1 && } 160 | 161 | 162 | 163 | 164 | 171 | 172 | ) 173 | } 174 | 175 | export type { MediaPlayerInstance } 176 | -------------------------------------------------------------------------------- /Client/src/components/vidstack/storage.ts: -------------------------------------------------------------------------------- 1 | import { MediaStorage, SerializedVideoQuality } from '@vidstack/react' 2 | 3 | type SavedMediaData = { 4 | volume: number | null 5 | muted: boolean | null 6 | audioGain: number | null 7 | lang: string | null 8 | captions: boolean | null 9 | rate: number | null 10 | quality: SerializedVideoQuality | null 11 | } 12 | 13 | export class CustomStorage implements MediaStorage { 14 | private storageKey = 'vidstack' 15 | private bookmarkKey = 'bookmark' 16 | private videoKey = 'video' 17 | 18 | private _currentId: number | null = null 19 | private _videoId: number | null = null 20 | private _time: number | null = null 21 | private _canAddPlay = false 22 | 23 | updateVideoId(videoId: number) { 24 | this._currentId = videoId 25 | } 26 | 27 | private _data: SavedMediaData = { 28 | volume: null, 29 | muted: null, 30 | audioGain: null, 31 | lang: null, 32 | captions: null, 33 | rate: null, 34 | quality: null 35 | } 36 | 37 | async getVolume() { 38 | return Promise.resolve(this._data.volume) 39 | } 40 | 41 | async setVolume(volume: number) { 42 | this._data.volume = volume 43 | await this.save() 44 | } 45 | 46 | async getMuted() { 47 | return Promise.resolve(this._data.muted) 48 | } 49 | 50 | async setMuted(muted: boolean) { 51 | this._data.muted = muted 52 | await this.save() 53 | } 54 | 55 | async getTime() { 56 | return Promise.resolve(this._time) 57 | } 58 | 59 | async setTime(time: number) { 60 | this._time = time >= 0 ? time : null 61 | await this.saveTime() 62 | } 63 | 64 | async getLang() { 65 | return Promise.resolve(this._data.lang) 66 | } 67 | 68 | async setLang(lang: string | null) { 69 | this._data.lang = lang 70 | await this.save() 71 | } 72 | 73 | async getCaptions() { 74 | return Promise.resolve(this._data.captions) 75 | } 76 | 77 | async setCaptions(enabled: boolean) { 78 | this._data.captions = enabled 79 | await this.save() 80 | } 81 | 82 | async getPlaybackRate() { 83 | return Promise.resolve(this._data.rate) 84 | } 85 | 86 | async setPlaybackRate(rate: number | null) { 87 | this._data.rate = rate 88 | await this.save() 89 | } 90 | 91 | async getAudioGain() { 92 | return Promise.resolve(this._data.audioGain) 93 | } 94 | 95 | async setAudioGain(gain: number | null) { 96 | this._data.audioGain = gain 97 | await this.save() 98 | } 99 | 100 | async getVideoQuality() { 101 | return Promise.resolve(this._data.quality) 102 | } 103 | 104 | async setVideoQuality(quality: SerializedVideoQuality | null) { 105 | this._data.quality = quality 106 | await this.save() 107 | } 108 | 109 | onChange() { 110 | const savedData = localStorage.getItem(this.storageKey) 111 | const savedTime = Number(sessionStorage.getItem(this.bookmarkKey)) 112 | const videoId = Number(sessionStorage.getItem(this.videoKey)) 113 | 114 | this._data = { 115 | volume: null, 116 | muted: null, 117 | audioGain: null, 118 | lang: null, 119 | captions: null, 120 | rate: null, 121 | quality: null, 122 | ...(savedData ? (JSON.parse(savedData) as Record) : {}) 123 | } 124 | 125 | this._time = Number(savedTime) 126 | this._videoId = Number(videoId) 127 | } 128 | 129 | protected getVideoId() { 130 | return this._videoId 131 | } 132 | 133 | protected setVideoId(videoId: number | null) { 134 | this._videoId = videoId 135 | return this.saveVideoId() 136 | } 137 | 138 | onLoad() { 139 | if (this._currentId !== this.getVideoId()) { 140 | this._canAddPlay = true 141 | this.setVideoId(this._currentId) 142 | this.setTime(0) 143 | } 144 | } 145 | 146 | protected save() { 147 | const data = JSON.stringify(this._data) 148 | localStorage.setItem(this.storageKey, data) 149 | return Promise.resolve() 150 | } 151 | 152 | protected saveTime() { 153 | const data = Number(this._time).toString() 154 | sessionStorage.setItem(this.bookmarkKey, data) 155 | return Promise.resolve() 156 | } 157 | 158 | protected saveVideoId() { 159 | const data = Number(this._videoId).toString() 160 | sessionStorage.setItem(this.videoKey, data) 161 | return Promise.resolve() 162 | } 163 | 164 | protected removeVideoId() { 165 | sessionStorage.removeItem(this.videoKey) 166 | } 167 | 168 | public canAddPlay() { 169 | return this._canAddPlay 170 | } 171 | 172 | public resetCanAddPlay() { 173 | this._canAddPlay = false 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Client/src/components/vidstack/vidstack.css: -------------------------------------------------------------------------------- 1 | @import '@vidstack/react/player/styles/default/theme.css'; 2 | @import '@vidstack/react/player/styles/default/layouts/video.css'; 3 | 4 | .vds-slider { 5 | --media-slider-track-height: 6px; 6 | 7 | --media-slider-track-fill-bg: rgb(0, 200, 0); 8 | --media-slider-track-progress-bg: rgb(162, 0, 255); 9 | --media-slider-track-bg: rgba(255, 255, 255, 0.25); 10 | } 11 | 12 | .vds-time-group { 13 | --media-time-color: rgb(255, 255, 255); 14 | } 15 | 16 | /* Always show Timeline-DOT */ 17 | .vds-time-slider .vds-slider-thumb { 18 | opacity: 1; 19 | } 20 | -------------------------------------------------------------------------------- /Client/src/components/virtualized/virtuoso.module.css: -------------------------------------------------------------------------------- 1 | .grid-list { 2 | display: flex; 3 | flex-wrap: wrap; 4 | justify-content: center; 5 | } 6 | -------------------------------------------------------------------------------- /Client/src/components/virtualized/virtuoso.tsx: -------------------------------------------------------------------------------- 1 | import { VirtuosoGrid } from 'react-virtuoso' 2 | 3 | import styles from './virtuoso.module.css' 4 | 5 | type GridProps = { 6 | renderData: (id: number) => JSX.Element 7 | total: number 8 | itemHeight: number 9 | itemRows?: number 10 | listClassName?: string 11 | } 12 | export default function Grid({ renderData, total, itemHeight, itemRows = 1, listClassName = '' }: GridProps) { 13 | return ( 14 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /Client/src/config/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios' 2 | 3 | import serverConfig from './server' 4 | 5 | type Options = { 6 | serverKey: keyof typeof serverConfig 7 | } 8 | 9 | async function getResponse(promise: Promise>) { 10 | return promise.then(res => res.data) 11 | } 12 | 13 | export default function createApi(suffix: string, options?: Partial) { 14 | const baseURL = serverConfig[options?.serverKey ?? 'newApi'] + suffix 15 | 16 | const api = axios.create({ baseURL }) 17 | 18 | return { 19 | api: { 20 | get: (...args: Parameters<(typeof api)['get']>) => getResponse(api.get(...args)), 21 | post: (...args: Parameters<(typeof api)['post']>) => getResponse(api.post(...args)), 22 | put: (...args: Parameters<(typeof api)['put']>) => getResponse(api.put(...args)), 23 | delete: (...args: Parameters<(typeof api)['delete']>) => getResponse(api.delete(...args)), 24 | sleep: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 25 | }, 26 | legacyApi: api, 27 | baseURL 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Client/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createApi } from './api' 2 | export { default as serverConfig } from './server' 3 | -------------------------------------------------------------------------------- /Client/src/config/server.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | api: 'http://localhost:5454/api', 3 | newApi: 'http://transcoder.local:4001/api' 4 | } 5 | -------------------------------------------------------------------------------- /Client/src/context/modalContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from 'react' 2 | 3 | import ModalComponent from '@/components/modal' 4 | 5 | export type Modal = { 6 | visible: boolean 7 | title: string 8 | data: React.ReactNode 9 | filter: boolean 10 | } 11 | 12 | type ModalHandler = (title?: Modal['title'], data?: Modal['data'], filter?: Modal['filter']) => void 13 | 14 | type ActiveContextState = { 15 | modal: Modal 16 | setModal: ModalHandler 17 | } 18 | 19 | const ModalContext = createContext(null) 20 | 21 | export default function ModalContextProvider({ children }: { children: React.ReactNode }) { 22 | const [modal, setModal] = useState({ 23 | visible: false, 24 | title: '', 25 | data: null, 26 | filter: false 27 | }) 28 | 29 | const handleModal: ModalHandler = (title = '', data = null, filter = false) => { 30 | setModal(prevModal => ({ 31 | title, 32 | data, 33 | visible: !prevModal.visible, 34 | filter 35 | })) 36 | } 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | 43 | 44 | ) 45 | } 46 | 47 | export function useModalContext() { 48 | const context = useContext(ModalContext) 49 | if (context === null) { 50 | throw new Error('useModalContext must be used within ModalContextProvider') 51 | } 52 | 53 | return context 54 | } 55 | -------------------------------------------------------------------------------- /Client/src/hooks/search.ts: -------------------------------------------------------------------------------- 1 | import { useNavigate } from '@tanstack/react-router' 2 | 3 | import { DefaultObj } from '@/components/search/sort' 4 | 5 | import { AllowString } from '@/interface' 6 | 7 | type ParamValue = T[K] extends string ? AllowString : never 8 | 9 | function generateSearch(searchParams: URLSearchParams) { 10 | if (searchParams.size === 0) { 11 | return '' 12 | } else { 13 | return '?' + searchParams.toString() 14 | } 15 | } 16 | 17 | export function useDynamicSearchParam(defaultValue: T) { 18 | const navigate = useNavigate() 19 | 20 | const currentSearchParams = new URLSearchParams(location.search) 21 | 22 | const setParam = (param: K, value: ParamValue) => { 23 | if (value !== defaultValue[param]) { 24 | currentSearchParams.set(param, value) 25 | } else { 26 | currentSearchParams.delete(param) 27 | } 28 | } 29 | 30 | const update = () => { 31 | navigate({ 32 | to: location.pathname + generateSearch(currentSearchParams), 33 | replace: true, 34 | resetScroll: false 35 | }) 36 | } 37 | 38 | return { setParam, update } 39 | } 40 | 41 | export function useAllSearchParams>(defaultParams: T): T { 42 | const searchParams = new URLSearchParams(location.search) 43 | 44 | const result: Record = {} 45 | for (const key in defaultParams) { 46 | result[key] = searchParams.get(key) ?? defaultParams[key] 47 | } 48 | 49 | return result as T 50 | } 51 | 52 | export function useSearchParam(defaultParams: T, label: string & keyof T) { 53 | const params = useAllSearchParams(defaultParams) 54 | 55 | return { 56 | currentValue: params[label] as string & T[typeof label], 57 | defaultValue: defaultParams[label] as string & T[typeof label] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Client/src/hooks/useCollision.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | 3 | import { useSettings } from '@/components/settings' 4 | 5 | //TODO could probably use "useIntersection-hook" 6 | export default function useCollision() { 7 | const { localSettings } = useSettings() 8 | 9 | const collisionCheck = useCallback( 10 | (a: HTMLElement | null, b: HTMLElement | null) => { 11 | if (a === null || b === null) return false 12 | 13 | const spacing = localSettings.bookmark_spacing 14 | 15 | const aRect = a.getBoundingClientRect() 16 | const bRect = b.getBoundingClientRect() 17 | 18 | return aRect.x + aRect.width >= bRect.x - spacing && aRect.x - spacing <= bRect.x + bRect.width 19 | }, 20 | [localSettings.bookmark_spacing] 21 | ) 22 | 23 | return { collisionCheck } 24 | } 25 | -------------------------------------------------------------------------------- /Client/src/hooks/useFocus.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export default function useFocus(currentValue?: string) { 4 | const ref = useRef(null) 5 | 6 | useEffect(() => { 7 | const input = ref.current 8 | if (input !== null) { 9 | if (currentValue !== undefined) { 10 | input.value = currentValue 11 | } 12 | input.focus() 13 | } 14 | }, [currentValue]) 15 | 16 | return ref 17 | } 18 | -------------------------------------------------------------------------------- /Client/src/hooks/useOptimistic.ts: -------------------------------------------------------------------------------- 1 | import { DefaultError, MutationKey, MutationState, useMutationState } from '@tanstack/react-query' 2 | 3 | type OptimisticProps = { 4 | mutationKey: MutationKey 5 | } 6 | 7 | export default function useOptimistic({ mutationKey }: OptimisticProps): T[] { 8 | const originalResult = useMutationState>({ 9 | filters: { 10 | mutationKey, 11 | status: 'pending' 12 | } 13 | }) 14 | 15 | return originalResult.flatMap(result => (result.variables !== undefined ? [result.variables] : [])) 16 | } 17 | -------------------------------------------------------------------------------- /Client/src/hooks/useRetired.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | import { useSettings } from '@/components/settings' 4 | 5 | import { StarVideo } from '@/interface' 6 | 7 | export default function useRetired(videos: StarVideo[] | undefined) { 8 | const { localSettings } = useSettings() 9 | 10 | const currentDate = dayjs() 11 | const videoDates = videos?.map(video => dayjs(video.date)).sort((a, b) => b.diff(a)) 12 | 13 | const lastVideoDate = videoDates?.at(0) 14 | 15 | const yearDiff = Math.ceil(currentDate.diff(lastVideoDate, 'year', true)) 16 | const shouldBeRetired = yearDiff > localSettings.max_retired_years 17 | 18 | return { yearDiff, shouldBeRetired } 19 | } 20 | -------------------------------------------------------------------------------- /Client/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Star Image */ 3 | --star-width: 200px; 4 | --star-height: 275px; 5 | 6 | --thumb-width: 290px; 7 | } 8 | 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6 { 15 | font-weight: 400; 16 | margin-top: 0.3em; 17 | margin-bottom: 0.3em; 18 | } 19 | 20 | .divider { 21 | color: grey; 22 | 23 | &::before, 24 | &::after { 25 | content: ' '; 26 | } 27 | } 28 | 29 | .unselectable, 30 | label { 31 | user-select: none; 32 | outline: none; 33 | } 34 | 35 | button { 36 | font-weight: 400 !important; 37 | font-size: 0.9rem !important; 38 | cursor: default !important; 39 | } 40 | 41 | /* Utils */ 42 | .text-center { 43 | text-align: center; 44 | } 45 | 46 | .d-inline-block { 47 | display: inline-block; 48 | } 49 | 50 | .d-flex { 51 | display: flex !important; 52 | } 53 | 54 | .mx-1 { 55 | margin-left: 0.5em !important; 56 | margin-right: 0.5em !important; 57 | } 58 | 59 | /* MUI Link Color */ 60 | a { 61 | color: rgb(25, 118, 212); 62 | text-decoration: underline rgba(25, 118, 212, 0.4); 63 | } 64 | 65 | a:hover { 66 | text-decoration-color: rgba(25, 118, 212, 1); 67 | } 68 | -------------------------------------------------------------------------------- /Client/src/interface.ts: -------------------------------------------------------------------------------- 1 | // Common types 2 | export type SetState = React.Dispatch> 3 | 4 | export type General = { 5 | id: number 6 | name: string 7 | } 8 | 9 | export type AllowString = T | (string & NonNullable) 10 | 11 | type WebsiteWithSites = General & { sites: string[] } 12 | 13 | // Other Types 14 | export type WebsiteWithCount = { 15 | _count: { 16 | videos: number 17 | } 18 | } & WebsiteWithSites 19 | 20 | export type Bookmark = { 21 | id: number 22 | category: { 23 | id: number 24 | name: string 25 | } 26 | start: number 27 | } 28 | 29 | export type Similar = { 30 | id: number 31 | name: string 32 | image: string | null 33 | match: number 34 | } 35 | 36 | export type Video = { 37 | id: number 38 | name: string 39 | validated: boolean 40 | image: string | null 41 | slug: string | null 42 | duration: number 43 | height?: number 44 | plays: number 45 | star: string 46 | website: string 47 | subsite: string 48 | date: { added: string; published: string; apiDate: string | null } 49 | path: { file: string; stream: string } 50 | } 51 | 52 | export type VideoStar = { 53 | id: number 54 | name: string 55 | image: string | null 56 | ageInVideo: number 57 | numVideos: number 58 | } 59 | 60 | export type StarVideo = { 61 | id: number 62 | name: string 63 | image: string 64 | date: string 65 | fname: string 66 | website: string 67 | site: string | null 68 | age: number 69 | hidden: boolean 70 | } 71 | 72 | export type LocalWebsite = { 73 | label: string 74 | count: number 75 | finished: boolean 76 | } 77 | 78 | export type StarSearch = { 79 | id: number 80 | name: string 81 | image: string | null 82 | age: number 83 | breast: string | null 84 | ethnicity: string | null 85 | haircolor: string[] 86 | score: number 87 | websites: string[] 88 | sites: string[] 89 | videoCount: number 90 | videoDates: number[] 91 | lastDate: number | undefined 92 | retired: boolean 93 | } 94 | 95 | export type VideoSearch = { 96 | id: number 97 | name: string 98 | ageInVideo: number 99 | attributes: string[] 100 | categories: string[] 101 | date: string 102 | image: string | null 103 | locations: string[] 104 | plays: number 105 | quality: number 106 | site: string | null 107 | star: string | null 108 | website: string 109 | api: string | null 110 | } 111 | 112 | export type File = { 113 | path: string 114 | website: string 115 | site: string 116 | title: string 117 | date: string 118 | } 119 | 120 | export type Missing = { 121 | videoId: number 122 | name: string 123 | } 124 | 125 | export type Star = { 126 | id: number 127 | name: string 128 | image: string | null 129 | slug: string | null 130 | ignored: boolean 131 | info: { 132 | breast: string 133 | haircolor: string[] 134 | ethnicity: string 135 | birthdate: string 136 | height: string 137 | weight: string 138 | } 139 | similar: Similar[] 140 | retired: boolean 141 | } 142 | -------------------------------------------------------------------------------- /Client/src/keys/attribute.ts: -------------------------------------------------------------------------------- 1 | import { createQueryKeys } from '@lukemorales/query-key-factory' 2 | 3 | export const attribute = createQueryKeys('attribute', { 4 | all: null 5 | }) 6 | -------------------------------------------------------------------------------- /Client/src/keys/category.ts: -------------------------------------------------------------------------------- 1 | import { createQueryKeys } from '@lukemorales/query-key-factory' 2 | 3 | export const category = createQueryKeys('category', { 4 | all: null 5 | }) 6 | -------------------------------------------------------------------------------- /Client/src/keys/index.ts: -------------------------------------------------------------------------------- 1 | // TODO move directory to /src/service/keys 2 | import { mergeQueryKeys } from '@lukemorales/query-key-factory' 3 | 4 | import { attribute } from './attribute' 5 | import { category } from './category' 6 | import { location } from './location' 7 | import { search } from './search' 8 | import { star } from './star' 9 | import { video } from './video' 10 | import { website } from './website' 11 | 12 | export const keys = mergeQueryKeys(attribute, category, location, search, star, video, website) 13 | -------------------------------------------------------------------------------- /Client/src/keys/location.ts: -------------------------------------------------------------------------------- 1 | import { createQueryKeys } from '@lukemorales/query-key-factory' 2 | 3 | export const location = createQueryKeys('location', { 4 | all: null 5 | }) 6 | -------------------------------------------------------------------------------- /Client/src/keys/search.ts: -------------------------------------------------------------------------------- 1 | import { createQueryKeys } from '@lukemorales/query-key-factory' 2 | 3 | export const search = createQueryKeys('search', { 4 | star: null, 5 | video: null 6 | }) 7 | -------------------------------------------------------------------------------- /Client/src/keys/star.ts: -------------------------------------------------------------------------------- 1 | import { createQueryKeys } from '@lukemorales/query-key-factory' 2 | 3 | export const star = createQueryKeys('star', { 4 | byId: (id: number) => ({ 5 | queryKey: [id], 6 | contextQueries: { 7 | video: null 8 | } 9 | }), 10 | info: null, 11 | all: null 12 | }) 13 | -------------------------------------------------------------------------------- /Client/src/keys/video.ts: -------------------------------------------------------------------------------- 1 | import { createQueryKeys } from '@lukemorales/query-key-factory' 2 | 3 | export const video = createQueryKeys('video', { 4 | byId: (id: number) => ({ 5 | queryKey: [id], 6 | contextQueries: { 7 | bookmark: null, 8 | star: null, 9 | attribute: null, 10 | location: null 11 | } 12 | }), 13 | home: (label: string) => ({ 14 | queryKey: [label] 15 | }), 16 | new: null 17 | }) 18 | -------------------------------------------------------------------------------- /Client/src/keys/website.ts: -------------------------------------------------------------------------------- 1 | import { createQueryKeys } from '@lukemorales/query-key-factory' 2 | 3 | export const website = createQueryKeys('website', { 4 | all: null 5 | }) 6 | -------------------------------------------------------------------------------- /Client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | 4 | import { QueryClientProvider, QueryClient, QueryCache, MutationCache } from '@tanstack/react-query' 5 | import { RouterProvider, createRouter } from '@tanstack/react-router' 6 | import { toast } from 'react-toastify' 7 | 8 | import ErrorComponent from './components/error' 9 | import NotFoundComponent from './components/not-found' 10 | import ModalContextProvider from './context/modalContext' 11 | import { routeTree } from './routeTree.gen' 12 | 13 | const router = createRouter({ routeTree }) 14 | 15 | declare module '@tanstack/react-router' { 16 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 17 | interface Register { 18 | router: typeof router 19 | } 20 | } 21 | 22 | const onError = () => { 23 | toast.error('Network Error', { autoClose: 1000 }) 24 | } 25 | 26 | const client = new QueryClient({ 27 | defaultOptions: { 28 | queries: { 29 | refetchOnMount: 'always', 30 | staleTime: Infinity 31 | } 32 | }, 33 | queryCache: new QueryCache({ onError }), 34 | mutationCache: new MutationCache({ onError }) 35 | }) 36 | 37 | const root = document.getElementById('root') 38 | if (root === null) throw new Error('root element not found') 39 | 40 | ReactDOM.createRoot(root).render( 41 | 42 | 43 | 44 | 49 | 50 | 51 | 52 | ) 53 | -------------------------------------------------------------------------------- /Client/src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | 5 | // @ts-nocheck 6 | 7 | // noinspection JSUnusedGlobalSymbols 8 | 9 | // This file is auto-generated by TanStack Router 10 | 11 | // Import Routes 12 | 13 | import { Route as rootRoute } from './routes/__root' 14 | import { Route as SettingsImport } from './routes/settings' 15 | import { Route as ConfigImport } from './routes/config' 16 | import { Route as IndexImport } from './routes/index' 17 | import { Route as StarIndexImport } from './routes/star/index' 18 | import { Route as EditorIndexImport } from './routes/editor/index' 19 | import { Route as VideoAddImport } from './routes/video/add' 20 | import { Route as VideoVideoIdImport } from './routes/video/$videoId' 21 | import { Route as StarStarIdImport } from './routes/star/$starId' 22 | import { Route as VideoSearchIndexImport } from './routes/video/search/index' 23 | import { Route as StarSearchIndexImport } from './routes/star/search/index' 24 | 25 | // Create/Update Routes 26 | 27 | const SettingsRoute = SettingsImport.update({ 28 | path: '/settings', 29 | getParentRoute: () => rootRoute, 30 | } as any) 31 | 32 | const ConfigRoute = ConfigImport.update({ 33 | path: '/config', 34 | getParentRoute: () => rootRoute, 35 | } as any) 36 | 37 | const IndexRoute = IndexImport.update({ 38 | path: '/', 39 | getParentRoute: () => rootRoute, 40 | } as any) 41 | 42 | const StarIndexRoute = StarIndexImport.update({ 43 | path: '/star/', 44 | getParentRoute: () => rootRoute, 45 | } as any) 46 | 47 | const EditorIndexRoute = EditorIndexImport.update({ 48 | path: '/editor/', 49 | getParentRoute: () => rootRoute, 50 | } as any) 51 | 52 | const VideoAddRoute = VideoAddImport.update({ 53 | path: '/video/add', 54 | getParentRoute: () => rootRoute, 55 | } as any) 56 | 57 | const VideoVideoIdRoute = VideoVideoIdImport.update({ 58 | path: '/video/$videoId', 59 | getParentRoute: () => rootRoute, 60 | } as any) 61 | 62 | const StarStarIdRoute = StarStarIdImport.update({ 63 | path: '/star/$starId', 64 | getParentRoute: () => rootRoute, 65 | } as any) 66 | 67 | const VideoSearchIndexRoute = VideoSearchIndexImport.update({ 68 | path: '/video/search/', 69 | getParentRoute: () => rootRoute, 70 | } as any) 71 | 72 | const StarSearchIndexRoute = StarSearchIndexImport.update({ 73 | path: '/star/search/', 74 | getParentRoute: () => rootRoute, 75 | } as any) 76 | 77 | // Populate the FileRoutesByPath interface 78 | 79 | declare module '@tanstack/react-router' { 80 | interface FileRoutesByPath { 81 | '/': { 82 | id: '/' 83 | path: '/' 84 | fullPath: '/' 85 | preLoaderRoute: typeof IndexImport 86 | parentRoute: typeof rootRoute 87 | } 88 | '/config': { 89 | id: '/config' 90 | path: '/config' 91 | fullPath: '/config' 92 | preLoaderRoute: typeof ConfigImport 93 | parentRoute: typeof rootRoute 94 | } 95 | '/settings': { 96 | id: '/settings' 97 | path: '/settings' 98 | fullPath: '/settings' 99 | preLoaderRoute: typeof SettingsImport 100 | parentRoute: typeof rootRoute 101 | } 102 | '/star/$starId': { 103 | id: '/star/$starId' 104 | path: '/star/$starId' 105 | fullPath: '/star/$starId' 106 | preLoaderRoute: typeof StarStarIdImport 107 | parentRoute: typeof rootRoute 108 | } 109 | '/video/$videoId': { 110 | id: '/video/$videoId' 111 | path: '/video/$videoId' 112 | fullPath: '/video/$videoId' 113 | preLoaderRoute: typeof VideoVideoIdImport 114 | parentRoute: typeof rootRoute 115 | } 116 | '/video/add': { 117 | id: '/video/add' 118 | path: '/video/add' 119 | fullPath: '/video/add' 120 | preLoaderRoute: typeof VideoAddImport 121 | parentRoute: typeof rootRoute 122 | } 123 | '/editor/': { 124 | id: '/editor/' 125 | path: '/editor' 126 | fullPath: '/editor' 127 | preLoaderRoute: typeof EditorIndexImport 128 | parentRoute: typeof rootRoute 129 | } 130 | '/star/': { 131 | id: '/star/' 132 | path: '/star' 133 | fullPath: '/star' 134 | preLoaderRoute: typeof StarIndexImport 135 | parentRoute: typeof rootRoute 136 | } 137 | '/star/search/': { 138 | id: '/star/search/' 139 | path: '/star/search' 140 | fullPath: '/star/search' 141 | preLoaderRoute: typeof StarSearchIndexImport 142 | parentRoute: typeof rootRoute 143 | } 144 | '/video/search/': { 145 | id: '/video/search/' 146 | path: '/video/search' 147 | fullPath: '/video/search' 148 | preLoaderRoute: typeof VideoSearchIndexImport 149 | parentRoute: typeof rootRoute 150 | } 151 | } 152 | } 153 | 154 | // Create and export the route tree 155 | 156 | export const routeTree = rootRoute.addChildren({ 157 | IndexRoute, 158 | ConfigRoute, 159 | SettingsRoute, 160 | StarStarIdRoute, 161 | VideoVideoIdRoute, 162 | VideoAddRoute, 163 | EditorIndexRoute, 164 | StarIndexRoute, 165 | StarSearchIndexRoute, 166 | VideoSearchIndexRoute, 167 | }) 168 | 169 | /* prettier-ignore-end */ 170 | 171 | /* ROUTE_MANIFEST_START 172 | { 173 | "routes": { 174 | "__root__": { 175 | "filePath": "__root.tsx", 176 | "children": [ 177 | "/", 178 | "/config", 179 | "/settings", 180 | "/star/$starId", 181 | "/video/$videoId", 182 | "/video/add", 183 | "/editor/", 184 | "/star/", 185 | "/star/search/", 186 | "/video/search/" 187 | ] 188 | }, 189 | "/": { 190 | "filePath": "index.tsx" 191 | }, 192 | "/config": { 193 | "filePath": "config.tsx" 194 | }, 195 | "/settings": { 196 | "filePath": "settings.tsx" 197 | }, 198 | "/star/$starId": { 199 | "filePath": "star/$starId.tsx" 200 | }, 201 | "/video/$videoId": { 202 | "filePath": "video/$videoId.tsx" 203 | }, 204 | "/video/add": { 205 | "filePath": "video/add.tsx" 206 | }, 207 | "/editor/": { 208 | "filePath": "editor/index.tsx" 209 | }, 210 | "/star/": { 211 | "filePath": "star/index.tsx" 212 | }, 213 | "/star/search/": { 214 | "filePath": "star/search/index.tsx" 215 | }, 216 | "/video/search/": { 217 | "filePath": "video/search/index.tsx" 218 | } 219 | } 220 | } 221 | ROUTE_MANIFEST_END */ 222 | -------------------------------------------------------------------------------- /Client/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { Container, CssBaseline } from '@mui/material' 2 | 3 | import { createRootRoute, Outlet } from '@tanstack/react-router' 4 | import { ToastContainer } from 'react-toastify' 5 | 6 | import NavBar from '@/components/navbar' 7 | 8 | import '../index.css' 9 | import 'react-toastify/dist/ReactToastify.min.css' 10 | 11 | export const Route = createRootRoute({ 12 | component: () => ( 13 | <> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | }) 25 | -------------------------------------------------------------------------------- /Client/src/routes/config.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { Button, Checkbox, FormControlLabel, Grid, List, TextField } from '@mui/material' 4 | 5 | import { createFileRoute } from '@tanstack/react-router' 6 | import { useLocalStorage } from 'usehooks-ts' 7 | 8 | import Spinner from '@/components/spinner' 9 | 10 | import { General, LocalWebsite, SetState } from '@/interface' 11 | import { websiteService } from '@/service' 12 | import { clamp } from '@/utils' 13 | 14 | export const Route = createFileRoute('/config')({ 15 | component: SettingsPage 16 | }) 17 | 18 | function SettingsPage() { 19 | const [rawWebsites, setRawWebsites] = useLocalStorage('websites', []) 20 | const [localWebsites, setLocalWebsites] = useState([]) 21 | const [changed, setChanged] = useState(false) 22 | 23 | const { data: websites } = websiteService.useAll() 24 | 25 | useEffect(() => { 26 | setLocalWebsites(rawWebsites) 27 | }, [rawWebsites]) 28 | 29 | const handleSubmit = (e: React.FormEvent) => { 30 | e.preventDefault() 31 | 32 | // update browser-storage 33 | setRawWebsites(localWebsites.filter(wsite => wsite.count > 0)) 34 | 35 | // Reset Changes 36 | setChanged(false) 37 | } 38 | 39 | const handleAddWebsite = (wsite: string) => { 40 | setLocalWebsites(prev => [...prev, { label: wsite, count: 0, finished: false }]) 41 | 42 | handleChanged() 43 | } 44 | 45 | const handleChanged = () => { 46 | setChanged(true) 47 | } 48 | 49 | if (websites === undefined) return 50 | 51 | return ( 52 | 53 | 54 | 55 | localWebsites.every(wsite => wsite.label !== website.name))} 57 | addWebsite={handleAddWebsite} 58 | /> 59 | 60 | 61 | 62 | 63 | 64 | {localWebsites 65 | .filter((_, i) => i % 2 === 0) 66 | .map(wsite => ( 67 | w.name === wsite.label)?._count.videos} 73 | onChange={handleChanged} 74 | /> 75 | ))} 76 | 77 | 78 | 79 | {localWebsites 80 | .filter((_, i) => i % 2 !== 0) 81 | .map(wsite => ( 82 | w.name === wsite.label)?._count.videos} 88 | onChange={handleChanged} 89 | /> 90 | ))} 91 | 92 | 93 | 94 | 97 | 98 | 99 | 100 | ) 101 | } 102 | 103 | type WebsiteListProps = { 104 | websites: General[] 105 | addWebsite: (wsite: string) => void 106 | } 107 | function WebsiteList({ websites, addWebsite }: WebsiteListProps) { 108 | return ( 109 | 110 | {websites.map(website => ( 111 |
addWebsite(website.name)}> 112 | {`ADD "${website.name}"`} 113 |
114 | ))} 115 |
116 | ) 117 | } 118 | 119 | type InputProps = { 120 | website: LocalWebsite 121 | update: SetState 122 | localWebsites: LocalWebsite[] 123 | max?: number 124 | onChange: () => void 125 | } 126 | function Input({ website, update, localWebsites, max = 0, onChange }: InputProps) { 127 | const [count, setCount] = useState(website.count) 128 | const [finished, setFinished] = useState(website.finished) 129 | 130 | const handleChange = (e: React.ChangeEvent) => { 131 | const value = clamp(parseInt(e.target.value), max) 132 | 133 | setCount(value) 134 | update( 135 | localWebsites.map(wsite => { 136 | if (wsite.label === website.label) { 137 | return { ...wsite, count: value } 138 | } 139 | 140 | return wsite 141 | }) 142 | ) 143 | 144 | // trigger change 145 | onChange() 146 | } 147 | 148 | const handleCheck = (_e: React.ChangeEvent, checked: boolean) => { 149 | setFinished(checked) 150 | 151 | update( 152 | localWebsites.map(wsite => { 153 | if (wsite.label === website.label) { 154 | return { ...wsite, finished: checked } 155 | } 156 | 157 | return wsite 158 | }) 159 | ) 160 | 161 | // trigger change 162 | onChange() 163 | } 164 | 165 | return ( 166 | 167 | 168 | 169 | } 172 | /> 173 | 174 | ) 175 | } 176 | -------------------------------------------------------------------------------- /Client/src/routes/editor/editor.module.css: -------------------------------------------------------------------------------- 1 | .table-striped tbody tr:nth-of-type(odd) { 2 | background-color: rgba(0, 0, 0, 0.05); 3 | } 4 | -------------------------------------------------------------------------------- /Client/src/routes/editor/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import { 4 | Grid, 5 | Button, 6 | Table as MuiTable, 7 | TableContainer, 8 | TableHead, 9 | TableRow as MuiTableRow, 10 | TableCell, 11 | TableBody, 12 | TextField, 13 | Paper 14 | } from '@mui/material' 15 | 16 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 17 | import { createFileRoute } from '@tanstack/react-router' 18 | import capitalize from 'capitalize' 19 | 20 | import TextFieldForm from '@/components/text-field-form' 21 | 22 | import createApi from '../../config/api' 23 | 24 | import { General } from '@/interface' 25 | 26 | import styles from './editor.module.css' 27 | 28 | export const Route = createFileRoute('/editor/')({ 29 | component: () => ( 30 | 31 | 32 |
33 |
34 | 35 | ) 36 | }) 37 | 38 | type TableKeys = 'attribute' | 'category' | 'location' 39 | 40 | type TableProps = { 41 | name: TableKeys 42 | } 43 | function Table({ name }: TableProps) { 44 | const [value, setValue] = useState('') 45 | 46 | const queryClient = useQueryClient() 47 | 48 | const { api } = createApi(`/${name}`) 49 | 50 | const { data } = useQuery({ 51 | queryKey: [name], 52 | queryFn: () => api.get('') 53 | }) 54 | 55 | const { mutate } = useMutation({ 56 | mutationKey: [name, 'add'], 57 | mutationFn: payload => api.post('', payload), 58 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [name] }) 59 | }) 60 | 61 | const handleSubmit = (e: React.FormEvent) => { 62 | e.preventDefault() 63 | 64 | mutate({ name: value }) 65 | 66 | setValue('') 67 | } 68 | 69 | return ( 70 | 71 | 72 | 73 | setValue(e.target.value)} 79 | style={{ marginLeft: 5, marginRight: 5 }} 80 | /> 81 | 82 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ID 93 | {capitalize(name)} 94 | 95 | 96 | 97 | {data?.map(item => )} 98 | 99 | 100 | 101 | ) 102 | } 103 | 104 | type TableRowProps = { 105 | data: General 106 | name: TableKeys 107 | } 108 | function TableRow({ data, name }: TableRowProps) { 109 | const [edit, setEdit] = useState(false) 110 | 111 | const queryClient = useQueryClient() 112 | 113 | const { api } = createApi(`/${name}`) 114 | 115 | const { mutate } = useMutation({ 116 | mutationKey: [name, 'update'], 117 | mutationFn: payload => api.put(`/${data.id}`, payload), 118 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [name] }) 119 | }) 120 | 121 | const handleSubmit = (input: string) => { 122 | mutate({ name: input }) 123 | 124 | setEdit(false) 125 | } 126 | 127 | return ( 128 | 129 | {data.id} 130 | 131 | {edit ? ( 132 | setEdit(false)} 138 | callback={handleSubmit} 139 | /> 140 | ) : ( 141 | setEdit(true)}>{data.name} 142 | )} 143 | 144 | 145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /Client/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@mui/material' 2 | 3 | import { Link, createFileRoute } from '@tanstack/react-router' 4 | import capitalize from 'capitalize' 5 | 6 | import MissingImage from '@/components/image/missing' 7 | import Ribbon, { RibbonContainer } from '@/components/ribbon' 8 | import Spinner from '@/components/spinner' 9 | 10 | import { serverConfig } from '@/config' 11 | import { homeService } from '@/service' 12 | 13 | export const Route = createFileRoute('/')({ 14 | component: () => ( 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | }) 22 | 23 | type ColumnProps = { 24 | label: string 25 | cols: number 26 | rows?: number 27 | } 28 | function Column({ label, cols, rows = 1 }: ColumnProps) { 29 | const { data: videos } = homeService.useVideos(label, cols * rows) 30 | 31 | if (videos === undefined) return 32 | 33 | return ( 34 | 35 |

36 | {capitalize(label)} ({videos.length}) 37 |

38 | 39 | 40 | {videos.map(video => ( 41 | 42 | 43 | 44 | {video.image === null ? ( 45 | 46 | ) : ( 47 | video 56 | )} 57 | 58 |
68 | {video.name} 69 |
70 | 71 | {video.total !== undefined && } 72 |
73 | 74 |
75 | ))} 76 |
77 |
78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /Client/src/routes/settings.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { 4 | Button, 5 | Checkbox, 6 | FormControl, 7 | FormControlLabel, 8 | Grid, 9 | InputLabel, 10 | MenuItem, 11 | Select, 12 | SelectChangeEvent, 13 | TextField 14 | } from '@mui/material' 15 | 16 | import { createFileRoute } from '@tanstack/react-router' 17 | 18 | import { defaultSettings, dropdownOptions, useSettings } from '@/components/settings' 19 | 20 | import { SetState } from '@/interface' 21 | 22 | export const Route = createFileRoute('/settings')({ 23 | component: SettingsPage 24 | }) 25 | 26 | function SettingsPage() { 27 | const [changed, setChanged] = useState(false) 28 | const { localSettings, setLocalSettings } = useSettings() 29 | const [settings, setSettings] = useState(localSettings) 30 | 31 | const handleSubmit = (e: React.FormEvent) => { 32 | e.preventDefault() 33 | 34 | setLocalSettings(settings) 35 | 36 | setChanged(false) 37 | } 38 | 39 | const handleChanged = () => { 40 | setChanged(true) 41 | } 42 | 43 | const entries = Object.entries(localSettings) 44 | const leftSide = entries.filter((_, i) => i % 2 === 0) 45 | const rightSide = entries.filter((_, i) => i % 2 === 1) 46 | 47 | return ( 48 | 49 | 50 | 51 | 52 | {entries.length > 1 ? ( 53 | <> 54 | 55 | {leftSide.map(([key, value]) => ( 56 | 57 | ))} 58 | 59 | 60 | 61 | {rightSide.map(([key, value]) => ( 62 | 63 | ))} 64 | 65 | 66 | ) : ( 67 | 68 | {entries.map(([key, value]) => ( 69 | 77 | ))} 78 | 79 | )} 80 | 81 | 82 | 85 | 86 | 87 | 88 | ) 89 | } 90 | 91 | type InputProps = { 92 | label: string 93 | setting: (typeof defaultSettings)[keyof typeof defaultSettings] 94 | update: SetState 95 | onChange: () => void 96 | fullWidth?: boolean 97 | } 98 | 99 | function Input({ label, setting, update, onChange, fullWidth = true }: InputProps) { 100 | const [value, setValue] = useState(setting) 101 | 102 | useEffect(() => { 103 | setValue(setting) 104 | }, [setting]) 105 | 106 | const handleChange = (newValue: typeof setting) => { 107 | setValue(newValue) 108 | update(prev => ({ ...prev, [label]: newValue })) 109 | onChange() 110 | } 111 | 112 | const style = fullWidth ? { width: '100%' } : {} 113 | if (typeof value === 'boolean') { 114 | return 115 | } else if (typeof value === 'string') { 116 | return ( 117 | handleChange(newValue as typeof setting)} 121 | style={style} 122 | /> 123 | ) 124 | } else { 125 | return 126 | } 127 | } 128 | 129 | type InputRenderProps = { 130 | label: string 131 | value: T 132 | onChange: (value: T) => void 133 | style?: React.CSSProperties 134 | } 135 | 136 | function InputBoolean({ label, value, onChange, style = {} }: InputRenderProps) { 137 | const handleChange = (e: React.ChangeEvent) => { 138 | onChange(e.target.checked) 139 | } 140 | 141 | return ( 142 | 143 | } label={label} /> 144 | 145 | ) 146 | } 147 | 148 | function InputNumber({ label, value, onChange, style = {} }: InputRenderProps) { 149 | const handleChange = (e: React.ChangeEvent) => { 150 | onChange(Number(e.target.value)) 151 | } 152 | 153 | return ( 154 | 155 | 156 | 157 | ) 158 | } 159 | 160 | function InputString({ label, value, onChange, style = {} }: InputRenderProps) { 161 | const options = dropdownOptions[label as keyof typeof dropdownOptions] 162 | const [newValue, setNewValue] = useState(value) 163 | 164 | useEffect(() => { 165 | setNewValue(value) 166 | }, [value]) 167 | 168 | const handleChange = (e: SelectChangeEvent) => { 169 | setNewValue(e.target.value) 170 | onChange(e.target.value) 171 | } 172 | 173 | return ( 174 | 175 | 176 | {label} 177 | 178 | 185 | 186 | 187 | ) 188 | } 189 | -------------------------------------------------------------------------------- /Client/src/routes/star/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { Grid, TextField, Card, CardActionArea, CardContent, Button, Typography, CardMedia } from '@mui/material' 4 | 5 | import { Link, createFileRoute } from '@tanstack/react-router' 6 | import { useSessionStorage } from 'usehooks-ts' 7 | 8 | import MissingImage from '@/components/image/missing' 9 | import Spinner from '@/components/spinner' 10 | 11 | import { serverConfig } from '@/config' 12 | import { Missing } from '@/interface' 13 | import { starService } from '@/service' 14 | import { getUnique } from '@/utils' 15 | 16 | export const Route = createFileRoute('/star/')({ 17 | component: Stars 18 | }) 19 | 20 | function Stars() { 21 | const [input, setInput] = useState('') 22 | const [activeStar, setActiveStar] = useState() 23 | const [index, setIndex] = useState(0) 24 | 25 | const [starInput, setStarInput] = useSessionStorage('starInput', '') 26 | 27 | const { data } = starService.useAll() 28 | const { mutate, mutateAll } = starService.useAddStar() 29 | 30 | useEffect(() => { 31 | if (data === undefined) return 32 | 33 | if (data.missing.length) setInput(data.missing[index].name) 34 | }, [index, data]) 35 | 36 | useEffect(() => { 37 | if (data === undefined) return 38 | 39 | //TODO hook runs every input change, and causes another rerender 40 | setActiveStar(data.stars.find(s => s.name === starInput)?.name) 41 | }, [data, starInput]) 42 | 43 | if (data === undefined) return 44 | 45 | const missing = getUnique(data.missing, 'name').filter(star => data.stars.every(s => s.name !== star.name)) 46 | 47 | const handleSubmit = (e: React.FormEvent) => { 48 | e.preventDefault() 49 | 50 | if (input.length) { 51 | setStarInput(input) 52 | mutate({ name: input }) 53 | } 54 | } 55 | 56 | const handleSubmitAll = () => { 57 | mutateAll(missing.map(missing => ({ name: missing.name }))) 58 | } 59 | 60 | return ( 61 | 62 |
63 | setInput(e.currentTarget.value)} /> 64 | 65 | 66 | 67 | 74 | 75 | 85 | 86 | 87 | 88 | {data.stars 89 | .filter(star => star.image === null) 90 | .filter(star => star.name.includes(' ')) 91 | .sort((a, b) => a.name.localeCompare(b.name)) 92 | .slice(0, 12 * 12) // limit results to avoid crash 93 | .map(star => ( 94 | 95 | 96 | 97 | 98 | 99 | {star.image === null ? ( 100 | 101 | ) : ( 102 | star 103 | )} 104 | 105 | 106 | 107 | {star.name} 108 | 109 | 110 | 111 | 112 | 113 | ))} 114 | 115 | 116 | 117 | {data.missing 118 | .sort((a, b) => a.name.localeCompare(b.name)) 119 | .slice(0, 500) // limit results to avoid crash 120 | .map(star => ( 121 | 122 | 123 | 124 | {star.name} 125 | 126 | 127 | 128 | {star.videoId} 129 | 130 | 131 | 132 | 133 | 134 | ))} 135 | 136 |
137 | ) 138 | } 139 | 140 | type IndexChanger = { 141 | total: Missing[] 142 | index: number 143 | setIndex: (index: number) => void 144 | } 145 | function IndexChanger({ total, index, setIndex }: IndexChanger) { 146 | return ( 147 | 148 | 156 | 157 | 158 | {index} 159 | 160 | 161 | 169 | 170 | ) 171 | } 172 | -------------------------------------------------------------------------------- /Client/src/routes/star/search/-stars.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardActionArea, CardMedia, Typography } from '@mui/material' 2 | 3 | import { Link } from '@tanstack/react-router' 4 | 5 | import Badge from '@/components/badge' 6 | import MissingImage from '@/components/image/missing' 7 | import Ribbon, { RibbonContainer } from '@/components/ribbon' 8 | import { isDefault } from '@/components/search/filter' 9 | import { defaultStarObj as defaultObj, getStarSort as getSort } from '@/components/search/sort' 10 | import Spinner from '@/components/spinner' 11 | import VGrid from '@/components/virtualized/virtuoso' 12 | 13 | import { serverConfig } from '@/config' 14 | import { useAllSearchParams } from '@/hooks/search' 15 | import { StarSearch as Star } from '@/interface' 16 | import { searchService } from '@/service' 17 | import { daysToYears } from '@/utils' 18 | 19 | import styles from './search.module.scss' 20 | 21 | export default function Stars() { 22 | const { breast, haircolor, ethnicity, query, sort, website } = useAllSearchParams(defaultObj) 23 | const { data: stars, isLoading } = searchService.useStars() 24 | 25 | if (isLoading || stars === undefined) return 26 | 27 | const visible = stars 28 | .sort(getSort(sort)) 29 | .filter(s => s.name.toLowerCase().includes(query.toLowerCase()) || isDefault(query, defaultObj.query)) 30 | .filter( 31 | s => s.breast === breast || (s.breast === null && breast === 'NULL') || isDefault(breast, defaultObj.breast) 32 | ) 33 | .filter(s => s.haircolor.includes(haircolor) || isDefault(haircolor, defaultObj.haircolor)) 34 | .filter(s => s.ethnicity === ethnicity || isDefault(ethnicity, defaultObj.ethnicity)) 35 | .filter(s => s.websites.includes(website) || isDefault(website, defaultObj.website)) 36 | 37 | return ( 38 |
39 | 40 | {visible.length} Stars 41 | 42 | 43 | } /> 44 |
45 | ) 46 | } 47 | 48 | type StarCardProps = { 49 | star?: Star 50 | } 51 | function StarCard({ star }: StarCardProps) { 52 | if (star === undefined) return null 53 | 54 | return ( 55 | 56 | 57 | 58 | 59 | 60 | {star.image === null ? ( 61 | 62 | ) : ( 63 | star 68 | )} 69 | 70 | 71 | {star.name} 72 | 73 | 74 | 75 | 76 | 77 | 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /Client/src/routes/star/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, Grid, RadioGroup, SelectChangeEvent, TextField } from '@mui/material' 2 | 3 | import { createFileRoute } from '@tanstack/react-router' 4 | import ScrollToTop from 'react-scroll-to-top' 5 | 6 | import { FilterDropdown, FilterRadio, isDefault } from '@/components/search/filter' 7 | import { SortObjStar as SortObj, defaultStarObj as defaultObj, getSortString } from '@/components/search/sort' 8 | import Spinner from '@/components/spinner' 9 | 10 | import Stars from './-stars' 11 | 12 | import { useAllSearchParams, useDynamicSearchParam, useSearchParam } from '@/hooks/search' 13 | import useFocus from '@/hooks/useFocus' 14 | import { starService, websiteService } from '@/service' 15 | 16 | import styles from './search.module.scss' 17 | 18 | export const Route = createFileRoute('/star/search/')({ 19 | component: () => ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | }) 35 | 36 | function Filter() { 37 | const { data: websites } = websiteService.useAll() 38 | const { data: starData } = starService.useInfo() 39 | 40 | const { setParam, update } = useDynamicSearchParam(defaultObj) 41 | 42 | const breast = (target: string) => { 43 | if (isDefault(target, defaultObj.breast)) { 44 | setParam('breast', defaultObj.breast) 45 | } else { 46 | setParam('breast', target) 47 | } 48 | update() 49 | } 50 | 51 | const haircolor = (target: string) => { 52 | if (isDefault(target, defaultObj.haircolor)) { 53 | setParam('haircolor', defaultObj.haircolor) 54 | } else { 55 | setParam('haircolor', target) 56 | } 57 | update() 58 | } 59 | 60 | const ethnicity = (target: string) => { 61 | if (isDefault(target, defaultObj.ethnicity)) { 62 | setParam('ethnicity', defaultObj.ethnicity) 63 | } else { 64 | setParam('ethnicity', target) 65 | } 66 | update() 67 | } 68 | 69 | const website_DROP = (e: SelectChangeEvent) => { 70 | const value = e.target.value 71 | 72 | setParam('website', value) 73 | update() 74 | } 75 | 76 | const breast_NULL = () => { 77 | setParam('breast', 'NULL') 78 | update() 79 | } 80 | 81 | const breast_ALL = () => { 82 | setParam('breast', defaultObj.breast) 83 | update() 84 | } 85 | 86 | const haircolor_ALL = () => { 87 | setParam('haircolor', defaultObj.haircolor) 88 | update() 89 | } 90 | 91 | const ethnicity_ALL = () => { 92 | setParam('ethnicity', defaultObj.ethnicity) 93 | update() 94 | } 95 | 96 | return ( 97 | <> 98 | 99 | 100 | {starData === undefined ? ( 101 | 102 | ) : ( 103 | <> 104 | 112 | 113 | 120 | 121 | 128 | 129 | )} 130 | 131 | ) 132 | } 133 | 134 | function Sort() { 135 | const { setParam, update } = useDynamicSearchParam(defaultObj) 136 | const { sort } = useAllSearchParams(defaultObj) 137 | 138 | const sortAlphabetical = (reverse = false) => { 139 | if (reverse) { 140 | setParam('sort', '-alphabetical') 141 | } else { 142 | setParam('sort', 'alphabetical') 143 | } 144 | update() 145 | } 146 | 147 | const sortAdded = (reverse = false) => { 148 | if (reverse) { 149 | setParam('sort', '-added') 150 | } else { 151 | setParam('sort', 'added') 152 | } 153 | update() 154 | } 155 | 156 | const sortAge = (reverse = false) => { 157 | if (reverse) { 158 | setParam('sort', '-age') 159 | } else { 160 | setParam('sort', 'age') 161 | } 162 | update() 163 | } 164 | 165 | const sortVideos = (reverse = false) => { 166 | if (reverse) { 167 | setParam('sort', '-videos') 168 | } else { 169 | setParam('sort', 'videos') 170 | } 171 | update() 172 | } 173 | 174 | const sortScore = (reverse = false) => { 175 | if (reverse) { 176 | setParam('sort', '-score') 177 | } else { 178 | setParam('sort', 'score') 179 | } 180 | update() 181 | } 182 | 183 | const sortActivity = (reverse = false) => { 184 | if (reverse) { 185 | setParam('sort', '-activity') 186 | } else { 187 | setParam('sort', 'activity') 188 | } 189 | update() 190 | } 191 | 192 | return ( 193 | <> 194 |

Sort

195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | ) 208 | } 209 | 210 | function TitleSearch() { 211 | const { setParam, update } = useDynamicSearchParam(defaultObj) 212 | const { currentValue } = useSearchParam(defaultObj, 'query') 213 | 214 | const ref = useFocus(currentValue) 215 | 216 | const callback = (e: React.ChangeEvent) => { 217 | setParam('query', e.currentTarget.value) 218 | update() 219 | } 220 | 221 | return 222 | } 223 | -------------------------------------------------------------------------------- /Client/src/routes/star/search/search.module.scss: -------------------------------------------------------------------------------- 1 | #sidebar { 2 | h2 { 3 | margin-bottom: 0; 4 | } 5 | 6 | .global { 7 | text-decoration: underline; 8 | } 9 | } 10 | 11 | #stars { 12 | #count { 13 | color: blue; 14 | } 15 | 16 | .star { 17 | margin: 5px; 18 | 19 | width: var(--star-width); 20 | img { 21 | height: var(--star-height); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Client/src/routes/star/star.module.scss: -------------------------------------------------------------------------------- 1 | #similar { 2 | text-align: center; 3 | 4 | .star { 5 | display: inline-block; 6 | width: 200px; 7 | 8 | margin: 10px; 9 | 10 | &:hover { 11 | text-decoration: underline !important; 12 | } 13 | } 14 | } 15 | 16 | // When changing images 17 | .profile { 18 | max-height: var(--star-height); 19 | width: var(--star-width) !important; 20 | object-fit: inherit !important; 21 | } 22 | 23 | #star { 24 | #profile { 25 | max-height: var(--star-height); 26 | width: var(--star-width); 27 | } 28 | 29 | #ignored { 30 | color: red; 31 | } 32 | 33 | .action { 34 | margin-right: 10px; 35 | margin-bottom: 10px; 36 | } 37 | 38 | .no-error { 39 | color: rgba(black, 0.6); 40 | } 41 | 42 | .error label { 43 | font-weight: bold; 44 | color: black; 45 | } 46 | 47 | input { 48 | text-transform: capitalize; 49 | } 50 | 51 | .capitalize input { 52 | text-transform: uppercase; 53 | } 54 | 55 | .data { 56 | margin-right: 0.5em; 57 | } 58 | } 59 | 60 | .video { 61 | margin: 0 10px 10px 5px !important; 62 | width: var(--thumb-width); 63 | 64 | &.hidden { 65 | opacity: 0.2; 66 | } 67 | 68 | .info { 69 | padding: 2px 8px; 70 | } 71 | 72 | .site-info { 73 | text-align: center; 74 | margin-top: 10px; 75 | 76 | .wsite { 77 | color: green; 78 | } 79 | 80 | .site { 81 | color: red; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Client/src/routes/video/$videoId.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react' 2 | 3 | import { Grid, Card, Typography, CardMedia } from '@mui/material' 4 | 5 | import { Link, createFileRoute } from '@tanstack/react-router' 6 | import { ContextMenu, ContextMenuTrigger, ContextMenuItem } from 'rctx-contextmenu' 7 | 8 | import Badge from '@/components/badge' 9 | import { IconWithText } from '@/components/icon' 10 | import MissingImage from '@/components/image/missing' 11 | import Ribbon, { RibbonContainer } from '@/components/ribbon' 12 | import Spinner from '@/components/spinner' 13 | import TextFieldForm from '@/components/text-field-form' 14 | import { Header, Player as VideoPlayer, Timeline } from '@/components/video' 15 | import { MediaPlayerInstance } from '@/components/vidstack' 16 | 17 | import { serverConfig } from '@/config' 18 | import { videoService } from '@/service' 19 | import { daysToYears } from '@/utils' 20 | 21 | import styles from './video.module.css' 22 | 23 | export const Route = createFileRoute('/video/$videoId')({ 24 | parseParams: ({ videoId }) => ({ videoId: parseInt(videoId) }), 25 | component: () => ( 26 | 27 |
28 | 29 | 30 |
31 | 32 | 33 | 34 |
35 |
36 | 37 | ) 38 | }) 39 | 40 | function Section() { 41 | const { videoId } = Route.useParams() 42 | 43 | const playerRef = useRef(null) 44 | const [ready, setReady] = useState(false) 45 | 46 | return ( 47 | 48 |
49 | setReady(true)} /> 50 | 51 | 52 | ) 53 | } 54 | 55 | function Star() { 56 | const { videoId } = Route.useParams() 57 | 58 | const { data: star, optimisticAdd: optimisticStar } = videoService.useStar(videoId) 59 | const { mutate } = videoService.useRemoveStar(videoId) 60 | 61 | if (star === undefined) return 62 | 63 | if (optimisticStar !== undefined) return 64 | if (star === null) return null 65 | 66 | return ( 67 |
68 | 69 | 70 | 71 | 72 | {star.image === null ? ( 73 | 74 | ) : ( 75 | star 80 | )} 81 | 82 | 83 | 84 | 85 | {star.name} 86 | 87 | 88 | 89 | {star.ageInVideo > 0 && } 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 |
98 | ) 99 | } 100 | 101 | function OptimisticStar() { 102 | const { videoId } = Route.useParams() 103 | 104 | const { optimisticAdd: optimisticStar } = videoService.useStar(videoId) 105 | 106 | if (optimisticStar === undefined) return null 107 | 108 | return ( 109 |
110 | 111 | 112 | 113 | 114 | 115 | 116 | {optimisticStar.name} 117 | 118 | 119 |
120 | ) 121 | } 122 | 123 | function StarInput() { 124 | const { videoId } = Route.useParams() 125 | 126 | const { data: video } = videoService.useVideo(videoId) 127 | const { data: star, optimisticAdd: optimisticStar } = videoService.useStar(videoId) 128 | const { mutate } = videoService.useAddStar(videoId) 129 | 130 | const handleSubmit = (value: string) => { 131 | mutate({ name: value }) 132 | } 133 | 134 | if (star === undefined) return 135 | if (star !== null || optimisticStar !== undefined || video === undefined) return null 136 | 137 | return 138 | } 139 | -------------------------------------------------------------------------------- /Client/src/routes/video/add.module.css: -------------------------------------------------------------------------------- 1 | .table-striped tbody tr:nth-of-type(odd) { 2 | background-color: rgba(0, 0, 0, 0.05); 3 | } 4 | -------------------------------------------------------------------------------- /Client/src/routes/video/add.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { 4 | Grid, 5 | Button, 6 | Table, 7 | TableContainer, 8 | TableBody, 9 | TableRow, 10 | TableCell, 11 | TableHead, 12 | Typography, 13 | Paper 14 | } from '@mui/material' 15 | 16 | import { createFileRoute } from '@tanstack/react-router' 17 | 18 | import Spinner from '@/components/spinner' 19 | 20 | import { generateService, videoService } from '@/service' 21 | 22 | import styles from './add.module.css' 23 | 24 | export const Route = createFileRoute('/video/add')({ 25 | component: AddVideoPage 26 | }) 27 | 28 | function AddVideoPage() { 29 | const { data, isLoading } = videoService.useNew() 30 | const { mutate, isPending } = videoService.useAddVideos() 31 | 32 | if (data === undefined) return 33 | 34 | return ( 35 | 36 | Import Videos 37 | {data.files.length === 0 ? ( 38 |
39 | 40 | 41 | 42 |
43 | ) : ( 44 | <> 45 | 46 |
47 | 48 | 49 | website 50 | site 51 | path 52 | title 53 | 54 | 55 | 56 | 57 | {data.files.map(file => ( 58 | 59 | {file.website} 60 | {file.site} 61 | {file.path} 62 | {file.title} 63 | 64 | ))} 65 | 66 |
67 | 68 | 69 |
70 | mutate({ videos: data.files })} 73 | disabled={isPending || isLoading} 74 | /> 75 |
76 | 77 | )} 78 |
79 | ) 80 | } 81 | 82 | type ActionProps = { 83 | label: string 84 | callback: () => void | Promise 85 | disabled?: boolean 86 | } 87 | function Action({ label, callback, disabled = false }: ActionProps) { 88 | const [enabled, setEnabled] = useState(!disabled) 89 | 90 | useEffect(() => { 91 | setEnabled(!disabled) 92 | }, [disabled]) 93 | 94 | return ( 95 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /Client/src/routes/video/search/-videos.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | import { Card, CardActionArea, CardMedia, Grid, Typography } from '@mui/material' 4 | 5 | import { Link } from '@tanstack/react-router' 6 | import { useReadLocalStorage } from 'usehooks-ts' 7 | 8 | import MissingImage from '@/components/image/missing' 9 | import Ribbon, { RibbonContainer } from '@/components/ribbon' 10 | import { isDefault } from '@/components/search/filter' 11 | import { defaultVideoObj as defaultObj, getVideoSort as getSort } from '@/components/search/sort' 12 | import { useSettings } from '@/components/settings' 13 | import Spinner from '@/components/spinner' 14 | import VGrid from '@/components/virtualized/virtuoso' 15 | 16 | import { serverConfig } from '@/config' 17 | import { useAllSearchParams } from '@/hooks/search' 18 | import { LocalWebsite, VideoSearch } from '@/interface' 19 | import { searchService } from '@/service' 20 | import { daysToYears } from '@/utils' 21 | 22 | import styles from './search.module.scss' 23 | 24 | export default function Videos() { 25 | const { sort, query, attribute, category, nullCategory, location, website, site } = useAllSearchParams(defaultObj) 26 | const { data: videos, isLoading } = searchService.useVideos() 27 | 28 | const { localSettings } = useSettings() 29 | const localWebsites = useReadLocalStorage('websites') 30 | const [filtered, setFiltered] = useState([]) 31 | const [data, setData] = useState<{ label: string; count: number }[]>([]) 32 | 33 | useEffect(() => { 34 | if (videos === undefined) return 35 | 36 | const map = new Map() 37 | 38 | const initialData = (localWebsites !== null ? [...localWebsites] : []).map(wsite => ({ 39 | ...wsite, 40 | count: wsite.finished ? wsite.count + 1 : wsite.count 41 | })) 42 | 43 | let stop = false 44 | setFiltered( 45 | videos.sort(getSort('date')).filter(video => { 46 | if (localSettings.disable_search_filter) return true 47 | 48 | const website = initialData.find(wsite => wsite.label === video.website) 49 | 50 | if (website !== undefined && website.count-- > 1) { 51 | return true 52 | } 53 | 54 | if (video.categories.length === 0 && !stop) { 55 | const isNewWebsite = map.get(video.website) === undefined 56 | 57 | if (isNewWebsite && map.size >= 3) { 58 | // 3rd website found 59 | stop = true 60 | } 61 | 62 | if (!stop || !isNewWebsite) { 63 | // add or increment website 64 | map.set(video.website, (map.get(video.website) ?? 0) + 1) 65 | } 66 | } 67 | 68 | return false 69 | }) 70 | ) 71 | 72 | setData([...map].map(([key, value]) => ({ label: key, count: value }))) 73 | }, [localSettings.disable_search_filter, localWebsites, videos]) 74 | 75 | if (isLoading || videos === undefined) return 76 | 77 | const visible = filtered 78 | .sort(getSort(sort)) 79 | .filter(v => v.name.toLowerCase().includes(query.toLowerCase()) || isDefault(query, defaultObj.query)) 80 | .filter( 81 | v => category.split(',').every(cat => v.categories.includes(cat)) || isDefault(category, defaultObj.category) 82 | ) 83 | .filter( 84 | v => 85 | (nullCategory !== defaultObj.nullCategory && v.categories.length === 0) || 86 | isDefault(nullCategory, defaultObj.nullCategory) 87 | ) 88 | .filter( 89 | v => attribute.split(',').every(attr => v.attributes.includes(attr)) || isDefault(attribute, defaultObj.attribute) 90 | ) 91 | .filter( 92 | v => location.split(',').every(loc => v.locations.includes(loc)) || isDefault(location, defaultObj.location) 93 | ) 94 | .filter(v => v.website === website || isDefault(website, defaultObj.website)) 95 | .filter(v => v.site === site || isDefault(site, defaultObj.site)) 96 | 97 | return ( 98 |
99 | 100 | {visible.length} Videos 101 | 102 | 103 | {data.length > 0 && ( 104 | 105 | {data.map((item, idx) => ( 106 | 107 | {idx > 0 && ' - '} 108 | {item.label}: {item.count} 109 | 110 | ))} 111 | 112 | )} 113 | 114 | } /> 115 |
116 | ) 117 | } 118 | 119 | type VideoCardProps = { 120 | video?: VideoSearch 121 | } 122 | function VideoCard({ video }: VideoCardProps) { 123 | if (video === undefined) return null 124 | 125 | return ( 126 | 127 | 128 | 129 | 130 | {video.image === null ? ( 131 | 132 | ) : ( 133 | video 138 | )} 139 | 140 | 141 | 142 | {video.name} 143 | 144 | 145 | 146 | 147 | 148 | 149 | ) 150 | } 151 | -------------------------------------------------------------------------------- /Client/src/routes/video/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, Grid, RadioGroup, SelectChangeEvent, TextField } from '@mui/material' 2 | 3 | import { createFileRoute } from '@tanstack/react-router' 4 | import ScrollToTop from 'react-scroll-to-top' 5 | 6 | import { RegularHandlerProps } from '@/components/indeterminate' 7 | import { FilterCheckbox, FilterDropdown, isDefault } from '@/components/search/filter' 8 | import { SortObjVideo as SortObj, defaultVideoObj as defaultObj, getSortString } from '@/components/search/sort' 9 | import Spinner from '@/components/spinner' 10 | 11 | import Videos from './-videos' 12 | 13 | import { useAllSearchParams, useDynamicSearchParam, useSearchParam } from '@/hooks/search' 14 | import useFocus from '@/hooks/useFocus' 15 | import { General } from '@/interface' 16 | import { attributeService, categoryService, locationService, websiteService } from '@/service' 17 | 18 | import styles from './search.module.scss' 19 | 20 | export const Route = createFileRoute('/video/search/')({ 21 | component: () => ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | }) 37 | 38 | function TitleSearch() { 39 | const { setParam, update } = useDynamicSearchParam(defaultObj) 40 | const { currentValue } = useSearchParam(defaultObj, 'query') 41 | 42 | const ref = useFocus(currentValue) 43 | 44 | const callback = (e: React.ChangeEvent) => { 45 | setParam('query', e.target.value) 46 | update() 47 | } 48 | 49 | return 50 | } 51 | 52 | function Sort() { 53 | const { setParam, update } = useDynamicSearchParam(defaultObj) 54 | const { sort } = useAllSearchParams(defaultObj) 55 | 56 | const sortAlphabetical = (reverse = false) => { 57 | if (reverse) { 58 | setParam('sort', '-alphabetical') 59 | } else { 60 | setParam('sort', 'alphabetical') 61 | } 62 | update() 63 | } 64 | 65 | const sortAdded = (reverse = false) => { 66 | if (reverse) { 67 | setParam('sort', '-added') 68 | } else { 69 | setParam('sort', 'added') 70 | } 71 | update() 72 | } 73 | 74 | const sortDate = (reverse = false) => { 75 | if (reverse) { 76 | setParam('sort', '-date') 77 | } else { 78 | setParam('sort', 'date') 79 | } 80 | update() 81 | } 82 | 83 | const sortAge = (reverse = false) => { 84 | if (reverse) { 85 | setParam('sort', '-age') 86 | } else { 87 | setParam('sort', 'age') 88 | } 89 | update() 90 | } 91 | 92 | const sortPlays = (reverse = false) => { 93 | if (reverse) { 94 | setParam('sort', '-plays') 95 | } else { 96 | setParam('sort', 'plays') 97 | } 98 | update() 99 | } 100 | 101 | const sortTitleLength = (reverse = false) => { 102 | if (reverse) { 103 | setParam('sort', '-title-length') 104 | } else { 105 | setParam('sort', 'title-length') 106 | } 107 | update() 108 | } 109 | 110 | return ( 111 | <> 112 |

Sort

113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | ) 126 | } 127 | 128 | function Filter() { 129 | const { setParam, update } = useDynamicSearchParam(defaultObj) 130 | const { 131 | category: categoryParam, 132 | attribute: attributeParam, 133 | location: locationParam, 134 | nullCategory: nullCategoryParam, 135 | website: websiteParam 136 | } = useAllSearchParams(defaultObj) 137 | 138 | const { data: websites } = websiteService.useAll() 139 | const { data: attributes } = attributeService.useAll() 140 | const { data: locations } = locationService.useAll() 141 | const { data: categories } = categoryService.useAll() 142 | 143 | const category = (ref: RegularHandlerProps, target: General) => { 144 | const value = target.name 145 | 146 | if (isDefault(categoryParam, defaultObj.category)) { 147 | setParam('category', value) 148 | } else { 149 | const urlParam = categoryParam.split(',') 150 | 151 | if (!ref.checked) { 152 | const filtered = urlParam.filter(category => category !== value) 153 | setParam('category', filtered.toString()) 154 | } else { 155 | const merged = [...urlParam, value] 156 | setParam('category', merged.toString()) 157 | } 158 | } 159 | update() 160 | } 161 | 162 | const attribute = (ref: RegularHandlerProps, target: General) => { 163 | const value = target.name 164 | 165 | if (isDefault(attributeParam, defaultObj.attribute)) { 166 | setParam('attribute', value) 167 | } else { 168 | const urlParam = attributeParam.split(',') 169 | 170 | if (!ref.checked) { 171 | const filtered = urlParam.filter(attribute => attribute !== value) 172 | setParam('attribute', filtered.toString()) 173 | } else { 174 | const merged = [...urlParam, value] 175 | setParam('attribute', merged.toString()) 176 | } 177 | } 178 | update() 179 | } 180 | 181 | const location = (ref: RegularHandlerProps, target: General) => { 182 | const value = target.name 183 | 184 | if (isDefault(locationParam, defaultObj.location)) { 185 | setParam('location', value) 186 | } else { 187 | const urlParam = locationParam.split(',') 188 | 189 | if (!ref.checked) { 190 | const filtered = urlParam.filter(location => location !== value) 191 | setParam('location', filtered.toString()) 192 | } else { 193 | const merged = [...urlParam, value] 194 | setParam('location', merged.toString()) 195 | } 196 | } 197 | update() 198 | } 199 | 200 | const website_DROP = (e: SelectChangeEvent) => { 201 | const value = e.target.value 202 | 203 | setParam('website', value) 204 | setParam('site', defaultObj.site) 205 | update() 206 | } 207 | 208 | const site_DROP = (e: SelectChangeEvent) => { 209 | const value = e.target.value 210 | 211 | setParam('site', value) 212 | update() 213 | } 214 | 215 | const category_NULL = (ref: RegularHandlerProps) => { 216 | if (!ref.checked) { 217 | setParam('nullCategory', defaultObj.nullCategory) 218 | } else { 219 | setParam('nullCategory', '1') 220 | } 221 | update() 222 | } 223 | 224 | const sites = websites?.find(website => website.name === websiteParam)?.sites ?? [] 225 | 226 | return ( 227 | <> 228 | 229 | 230 | {sites.length > 0 && } 231 | 232 | 233 | {categories === undefined || attributes === undefined || locations === undefined ? ( 234 | 235 | ) : ( 236 | <> 237 | 245 | 246 | 247 | 248 | 249 | )} 250 | 251 | ) 252 | } 253 | -------------------------------------------------------------------------------- /Client/src/routes/video/search/search.module.scss: -------------------------------------------------------------------------------- 1 | #sidebar { 2 | h2 { 3 | margin-bottom: 0; 4 | } 5 | 6 | .global { 7 | text-decoration: underline; 8 | } 9 | } 10 | 11 | #videos { 12 | #count { 13 | color: blue; 14 | } 15 | 16 | .video { 17 | margin: 5px; 18 | 19 | min-width: var(--thumb-width); 20 | width: min-content; 21 | img { 22 | height: auto; 23 | } 24 | 25 | .title { 26 | padding: 0 4px; 27 | align-items: center; 28 | height: 24px * 3; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Client/src/routes/video/video.module.css: -------------------------------------------------------------------------------- 1 | #sidebar { 2 | margin-top: 5.45em; 3 | 4 | #stars { 5 | text-align: center; 6 | 7 | .star { 8 | margin: 0 auto; 9 | width: 70%; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Client/src/service/attribute.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 2 | 3 | import { createApi } from '@/config' 4 | import { General } from '@/interface' 5 | import { keys } from '@/keys' 6 | 7 | const { api } = createApi('/attribute') 8 | 9 | export default { 10 | useRemoveVideo: (videoId: number) => { 11 | const queryClient = useQueryClient() 12 | 13 | const { mutate } = useMutation({ 14 | mutationKey: ['video', videoId, 'removeAttribute'], 15 | mutationFn: ({ attribute }) => api.delete(`/${attribute.id}/${videoId}`), 16 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(videoId)._ctx.attribute }) 17 | }) 18 | 19 | return { mutate } 20 | }, 21 | useAll: () => { 22 | const query = useQuery({ 23 | ...keys.attribute.all, 24 | queryFn: () => api.get('') 25 | }) 26 | 27 | return { data: query.data } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Client/src/service/bookmark.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | 3 | import { createApi } from '@/config' 4 | import { keys } from '@/keys' 5 | 6 | const { api } = createApi('/bookmark') 7 | 8 | export default { 9 | useSetTime: (videoId: number, id: number) => { 10 | const queryClient = useQueryClient() 11 | 12 | const { mutate } = useMutation({ 13 | mutationKey: ['bookmark', id, 'setTime'], 14 | mutationFn: payload => api.put(`/${id}`, payload), 15 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(videoId)._ctx.bookmark }) 16 | }) 17 | 18 | return { mutate } 19 | }, 20 | useDeleteBookmark: (videoId: number, id: number) => { 21 | const queryClient = useQueryClient() 22 | 23 | const { mutate } = useMutation({ 24 | mutationKey: ['bookmark', id, 'delete'], 25 | mutationFn: () => api.delete(`/${id}`), 26 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(videoId)._ctx.bookmark }) 27 | }) 28 | 29 | return { mutate } 30 | }, 31 | useSetCategory: (videoId: number, id: number) => { 32 | const queryClient = useQueryClient() 33 | 34 | const { mutate } = useMutation({ 35 | mutationKey: ['bookmark', 'setCategory'], 36 | mutationFn: payload => api.put(`/${id}`, payload), 37 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(videoId)._ctx.bookmark }) 38 | }) 39 | 40 | return { mutate } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Client/src/service/category.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | 3 | import { createApi } from '@/config' 4 | import { General } from '@/interface' 5 | import { keys } from '@/keys' 6 | 7 | const { api } = createApi('/category') 8 | 9 | export default { 10 | useAll: () => { 11 | const query = useQuery({ 12 | ...keys.category.all, 13 | queryFn: () => api.get('') 14 | }) 15 | 16 | return { data: query.data } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Client/src/service/generate.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from '@/config' 2 | 3 | const { legacyApi } = createApi('/generate') 4 | 5 | export default { 6 | metadata: () => legacyApi.post('/meta'), 7 | vtt: () => legacyApi.post('/vtt'), 8 | thumb: () => legacyApi.post('/thumb') 9 | } 10 | -------------------------------------------------------------------------------- /Client/src/service/home.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | 3 | import { createApi } from '@/config' 4 | import { keys } from '@/keys' 5 | 6 | const { api } = createApi('/home') 7 | 8 | type Video = { 9 | id: number 10 | name: string 11 | image: string | null 12 | total?: number 13 | } 14 | 15 | export default { 16 | useVideos: (label: string, limit: number) => { 17 | const query = useQuery({ 18 | ...keys.video.home(label), 19 | queryFn: () => api.get(`/${label}/${limit}`) 20 | }) 21 | 22 | return { data: query.data } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Client/src/service/index.ts: -------------------------------------------------------------------------------- 1 | export { default as attributeService } from './attribute' 2 | export { default as bookmarkService } from './bookmark' 3 | export { default as generateService } from './generate' 4 | export { default as locationService } from './location' 5 | export { default as searchService } from './search' 6 | export { default as starService } from './star' 7 | export { default as videoService } from './video' 8 | export { default as homeService } from './home' 9 | export { default as websiteService } from './website' 10 | export { default as categoryService } from './category' 11 | -------------------------------------------------------------------------------- /Client/src/service/location.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 2 | 3 | import { createApi } from '@/config' 4 | import { General } from '@/interface' 5 | import { keys } from '@/keys' 6 | 7 | const { api } = createApi('/location') 8 | 9 | export default { 10 | useRemoveVideo: (videoId: number) => { 11 | const queryClient = useQueryClient() 12 | 13 | const { mutate } = useMutation({ 14 | mutationKey: ['video', videoId, 'removeLocation'], 15 | mutationFn: ({ location }) => api.delete(`/${location.id}/${videoId}`), 16 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(videoId)._ctx.location }) 17 | }) 18 | 19 | return { mutate } 20 | }, 21 | useAll: () => { 22 | const query = useQuery({ 23 | ...keys.location.all, 24 | queryFn: () => api.get('') 25 | }) 26 | 27 | return { data: query.data } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Client/src/service/search.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | 3 | import { createApi } from '@/config' 4 | import { StarSearch, VideoSearch } from '@/interface' 5 | import { keys } from '@/keys' 6 | 7 | const { api } = createApi('/search') 8 | 9 | export default { 10 | useStars: () => { 11 | const query = useQuery({ 12 | ...keys.search.star, 13 | queryFn: () => api.get('/star'), 14 | placeholderData: prevData => prevData 15 | }) 16 | 17 | return { data: query.data, isLoading: query.isFetching } 18 | }, 19 | useVideos: () => { 20 | const query = useQuery({ 21 | ...keys.search.video, 22 | queryFn: () => api.get('/video'), 23 | placeholderData: prevData => prevData 24 | }) 25 | 26 | return { data: query.data, isLoading: query.isFetching } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Client/src/service/star.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 2 | 3 | import { createApi } from '@/config' 4 | import { Missing, Similar, Star, StarVideo } from '@/interface' 5 | import { keys } from '@/keys' 6 | 7 | const { api } = createApi('/star') 8 | 9 | type StarInfo = { 10 | breast: string[] 11 | haircolor: string[] 12 | ethnicity: string[] 13 | } 14 | 15 | export default { 16 | useAddStar: () => { 17 | const queryClient = useQueryClient() 18 | 19 | type Payload = { name: string } 20 | const { mutate: mutateSync, mutateAsync } = useMutation({ 21 | mutationKey: ['star', 'add'], 22 | mutationFn: payload => api.post('/', payload) 23 | }) 24 | 25 | const mutate = (payload: Payload) => { 26 | mutateSync(payload, { 27 | onSuccess: () => { 28 | queryClient.invalidateQueries(keys.star.all) 29 | } 30 | }) 31 | } 32 | 33 | const mutateAll = (payloads: Payload[]) => { 34 | Promise.allSettled(payloads.map(payload => mutateAsync(payload))).then(() => { 35 | queryClient.invalidateQueries(keys.star.all) 36 | }) 37 | } 38 | 39 | return { mutate, mutateAll } 40 | }, 41 | remove: (id: number) => api.delete(`/${id}`), 42 | renameStar: (id: number, name: string) => api.put(`/${id}`, { name }), 43 | setSlug: (id: number, slug: string) => api.put(`/${id}`, { slug }), 44 | ignoreStar: (star: T) => { 45 | return api.put(`/${star.id}`, { ignore: !star.ignored }) 46 | }, 47 | removeImage: (id: number) => api.delete(`/${id}/image`), 48 | useAddImage: (id: number) => { 49 | const queryClient = useQueryClient() 50 | 51 | const { mutate } = useMutation({ 52 | mutationKey: ['star', id, 'addImage'], 53 | mutationFn: payload => api.post(`/${id}/image`, payload), 54 | onSuccess: () => queryClient.invalidateQueries({ ...keys.star.byId(id) }) 55 | }) 56 | 57 | return { mutate } 58 | }, 59 | getImages: (id: number) => api.post<{ images: string[] }>(`/${id}/api/image`), 60 | useAddHaircolor: (id: number) => { 61 | const queryClient = useQueryClient() 62 | 63 | const { mutate } = useMutation({ 64 | mutationKey: ['star', id, 'addHaircolor'], 65 | mutationFn: payload => api.put(`/${id}/haircolor`, payload), 66 | onSuccess: () => queryClient.invalidateQueries({ ...keys.star.byId(id) }) 67 | }) 68 | 69 | return { mutate } 70 | }, 71 | removeHaircolor: (id: number, name: string) => api.put(`/${id}/haircolor`, { name, remove: true }), 72 | updateInfo: (id: number, label: string, value: string) => { 73 | return api.put<{ reload: boolean; content: string | Date | number | null; similar: Similar[] }>(`/${id}`, { 74 | label, 75 | value 76 | }) 77 | }, 78 | resetInfo: (id: number) => api.delete(`/${id}/api`), 79 | getData: (id: number) => api.post(`/${id}/api`), 80 | useInfo: () => { 81 | const query = useQuery({ 82 | ...keys.star.info, 83 | queryFn: () => api.get('/info') 84 | }) 85 | 86 | return { data: query.data } 87 | }, 88 | useAll: () => { 89 | type Star = { 90 | id: number 91 | name: string 92 | image: string | null 93 | } 94 | 95 | const query = useQuery<{ stars: Star[]; missing: Missing[] }>({ 96 | ...keys.star.all, 97 | queryFn: () => api.get('') 98 | }) 99 | 100 | return { data: query.data } 101 | }, 102 | useVideos: (id: number) => { 103 | const query = useQuery({ 104 | ...keys.star.byId(id)._ctx.video, 105 | queryFn: () => api.get(`/${id}/video`) 106 | }) 107 | 108 | return { data: query.data } 109 | }, 110 | useStar: (id: number) => { 111 | const query = useQuery({ 112 | ...keys.star.byId(id), 113 | queryFn: () => api.get(`/${id}`) 114 | }) 115 | 116 | return { data: query.data } 117 | }, 118 | useSetRetired: (id: number) => { 119 | const queryClient = useQueryClient() 120 | 121 | const { mutate } = useMutation({ 122 | mutationKey: ['star', id, 'retired'], 123 | mutationFn: payload => api.put(`/${id}`, payload), 124 | onSuccess: () => queryClient.invalidateQueries({ ...keys.star.byId(id) }) 125 | }) 126 | 127 | return { mutate } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Client/src/service/video.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 2 | 3 | import { createApi } from '@/config' 4 | import useOptimistic from '@/hooks/useOptimistic' 5 | import { Bookmark, File, General, Video, VideoStar } from '@/interface' 6 | import { keys } from '@/keys' 7 | 8 | const { api } = createApi('/video') 9 | 10 | export default { 11 | useAddBookmark: (id: number) => { 12 | const queryClient = useQueryClient() 13 | 14 | const { mutate } = useMutation>({ 15 | mutationKey: ['video', id, 'bookmark', 'add'], 16 | mutationFn: ({ category, start }) => api.post(`/${id}/bookmark`, { categoryID: category.id, time: start }), 17 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(id)._ctx.bookmark }) 18 | }) 19 | 20 | return { mutate } 21 | }, 22 | useAddStar: (id: number) => { 23 | const queryClient = useQueryClient() 24 | 25 | const { mutate } = useMutation({ 26 | mutationKey: ['video', id, 'star', 'add'], 27 | mutationFn: payload => api.post(`/${id}/star`, payload), 28 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(id)._ctx.star }) 29 | }) 30 | 31 | return { mutate } 32 | }, 33 | addStar: (id: number, star: string) => api.post(`/${id}/star`, { name: star }), 34 | removeStar: (id: number) => api.delete(`/${id}/star`), 35 | useRemoveStar: (id: number) => { 36 | const queryClient = useQueryClient() 37 | 38 | const { mutate } = useMutation({ 39 | mutationKey: ['video', id, 'star', 'remove'], 40 | mutationFn: () => api.delete(`/${id}/star`), 41 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(id)._ctx.star }) 42 | }) 43 | 44 | return { mutate } 45 | }, 46 | useLocations: (id: number) => { 47 | const query = useQuery({ 48 | ...keys.video.byId(id)._ctx.location, 49 | queryFn: () => api.get(`/${id}/location`) 50 | }) 51 | 52 | const optimisticAdd = useOptimistic<{ location: General }>({ 53 | mutationKey: ['video', id, 'location', 'add'] 54 | }) 55 | 56 | const optimisticDelete = useOptimistic<{ location: General }>({ 57 | mutationKey: ['video', id, 'removeLocation'] 58 | }) 59 | 60 | return { data: query.data, optimisticAdd, optimisticDelete } 61 | }, 62 | useAddLocation: (id: number) => { 63 | const queryClient = useQueryClient() 64 | 65 | const { mutate } = useMutation({ 66 | mutationKey: ['video', id, 'location', 'add'], 67 | mutationFn: ({ location }) => api.post(`/${id}/location`, { locationID: location.id }), 68 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(id)._ctx.location }) 69 | }) 70 | 71 | return { mutate } 72 | }, 73 | useAttributes: (id: number) => { 74 | const query = useQuery({ 75 | ...keys.video.byId(id)._ctx.attribute, 76 | queryFn: () => api.get(`/${id}/attribute`) 77 | }) 78 | 79 | const optimisticAdd = useOptimistic<{ attribute: General }>({ 80 | mutationKey: ['video', id, 'attribute', 'add'] 81 | }) 82 | 83 | const optimisticDelete = useOptimistic<{ attribute: General }>({ 84 | mutationKey: ['video', id, 'removeAttribute'] 85 | }) 86 | 87 | return { data: query.data, optimisticAdd, optimisticDelete } 88 | }, 89 | useAddAttribute: (id: number) => { 90 | const queryClient = useQueryClient() 91 | 92 | const { mutate } = useMutation({ 93 | mutationKey: ['video', id, 'attribute', 'add'], 94 | mutationFn: ({ attribute }) => api.post(`/${id}/attribute`, { attributeID: attribute.id }), 95 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(id)._ctx.attribute }) 96 | }) 97 | 98 | return { mutate } 99 | }, 100 | fixDate: (id: number) => api.put(`/${id}/fix-date`), 101 | renameTitle: (id: number, title: string) => api.put(`/${id}`, { title }), 102 | useRenameTitle: (id: number) => { 103 | const queryClient = useQueryClient() 104 | 105 | const { mutate } = useMutation({ 106 | mutationKey: ['video', 'rename', 'title'], 107 | mutationFn: payload => api.put(`/${id}`, { title: payload.title }), 108 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(id) }) 109 | }) 110 | 111 | return { mutate } 112 | }, 113 | getSlugs: (id: number) => { 114 | return api.get< 115 | { 116 | id: string 117 | title: string 118 | image: string 119 | site: string 120 | date: string 121 | }[] 122 | >(`/${id}/meta`) 123 | }, 124 | setSlug: (id: number, slug: string) => api.put(`/${id}`, { slug }), 125 | addPlay: (id: number) => api.put(`/${id}`, { plays: 1 }), 126 | delete: (id: number) => api.delete(`/${id}`), 127 | useClearBookmarks: (id: number) => { 128 | const queryClient = useQueryClient() 129 | 130 | const { mutate } = useMutation({ 131 | mutationKey: ['video', id, 'bookmark', 'clear'], 132 | mutationFn: () => api.delete(`/${id}/bookmark`), 133 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.byId(id)._ctx.bookmark }) 134 | }) 135 | 136 | return { mutate } 137 | }, 138 | removePlays: (id: number) => api.put(`/${id}`, { plays: 0 }), 139 | rename: (id: number, path: string) => api.put(`/${id}`, { path }), 140 | setThumbnail: (id: number) => api.put(`/${id}`, { cover: true }), 141 | validateTitle: (id: number) => api.put(`/${id}`, { validated: true }), 142 | getVideoInfo: (id: number) => { 143 | return api.get<{ 144 | id: string 145 | title: string 146 | date: string 147 | image: string 148 | }>(`/${id}/star/info`) 149 | }, 150 | useAddVideos: () => { 151 | const queryClient = useQueryClient() 152 | 153 | const { mutate, isPending } = useMutation({ 154 | mutationKey: ['video', 'new', 'add'], 155 | mutationFn: payload => api.post('/add', payload), 156 | onSuccess: () => queryClient.invalidateQueries({ ...keys.video.new }) 157 | }) 158 | 159 | return { mutate, isPending } 160 | }, 161 | useNew: (limit?: number) => { 162 | const query = useQuery<{ files: File[]; pages: number }>({ 163 | ...keys.video.new, 164 | queryFn: () => api.get('/add' + (limit ? `/${limit}` : '')) 165 | }) 166 | 167 | return { data: query.data, isLoading: query.isFetching } 168 | }, 169 | useBookmarks: (id: number) => { 170 | const query = useQuery({ 171 | ...keys.video.byId(id)._ctx.bookmark, 172 | queryFn: () => api.get(`/${id}/bookmark`) 173 | }) 174 | 175 | const optimisticAdd = useOptimistic>({ 176 | mutationKey: ['video', id, 'bookmark', 'add'] 177 | }) 178 | 179 | //TODO optimisticDelete require bookmark-id 180 | return { data: query.data, optimisticAdd, optimisticDelete: null } 181 | }, 182 | useVideo: (id: number) => { 183 | const query = useQuery