Compare commits
162 Commits
493ddd7e78
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b842106e44 | |||
| 9abcd715d7 | |||
| ab0af9a773 | |||
| fbd2efa994 | |||
| 79932157bf | |||
| 04b0ec1a71 | |||
| cc693d8be7 | |||
| 52aa00dd13 | |||
| 8085b40af8 | |||
| 5f40a812d3 | |||
| 1b724e86c9 | |||
| a9e4ed6049 | |||
| 66179d7ba8 | |||
| 3a8fa7d8ce | |||
| fddc3f15d0 | |||
| d9a60f0572 | |||
| ba648c796a | |||
| 27e2ff5f66 | |||
| b7a29c55b3 | |||
| 99b2ed7f2b | |||
| 8357aecf98 | |||
| ab3d9f4118 | |||
| 5219fae36a | |||
| 7de1bf7a03 | |||
| a4fd1ff18b | |||
| 6605980a43 | |||
| 15d9708072 | |||
| 89c4c390fa | |||
| f5ff59b910 | |||
| fc97c1b84b | |||
| e2abb0794a | |||
| 2644e033b4 | |||
| ee1cea6d01 | |||
| 1496399b96 | |||
| 075f64f4e3 | |||
| 8c6c98d612 | |||
| 28be084781 | |||
| 21b8d2c223 | |||
| b315062d43 | |||
| 5bef996dbc | |||
| da2484d232 | |||
| 722392d19e | |||
| a07a5cb091 | |||
| ea23233645 | |||
| 6dcdc0130b | |||
| 8508e1f6e9 | |||
| 6abcfc7363 | |||
| d4b3968518 | |||
| 8f4999f127 | |||
| 4b53a25fa3 | |||
| 4f85637875 | |||
| 1175b4d0e6 | |||
| 2afa3c6e9b | |||
| b55cebea4e | |||
| 9845553d49 | |||
| ced0a08da3 | |||
| f880aa5957 | |||
| 239128bf5e | |||
| 0a50c3efd8 | |||
| af4a11b73c | |||
| 627ce75719 | |||
| 446e9f835b | |||
| 422f97417e | |||
| edee98b552 | |||
| b9b98f178f | |||
| dc1850126b | |||
| 4d81266cb1 | |||
| 2980c0b637 | |||
| 7af9c0d7ca | |||
| 76d71ee7c3 | |||
| 90497e9e7c | |||
| a558449964 | |||
| e236ced12a | |||
| 8313664d70 | |||
| ae0929ad06 | |||
| b78831231d | |||
| f90b045ca5 | |||
| d2cbb1004f | |||
| 77ebccf6fa | |||
| 1c101406f6 | |||
| cb7720ca9c | |||
| df099b2700 | |||
| 291f72381f | |||
| 1a2fab3e37 | |||
| 56b57486dc | |||
| a050e886cb | |||
| 519fd45d8d | |||
| 0592d27a15 | |||
| a38883e631 | |||
| 798495c3d6 | |||
| fde0d63271 | |||
| 754a236e51 | |||
| dfe49b5882 | |||
| 9ba848372a | |||
| dcf2fbd3d4 | |||
| bff354094e | |||
| 6f2f3b3529 | |||
| f2871b98db | |||
| 9c5dba5c90 | |||
| c90c09da9a | |||
| aed7b4a16f | |||
| 454c477c40 | |||
| 3cf81bd381 | |||
| ac63e59906 | |||
| 19d29cbfc6 | |||
| 0ec27117ae | |||
| ed9eb6ef22 | |||
| 609f116b5d | |||
| e943876e70 | |||
| 7d373b3aa3 | |||
| 95fd9f48fc | |||
| 670c18bcb7 | |||
| 9ef490c1e5 | |||
| 434e926f77 | |||
| 7a9ce0c3b1 | |||
| ff1e1f6679 | |||
| 648123fab5 | |||
| a7fafaf7c5 | |||
| b71d7dc559 | |||
| f764e27d59 | |||
| d7eb2acc6c | |||
| fb38d6b9a9 | |||
| d021acaf0b | |||
| e06a1915f2 | |||
| ebab3405b1 | |||
| ad7ceee5f8 | |||
| c1770ab9c9 | |||
| b200498a10 | |||
| 1369d5c228 | |||
| e200514347 | |||
| d7057c3681 | |||
| d820a8f6be | |||
| 9bef2469d1 | |||
| 97269788ee | |||
| c6126c13e9 | |||
| fd4050a49f | |||
| efc7624ba3 | |||
| 18116072c9 | |||
| 741e0c3387 | |||
| 662e3e8fe2 | |||
| fa159feffa | |||
| 124f0bfb22 | |||
| df89cc59f5 | |||
| 845e3df223 | |||
| 05cb6a66e3 | |||
| 273aa42510 | |||
| 1e930baccb | |||
| 012bb176d9 | |||
| ed7ac0c573 | |||
| 4565038be3 | |||
| fbafbeca5d | |||
| 480369aa4e | |||
| ceb57ec1c4 | |||
| 4f8271217c | |||
| 046689e363 | |||
| 9ba71239b7 | |||
| 757bbe9e3b | |||
| 73f7a4f2f0 | |||
| 3bd8d95576 | |||
| 14e816241d | |||
| 4102f9990c | |||
| 2565e6c28b |
68
.gitea/workflows/docker-build-backend.yml
Normal file
68
.gitea/workflows/docker-build-backend.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Build and Push Backend Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "packages/backend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile.backend"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/backend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile.backend"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: dev.pivoine.art
|
||||
IMAGE_NAME: valknar/sexy-backend
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.backend
|
||||
platforms: linux/amd64
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
68
.gitea/workflows/docker-build-buttplug.yml
Normal file
68
.gitea/workflows/docker-build-buttplug.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Build and Push Buttplug Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "packages/buttplug/**"
|
||||
- "Dockerfile.buttplug"
|
||||
- "nginx.buttplug.conf"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/buttplug/**"
|
||||
- "Dockerfile.buttplug"
|
||||
- "nginx.buttplug.conf"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: dev.pivoine.art
|
||||
IMAGE_NAME: valknar/sexy-buttplug
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.buttplug
|
||||
platforms: linux/amd64
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
68
.gitea/workflows/docker-build-frontend.yml
Normal file
68
.gitea/workflows/docker-build-frontend.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Build and Push Frontend Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "packages/frontend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/frontend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: dev.pivoine.art
|
||||
IMAGE_NAME: valknar/sexy
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
@@ -1,159 +0,0 @@
|
||||
name: Build and Push Docker Image to Gitea
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Custom tag for the image'
|
||||
required: false
|
||||
default: 'manual'
|
||||
|
||||
env:
|
||||
REGISTRY: dev.pivoine.art
|
||||
IMAGE_NAME: valknar/sexy
|
||||
BACKEND_IMAGE_NAME: valknar/sexy-backend
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Tag as 'latest' for main branch
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
# Tag with branch name
|
||||
type=ref,event=branch
|
||||
# Tag with PR number
|
||||
type=ref,event=pr
|
||||
# Tag with git tag (semver)
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Tag with commit SHA
|
||||
type=sha,prefix={{branch}}-
|
||||
# Custom tag from workflow_dispatch
|
||||
type=raw,value=${{ gitea.event.inputs.tag }},enable=${{ gitea.event_name == 'workflow_dispatch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=sexy.pivoine.art
|
||||
org.opencontainers.image.description=Adult content platform with SvelteKit, Directus, and hardware integration
|
||||
org.opencontainers.image.vendor=valknar
|
||||
org.opencontainers.image.source=https://dev.pivoine.art/${{ gitea.repository }}
|
||||
|
||||
- name: Build and push frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
build-args: |
|
||||
NODE_ENV=production
|
||||
CI=true
|
||||
|
||||
- name: Extract metadata for backend image
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=${{ gitea.event.inputs.tag }},enable=${{ gitea.event_name == 'workflow_dispatch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=sexy.pivoine.art backend
|
||||
org.opencontainers.image.description=GraphQL backend for sexy.pivoine.art (Fastify + Drizzle + Pothos)
|
||||
org.opencontainers.image.vendor=valknar
|
||||
org.opencontainers.image.source=https://dev.pivoine.art/${{ gitea.repository }}
|
||||
|
||||
- name: Build and push backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
platforms: linux/amd64
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache,mode=max
|
||||
build-args: |
|
||||
NODE_ENV=production
|
||||
CI=true
|
||||
|
||||
- name: Generate image digest
|
||||
if: gitea.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "### Docker Images Published :rocket:" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "**Registry:** \`${{ env.REGISTRY }}\`" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "**Frontend (\`${{ env.IMAGE_NAME }}\`):**" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
echo "${{ steps.meta.outputs.tags }}" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "**Backend (\`${{ env.BACKEND_IMAGE_NAME }}\`):**" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
echo "${{ steps.meta-backend.outputs.tags }}" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "**Pull commands:**" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`bash" >> $GITEA_STEP_SUMMARY
|
||||
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITEA_STEP_SUMMARY
|
||||
echo "docker pull ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
|
||||
- name: PR Comment - Images built but not pushed
|
||||
if: gitea.event_name == 'pull_request'
|
||||
run: |
|
||||
echo "### Docker Images Built Successfully :white_check_mark:" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "Images were built successfully but **not pushed** (PR builds are not published)." >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "**Frontend would be tagged as:**" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
echo "${{ steps.meta.outputs.tags }}" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "**Backend would be tagged as:**" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
echo "${{ steps.meta-backend.outputs.tags }}" >> $GITEA_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITEA_STEP_SUMMARY
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ target/
|
||||
pkg/
|
||||
|
||||
.claude/
|
||||
.data/
|
||||
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
build/
|
||||
.svelte-kit/
|
||||
dist/
|
||||
node_modules/
|
||||
migrations/
|
||||
pnpm-lock.yaml
|
||||
241
CLAUDE.md
241
CLAUDE.md
@@ -2,176 +2,93 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
## Overview
|
||||
|
||||
This is a monorepo for an adult content platform built with SvelteKit, Directus CMS, and hardware integration via Buttplug.io. The project uses pnpm workspaces with three main packages.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install Node.js 20.19.1
|
||||
2. Enable corepack: `corepack enable`
|
||||
3. Install dependencies: `pnpm install`
|
||||
4. Install Rust toolchain and wasm-bindgen: `cargo install wasm-bindgen-cli`
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Packages
|
||||
|
||||
- **`packages/frontend`**: SvelteKit application (main frontend)
|
||||
- **`packages/bundle`**: Directus extension bundle (custom endpoints, hooks, themes)
|
||||
- **`packages/buttplug`**: Hardware control library with TypeScript/WebAssembly bindings
|
||||
|
||||
### Frontend (SvelteKit + Tailwind CSS 4)
|
||||
|
||||
- **Framework**: SvelteKit 2 with adapter-node
|
||||
- **Styling**: Tailwind CSS v4 via @tailwindcss/vite
|
||||
- **UI Components**: bits-ui, custom components in `src/lib/components/ui/`
|
||||
- **Backend**: Directus headless CMS
|
||||
- **Routes**: File-based routing in `src/routes/`
|
||||
- `+page.server.ts`: Server-side data loading
|
||||
- `+layout.server.ts`: Layout data (authentication, etc.)
|
||||
- **Authentication**: Session-based via Directus SDK (cookies)
|
||||
- **API Proxy**: Dev server proxies `/api` to `http://localhost:8055` (Directus)
|
||||
- **i18n**: svelte-i18n for internationalization
|
||||
|
||||
Key files:
|
||||
- `src/lib/directus.ts`: Directus client configuration
|
||||
- `src/lib/types.ts`: Shared TypeScript types
|
||||
- `src/hooks.server.ts`: Server-side auth middleware
|
||||
- `vite.config.ts`: Dev server on port 3000 with API proxy
|
||||
|
||||
### Bundle (Directus Extensions)
|
||||
|
||||
Custom Directus extensions providing:
|
||||
- **Endpoint** (`src/endpoint/index.ts`): `/sexy/stats` endpoint for platform statistics
|
||||
- **Hook** (`src/hook/index.ts`):
|
||||
- Auto-generates slugs for users based on artist_name
|
||||
- Processes uploaded videos with ffmpeg to extract duration
|
||||
- **Theme** (`src/theme/index.ts`): Custom Directus admin theme
|
||||
|
||||
### Buttplug (Hardware Control)
|
||||
|
||||
Hybrid TypeScript/Rust package for intimate hardware control:
|
||||
- **TypeScript**: Client library, connectors (WebSocket, Browser WebSocket)
|
||||
- **Rust/WASM**: Core buttplug implementation compiled to WebAssembly
|
||||
- Provides browser-based Bluetooth device control via WebBluetooth API
|
||||
|
||||
Key concepts:
|
||||
- `ButtplugClient`: Main client interface
|
||||
- `ButtplugClientDevice`: Device abstraction
|
||||
- `ButtplugWasmClientConnector`: WASM-based connector
|
||||
- Messages defined in `src/core/Messages.ts`
|
||||
`sexy.pivoine.art` is a self-hosted adult content platform (18+) built as a pnpm monorepo with three packages: `frontend` (SvelteKit 5), `backend` (Fastify + GraphQL), and `buttplug` (hardware integration via WebBluetooth/WASM).
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Development
|
||||
Run from the repo root unless otherwise noted.
|
||||
|
||||
Start full development environment (data + Directus + frontend):
|
||||
```bash
|
||||
pnpm dev
|
||||
# Development
|
||||
pnpm dev:data # Start postgres & redis via Docker
|
||||
pnpm dev:backend # Start backend on http://localhost:4000
|
||||
pnpm dev # Start backend + frontend (frontend on :3000)
|
||||
|
||||
# Linting & Formatting
|
||||
pnpm lint # ESLint across all packages
|
||||
pnpm lint:fix # Auto-fix ESLint issues
|
||||
pnpm format # Prettier format all files
|
||||
pnpm format:check # Check formatting without changes
|
||||
|
||||
# Build
|
||||
pnpm build:frontend # SvelteKit production build
|
||||
pnpm build:backend # Compile backend TypeScript to dist/
|
||||
|
||||
# Database migrations (from packages/backend/)
|
||||
pnpm migrate # Run pending Drizzle migrations
|
||||
```
|
||||
|
||||
Individual services:
|
||||
## Architecture
|
||||
|
||||
### Monorepo Layout
|
||||
|
||||
```
|
||||
packages/
|
||||
frontend/ # SvelteKit 2 + Svelte 5 + Tailwind CSS 4
|
||||
backend/ # Fastify v5 + GraphQL Yoga v5 + Drizzle ORM
|
||||
buttplug/ # TypeScript/Rust hybrid, compiles to WASM
|
||||
```
|
||||
|
||||
### Backend (`packages/backend/src/`)
|
||||
|
||||
- **`index.ts`** — Fastify server entry: registers plugins (CORS, multipart, static), mounts GraphQL at `/graphql`, serves transformed assets at `/assets/:id`
|
||||
- **`graphql/builder.ts`** — Pothos schema builder (code-first GraphQL)
|
||||
- **`graphql/context.ts`** — Injects `currentUser` from Redis session into every request
|
||||
- **`lib/auth.ts`** — Session management: `nanoid(32)` token stored in Redis with 24h TTL, set as httpOnly cookie
|
||||
- **`db/schema/`** — Drizzle ORM table definitions (users, videos, files, comments, gamification, etc.)
|
||||
- **`migrations/`** — SQL migration files managed by Drizzle Kit
|
||||
|
||||
### Frontend (`packages/frontend/src/`)
|
||||
|
||||
- **`lib/api.ts`** — GraphQL client (graphql-request)
|
||||
- **`lib/services.ts`** — All API calls (login, videos, comments, models, etc.)
|
||||
- **`lib/types.ts`** — Shared TypeScript types
|
||||
- **`hooks.server.ts`** — Auth guard: reads session cookie, fetches `me` query, redirects if needed
|
||||
- **`routes/`** — SvelteKit file-based routing: `/`, `/login`, `/signup`, `/me`, `/models`, `/models/[slug]`, `/videos`, `/play/[slug]`, `/magazine`, `/leaderboard`
|
||||
|
||||
### Asset Pipeline
|
||||
|
||||
Backend serves images with server-side Sharp transforms, cached to disk as WebP. Presets: `mini` (80×80), `thumbnail` (300×300), `preview` (800px wide), `medium` (1400px wide), `banner` (1600×480 cropped).
|
||||
|
||||
### Gamification
|
||||
|
||||
Points + achievements system tracked in `user_points` and `user_stats` tables. Logic in `packages/backend/src/lib/gamification.ts` and the `gamification` resolver.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **TypeScript strict mode** in all packages
|
||||
- **ESLint flat config** (`eslint.config.js` at root) — `any` is allowed but discouraged; enforces consistent type imports
|
||||
- **Prettier**: 2-space indent, trailing commas, 100-char line width, Svelte plugin
|
||||
- Migrations folder (`packages/backend/src/migrations/`) is excluded from lint
|
||||
|
||||
## Environment Variables (Backend)
|
||||
|
||||
| Variable | Purpose |
|
||||
| --------------------------- | ---------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `REDIS_URL` | Redis connection string |
|
||||
| `COOKIE_SECRET` | Session cookie signing |
|
||||
| `CORS_ORIGIN` | Frontend origin URL |
|
||||
| `UPLOAD_DIR` | File storage path |
|
||||
| `SMTP_HOST/PORT/EMAIL_FROM` | Email (Nodemailer) |
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
pnpm dev:data # Start Docker Compose data services
|
||||
pnpm dev:directus # Start Directus in Docker
|
||||
pnpm --filter @sexy.pivoine.art/frontend dev # Frontend dev server only
|
||||
docker compose up -d # Start all services (postgres, redis, backend, frontend)
|
||||
arty up -d <service> # Preferred way to manage containers in this project
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
Build all packages:
|
||||
```bash
|
||||
pnpm install # Ensure dependencies are installed first
|
||||
```
|
||||
|
||||
Build specific packages:
|
||||
```bash
|
||||
pnpm build:frontend # Pulls git, installs, builds frontend
|
||||
pnpm build:bundle # Pulls git, installs, builds Directus extensions
|
||||
```
|
||||
|
||||
Individual package builds:
|
||||
```bash
|
||||
pnpm --filter @sexy.pivoine.art/frontend build
|
||||
pnpm --filter @sexy.pivoine.art/bundle build
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build # TypeScript build
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build:wasm # Rust WASM build
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
Start production frontend server (local):
|
||||
```bash
|
||||
pnpm --filter @sexy.pivoine.art/frontend start
|
||||
```
|
||||
|
||||
Docker Compose deployment (recommended for production):
|
||||
```bash
|
||||
# Local development (with Postgres, Redis, Directus)
|
||||
docker-compose up -d
|
||||
|
||||
# Production (with Traefik, external DB, Redis)
|
||||
docker-compose -f compose.production.yml --env-file .env.production up -d
|
||||
```
|
||||
|
||||
See `COMPOSE.md` for Docker Compose guide and `DOCKER.md` for standalone Docker deployment.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Frontend** → `/api/*` (proxied) → **Directus CMS**
|
||||
2. Directus uses **bundle extensions** for custom logic (stats, video processing, user management)
|
||||
3. Frontend uses **Directus SDK** with session authentication
|
||||
4. Hardware control uses **buttplug package** (TypeScript → WASM → Bluetooth)
|
||||
|
||||
### Authentication
|
||||
|
||||
- Session tokens stored in `directus_session_token` cookie
|
||||
- `hooks.server.ts` validates token on every request via `isAuthenticated()`
|
||||
- User roles: Model, Viewer (checked via role or policy)
|
||||
- `isModel()` helper in `src/lib/directus.ts` checks user permissions
|
||||
|
||||
### Content Types
|
||||
|
||||
Core types in `packages/frontend/src/lib/types.ts`:
|
||||
- **User/CurrentUser**: User profiles with roles and policies
|
||||
- **Video**: Videos with models, tags, premium flag
|
||||
- **Model**: Creator profiles with photos and banner
|
||||
- **Article**: Magazine/blog content
|
||||
- **BluetoothDevice**: Hardware device state
|
||||
|
||||
### Docker Environment
|
||||
|
||||
Development uses Docker Compose in `../compose/` directory:
|
||||
- `../compose/data`: Database/storage services
|
||||
- `../compose/sexy`: Directus instance (uses `.env.local`)
|
||||
|
||||
### Asset URLs
|
||||
|
||||
Assets served via Directus with transforms:
|
||||
```typescript
|
||||
getAssetUrl(id, "thumbnail" | "preview" | "medium" | "banner")
|
||||
// Returns: ${directusApiUrl}/assets/${id}?transform=...
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Ensure Docker services are running: `pnpm dev:data && pnpm dev:directus`
|
||||
2. Start frontend dev server: `pnpm --filter @sexy.pivoine.art/frontend dev`
|
||||
3. Access frontend at `http://localhost:3000`
|
||||
4. Access Directus admin at `http://localhost:8055`
|
||||
|
||||
When modifying:
|
||||
- **Frontend code**: Hot reload via Vite
|
||||
- **Bundle extensions**: Rebuild with `pnpm --filter @sexy.pivoine.art/bundle build` and restart Directus
|
||||
- **Buttplug library**: Rebuild TypeScript (`pnpm build`) and/or WASM (`pnpm build:wasm`)
|
||||
|
||||
## Important Notes
|
||||
|
||||
- This is a pnpm workspace; always use `pnpm` not `npm` or `yarn`
|
||||
- Package manager is locked to `pnpm@10.17.0`
|
||||
- Buttplug package requires Rust toolchain for WASM builds
|
||||
- Frontend uses SvelteKit's adapter-node for production deployment
|
||||
- All TypeScript packages use ES modules (`"type": "module"`)
|
||||
Production images are built and pushed to `dev.pivoine.art` via Gitea Actions on push to `main`.
|
||||
|
||||
49
Dockerfile
49
Dockerfile
@@ -3,7 +3,7 @@
|
||||
# ============================================================================
|
||||
# Base stage - shared dependencies
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS base
|
||||
FROM node:22.14.0-slim AS base
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN npm install -g corepack@latest && corepack enable
|
||||
@@ -20,57 +20,31 @@ RUN mkdir -p ./packages/frontend && \
|
||||
printf 'PUBLIC_API_URL=\nPUBLIC_URL=\nPUBLIC_UMAMI_ID=\nPUBLIC_UMAMI_SCRIPT=\n' > ./packages/frontend/.env
|
||||
|
||||
# ============================================================================
|
||||
# Builder stage - compile application with Rust/WASM support
|
||||
# Builder stage - compile frontend
|
||||
# ============================================================================
|
||||
FROM base AS builder
|
||||
ARG CI=false
|
||||
ENV CI=$CI
|
||||
|
||||
# Install build dependencies for Rust and native modules
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust toolchain
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
|
||||
--default-toolchain stable \
|
||||
--profile minimal \
|
||||
--target wasm32-unknown-unknown
|
||||
|
||||
# Add Rust to PATH
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Install wasm-bindgen-cli
|
||||
RUN cargo install wasm-bindgen-cli
|
||||
|
||||
# Copy source files
|
||||
COPY packages ./packages
|
||||
|
||||
# Install all dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build packages in correct order with WASM support
|
||||
# 1. Build buttplug WASM
|
||||
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
|
||||
# Generate SvelteKit type definitions (creates .svelte-kit/tsconfig.json)
|
||||
RUN pnpm --filter @sexy.pivoine.art/frontend exec svelte-kit sync
|
||||
|
||||
# 2. Build buttplug TypeScript
|
||||
RUN pnpm --filter @sexy.pivoine.art/buttplug build
|
||||
|
||||
# 3. Build frontend
|
||||
# Build frontend
|
||||
RUN pnpm --filter @sexy.pivoine.art/frontend build
|
||||
|
||||
# Prune dev dependencies for production
|
||||
RUN pnpm install -rP
|
||||
RUN CI=true pnpm install -rP
|
||||
|
||||
# ============================================================================
|
||||
# Runner stage - minimal production image
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS runner
|
||||
FROM node:22.14.0-slim AS runner
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -91,19 +65,14 @@ COPY --from=builder --chown=node:node /app/package.json ./package.json
|
||||
COPY --from=builder --chown=node:node /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=builder --chown=node:node /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
# Create package directories
|
||||
RUN mkdir -p packages/frontend packages/buttplug
|
||||
# Create package directory
|
||||
RUN mkdir -p packages/frontend
|
||||
|
||||
# Copy frontend artifacts
|
||||
COPY --from=builder --chown=node:node /app/packages/frontend/build ./packages/frontend/build
|
||||
COPY --from=builder --chown=node:node /app/packages/frontend/node_modules ./packages/frontend/node_modules
|
||||
COPY --from=builder --chown=node:node /app/packages/frontend/package.json ./packages/frontend/package.json
|
||||
|
||||
# Copy buttplug artifacts
|
||||
COPY --from=builder --chown=node:node /app/packages/buttplug/dist ./packages/buttplug/dist
|
||||
COPY --from=builder --chown=node:node /app/packages/buttplug/node_modules ./packages/buttplug/node_modules
|
||||
COPY --from=builder --chown=node:node /app/packages/buttplug/package.json ./packages/buttplug/package.json
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
|
||||
|
||||
@@ -3,27 +3,38 @@
|
||||
# ============================================================================
|
||||
# Builder stage
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS builder
|
||||
FROM node:22.14.0-slim AS builder
|
||||
|
||||
RUN npm install -g corepack@latest && corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all package manifests so pnpm can resolve the workspace lockfile,
|
||||
# but use --ignore-scripts to skip buttplug's Rust/WASM build entirely.
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/backend/package.json ./packages/backend/package.json
|
||||
COPY packages/frontend/package.json ./packages/frontend/package.json
|
||||
COPY packages/buttplug/package.json ./packages/buttplug/package.json
|
||||
COPY packages/types/package.json ./packages/types/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend
|
||||
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --ignore-scripts
|
||||
|
||||
# Rebuild native bindings (argon2, sharp)
|
||||
RUN pnpm rebuild argon2 sharp
|
||||
|
||||
COPY packages/types ./packages/types
|
||||
COPY packages/backend ./packages/backend
|
||||
|
||||
RUN pnpm --filter @sexy.pivoine.art/backend build
|
||||
|
||||
RUN pnpm install -rP --filter @sexy.pivoine.art/backend
|
||||
RUN CI=true pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --prod --ignore-scripts
|
||||
|
||||
RUN pnpm rebuild argon2 sharp
|
||||
|
||||
# ============================================================================
|
||||
# Runner stage
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS runner
|
||||
FROM node:22.14.0-slim AS runner
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
dumb-init \
|
||||
@@ -39,9 +50,12 @@ WORKDIR /home/node/app
|
||||
|
||||
RUN mkdir -p packages/backend
|
||||
|
||||
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=node:node /app/package.json ./package.json
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/dist ./packages/backend/dist
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/node_modules ./packages/backend/node_modules
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/package.json ./packages/backend/package.json
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/dist/migrations
|
||||
|
||||
RUN mkdir -p /data/uploads && chown node:node /data/uploads
|
||||
|
||||
|
||||
65
Dockerfile.buttplug
Normal file
65
Dockerfile.buttplug
Normal file
@@ -0,0 +1,65 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ============================================================================
|
||||
# Builder stage - compile Rust/WASM and TypeScript
|
||||
# ============================================================================
|
||||
FROM node:22.14.0-slim AS builder
|
||||
|
||||
# Install build dependencies for Rust
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN npm install -g corepack@latest && corepack enable
|
||||
|
||||
# Install Rust toolchain
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
|
||||
--default-toolchain stable \
|
||||
--profile minimal \
|
||||
--target wasm32-unknown-unknown
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Install wasm-bindgen-cli
|
||||
RUN cargo install wasm-bindgen-cli
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace configuration
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/buttplug ./packages/buttplug
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/buttplug
|
||||
|
||||
# Build WASM
|
||||
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
|
||||
|
||||
# Build TypeScript
|
||||
RUN pnpm --filter @sexy.pivoine.art/buttplug build
|
||||
|
||||
# ============================================================================
|
||||
# Runner stage - nginx serving dist/ and wasm/
|
||||
# ============================================================================
|
||||
FROM nginx:1.27-alpine AS runner
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.buttplug.conf /etc/nginx/conf.d/buttplug.conf
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder /app/packages/buttplug/dist /usr/share/nginx/html/dist
|
||||
COPY --from=builder /app/packages/buttplug/wasm /usr/share/nginx/html/wasm
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/dist/index.js || exit 1
|
||||
323
README.md
323
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||

|
||||
|
||||
*"Lust und Liebe gehören zusammen - wer das eine verteufelt, zerstört auch das andere."*
|
||||
_"Lust und Liebe gehören zusammen - wer das eine verteufelt, zerstört auch das andere."_
|
||||
— **Beate Uhse**, Pionierin der sexuellen Befreiung ✈️
|
||||
|
||||
---
|
||||
@@ -13,10 +13,10 @@
|
||||
|
||||
Built with passion, technology, and the fearless spirit of sexual empowerment
|
||||
|
||||
[](https://github.com/valknarxxx/sexy.pivoine.art/actions/workflows/docker-build-push.yml)
|
||||
[](https://github.com/valknarxxx/sexy.pivoine.art/actions/workflows/docker-scan.yml)
|
||||
[](https://dev.pivoine.art/valknar/sexy/actions)
|
||||
[](https://dev.pivoine.art/valknar/sexy/actions)
|
||||
[](LICENSE)
|
||||
[](http://sexy.pivoine.art)
|
||||
[](https://sexy.pivoine.art)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -24,20 +24,23 @@ Built with passion, technology, and the fearless spirit of sexual empowerment
|
||||
|
||||
## 👅 What Is This Delicious Creation?
|
||||
|
||||
Welcome, dear pleasure-seeker! This is **sexy.pivoine.art** — a modern, sensual platform combining the elegance of **SvelteKit**, the power of **Directus CMS**, and the intimate connection of **Buttplug.io** hardware integration.
|
||||
Welcome, dear pleasure-seeker! This is **sexy.pivoine.art** — a modern, sensual platform built from the ground up with full control over every intimate detail. A **SvelteKit** frontend caresses a purpose-built **Fastify + GraphQL** backend, while **Buttplug.io** hardware integration brings the experience into the physical world.
|
||||
|
||||
Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom to explore, create, and celebrate sexuality without shame. This platform is built for **models**, **creators**, and **connoisseurs** of adult content who deserve technology as sophisticated as their desires.
|
||||
|
||||
### ♉ Features That'll Make You Blush ♊
|
||||
|
||||
- 💖 **Sensual SvelteKit Frontend** with Tailwind CSS 4 styling
|
||||
- 🗄️ **Headless CMS** powered by Directus for content liberation
|
||||
- ⚡ **Purpose-built GraphQL Backend** — lean, fast, no CMS overhead
|
||||
- 🔐 **Session-based Auth** with Redis & Argon2 — discretion guaranteed
|
||||
- 🖼️ **Smart Image Transforms** via Sharp (WebP, multiple presets, cached)
|
||||
- 🎮 **Hardware Integration** via Buttplug.io (yes, really!)
|
||||
- 🌐 **Multi-Platform Support** (AMD64 + ARM64) — pleasure everywhere
|
||||
- 🔒 **Session-Based Authentication** — discretion guaranteed
|
||||
- 📱 **Responsive Design** that looks sexy on any device
|
||||
- 🌍 **Internationalization** — pleasure speaks all languages
|
||||
- 🏆 **Gamification** — achievements, leaderboards, and reward points
|
||||
- 💬 **Comments & Social** — build your community
|
||||
- 📊 **Analytics Integration** (Umami) — know your admirers
|
||||
- 🐳 **Self-hosted CI/CD** via Gitea Actions on `dev.pivoine.art`
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -48,15 +51,21 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 💋 Frontend Layer │
|
||||
│ ├─ SvelteKit 2.0 → Smooth as silk │
|
||||
│ ├─ SvelteKit 2 → Smooth as silk │
|
||||
│ ├─ Tailwind CSS 4 → Styled to seduce │
|
||||
│ ├─ bits-ui Components → Building blocks of pleasure │
|
||||
│ ├─ graphql-request v7 → Whispering to the backend │
|
||||
│ └─ Vite → Fast and furious │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 🍷 Backend Layer │
|
||||
│ ├─ Directus CMS → Content with no limits │
|
||||
│ ├─ Custom Extensions → Bespoke pleasures │
|
||||
│ └─ PostgreSQL → Data deep and secure │
|
||||
│ ├─ Fastify v5 → The fastest penetration │
|
||||
│ ├─ GraphQL Yoga v5 → Flexible positions │
|
||||
│ ├─ Pothos (code-first) → Schema with intention │
|
||||
│ ├─ Drizzle ORM → Data with grace │
|
||||
│ ├─ PostgreSQL 16 → Deep and persistent │
|
||||
│ ├─ Redis → Sessions that never forget │
|
||||
│ ├─ Sharp → Images transformed beautifully │
|
||||
│ └─ Argon2 → Passwords hashed with passion │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 🎀 Hardware Layer │
|
||||
│ ├─ Buttplug.io → Real connections │
|
||||
@@ -65,8 +74,8 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 🌸 DevOps Layer │
|
||||
│ ├─ Docker → Containerized ecstasy │
|
||||
│ ├─ GitHub Actions → Automated seduction │
|
||||
│ └─ GHCR → Images served hot │
|
||||
│ ├─ Gitea Actions → Self-hosted seduction │
|
||||
│ └─ dev.pivoine.art → Our own pleasure palace │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -74,46 +83,49 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
|
||||
|
||||
## 🔥 Quick Start — Get Intimate Fast
|
||||
|
||||
### 💕 Option 1: Using Docker (Recommended)
|
||||
### 💕 Option 1: Using Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Pull the pleasure
|
||||
docker pull ghcr.io/valknarxxx/sexy:latest
|
||||
# Clone the repository
|
||||
git clone https://dev.pivoine.art/valknar/sexy.git
|
||||
cd sexy.pivoine.art
|
||||
|
||||
# Run with passion
|
||||
docker run -d -p 3000:3000 \
|
||||
-e PUBLIC_API_URL=https://api.your-domain.com \
|
||||
-e PUBLIC_URL=https://your-domain.com \
|
||||
ghcr.io/valknarxxx/sexy:latest
|
||||
# Configure your secrets
|
||||
cp .env.example .env
|
||||
# Edit .env with your intimate details
|
||||
|
||||
# Awaken all services (postgres, redis, backend, frontend)
|
||||
docker compose up -d
|
||||
|
||||
# Visit your creation at http://localhost:3000 💋
|
||||
```
|
||||
|
||||
See [QUICKSTART.md](QUICKSTART.md) for the full seduction guide.
|
||||
|
||||
### 💜 Option 2: Local Development
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
1. Node.js 20.19.1 — *the foundation*
|
||||
2. `corepack enable` — *unlock the tools*
|
||||
3. `pnpm install` — *gather your ingredients*
|
||||
4. Rust + `cargo install wasm-bindgen-cli` — *forge the connection*
|
||||
1. Node.js 20.19.1 — _the foundation_
|
||||
2. `corepack enable` — _unlock the tools_
|
||||
3. `pnpm install` — _gather your ingredients_
|
||||
4. PostgreSQL 16 + Redis — _the data lovers_
|
||||
|
||||
**Start your pleasure journey:**
|
||||
|
||||
```bash
|
||||
# Awaken all services
|
||||
pnpm dev
|
||||
# Awaken data services
|
||||
pnpm dev:data
|
||||
|
||||
# Or tease them one by one
|
||||
pnpm dev:data # The foundation
|
||||
pnpm dev:directus # The content
|
||||
pnpm --filter @sexy.pivoine.art/frontend dev # The face
|
||||
# Start the backend (port 4000)
|
||||
pnpm dev:backend
|
||||
|
||||
# Start the frontend (port 3000, proxied to :4000)
|
||||
pnpm --filter @sexy.pivoine.art/frontend dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000` and let the experience begin... 💋
|
||||
|
||||
GraphQL playground is available at `http://localhost:4000/graphql` — explore every query.
|
||||
|
||||
---
|
||||
|
||||
## 🌹 Project Structure
|
||||
@@ -123,98 +135,116 @@ This monorepo contains three packages, each serving its purpose:
|
||||
```
|
||||
sexy.pivoine.art/
|
||||
├─ 💄 packages/frontend/ → SvelteKit app (the seduction)
|
||||
├─ 🎭 packages/bundle/ → Directus extensions (the power)
|
||||
├─ ⚡ packages/backend/ → Fastify + GraphQL API (the engine)
|
||||
└─ 🎮 packages/buttplug/ → Hardware control (the connection)
|
||||
```
|
||||
|
||||
---
|
||||
### 💄 Frontend (`packages/frontend/`)
|
||||
|
||||
## 📚 Documentation — Your Guide to Pleasure
|
||||
SvelteKit 2 application with server-side rendering, i18n, and a clean component library.
|
||||
Communicates with the backend exclusively via GraphQL using `graphql-request`.
|
||||
Assets served via `/api/assets/:id?transform=<preset>` — no CDN, no Directus, just raw power.
|
||||
|
||||
<div align="center">
|
||||
### ⚡ Backend (`packages/backend/`)
|
||||
|
||||
| Document | Purpose | Emoji |
|
||||
|----------|---------|-------|
|
||||
| [QUICKSTART.md](QUICKSTART.md) | Get wet... I mean, get started! | 💦 |
|
||||
| [COMPOSE.md](COMPOSE.md) | Docker Compose setup guide | 🐳 |
|
||||
| [DOCKER.md](DOCKER.md) | Standalone Docker deployment | 🐋 |
|
||||
| [CLAUDE.md](CLAUDE.md) | Architecture & development | 🤖 |
|
||||
| [.github/workflows/README.md](.github/workflows/README.md) | CI/CD workflows | ⚙️ |
|
||||
Purpose-built Fastify v5 + GraphQL Yoga server. All business logic lives here:
|
||||
auth, file uploads, video processing, comments, gamification, and analytics.
|
||||
Files stored as `<UPLOAD_DIR>/<uuid>/<filename>` with on-demand WebP transforms cached on disk.
|
||||
|
||||
</div>
|
||||
### 🎮 Buttplug (`packages/buttplug/`)
|
||||
|
||||
Hybrid TypeScript/Rust package for intimate hardware control via WebBluetooth.
|
||||
Compiled to WebAssembly for browser-based Bluetooth device communication.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Building — Craft Your Masterpiece
|
||||
## 🗃️ Database Schema
|
||||
|
||||
### Build All Packages
|
||||
Built with Drizzle ORM — clean tables, no `directus_` prefix, full control:
|
||||
|
||||
```bash
|
||||
# Prepare everything
|
||||
pnpm install
|
||||
|
||||
# Build the WASM foundation
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
|
||||
|
||||
# Build the packages
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build
|
||||
pnpm --filter @sexy.pivoine.art/frontend build
|
||||
pnpm --filter @sexy.pivoine.art/bundle build
|
||||
```
|
||||
|
||||
### Build Docker Image
|
||||
|
||||
```bash
|
||||
# Quick build
|
||||
./build.sh
|
||||
|
||||
# Manual control
|
||||
docker build -t sexy.pivoine.art:latest .
|
||||
|
||||
# Multi-platform pleasure
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t sexy.pivoine.art:latest .
|
||||
users → profiles, roles (model/viewer/admin), auth tokens
|
||||
files → uploaded assets with metadata and duration
|
||||
videos → content with model junctions, likes, plays
|
||||
articles → magazine / editorial content
|
||||
recordings → user-created content with play tracking
|
||||
comments → threaded by collection + item_id
|
||||
achievements → gamification goals
|
||||
user_points → points ledger
|
||||
user_stats → cached leaderboard data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment — Share Your Creation
|
||||
## 🔐 Authentication Flow
|
||||
|
||||
```
|
||||
POST /graphql (login mutation)
|
||||
→ verify argon2 password hash
|
||||
→ nanoid(32) session token
|
||||
→ SET session:<token> <user JSON> EX 86400 in Redis
|
||||
→ set httpOnly cookie: session_token
|
||||
→ return CurrentUser
|
||||
|
||||
Every request:
|
||||
→ read session_token cookie
|
||||
→ GET session:<token> from Redis
|
||||
→ inject currentUser into GraphQL context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Image Transforms
|
||||
|
||||
Assets are transformed on first request and cached as WebP:
|
||||
|
||||
| Preset | Size | Fit | Use |
|
||||
| ----------- | ----------- | ------ | ---------------- |
|
||||
| `mini` | 80×80 | cover | Avatars in lists |
|
||||
| `thumbnail` | 300×300 | cover | Profile photos |
|
||||
| `preview` | 800px wide | inside | Video teasers |
|
||||
| `medium` | 1400px wide | inside | Full-size images |
|
||||
| `banner` | 1600×480 | cover | Profile banners |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Production with Docker Compose
|
||||
|
||||
```bash
|
||||
# Configure your secrets
|
||||
cp .env.production.example .env.production
|
||||
# Edit .env.production with your intimate details
|
||||
cp .env.example .env.production
|
||||
# Edit .env.production — set DB credentials, SMTP, cookie secret, CORS origin
|
||||
|
||||
# Deploy with grace (uses Traefik for routing)
|
||||
docker-compose -f compose.production.yml --env-file .env.production up -d
|
||||
# Deploy
|
||||
docker compose --env-file .env.production up -d
|
||||
```
|
||||
|
||||
### Production without Docker
|
||||
Key environment variables for the backend:
|
||||
|
||||
```bash
|
||||
# Build everything
|
||||
pnpm build:frontend
|
||||
|
||||
# Start serving
|
||||
pnpm --filter @sexy.pivoine.art/frontend start
|
||||
```env
|
||||
DATABASE_URL=postgresql://sexy:sexy@postgres:5432/sexy
|
||||
REDIS_URL=redis://redis:6379
|
||||
COOKIE_SECRET=your-very-secret-key
|
||||
CORS_ORIGIN=https://sexy.pivoine.art
|
||||
UPLOAD_DIR=/data/uploads
|
||||
SMTP_HOST=your.smtp.host
|
||||
SMTP_PORT=587
|
||||
EMAIL_FROM=noreply@sexy.pivoine.art
|
||||
PUBLIC_URL=https://sexy.pivoine.art
|
||||
```
|
||||
|
||||
---
|
||||
### 🎬 CI/CD — Self-Hosted Seduction
|
||||
|
||||
## 🌈 Environment Variables
|
||||
Automated builds run on **[dev.pivoine.art](https://dev.pivoine.art/valknar/sexy)** via Gitea Actions:
|
||||
|
||||
### 💖 Required (The Essentials)
|
||||
- ✅ Frontend image → `dev.pivoine.art/valknar/sexy:latest`
|
||||
- ✅ Backend image → `dev.pivoine.art/valknar/sexy-backend:latest`
|
||||
- ✅ Triggers on push to `main`, `develop`, or version tags (`v*.*.*`)
|
||||
- ✅ Build cache via registry for fast successive builds
|
||||
|
||||
- `PUBLIC_API_URL` — Your Directus backend
|
||||
- `PUBLIC_URL` — Your frontend domain
|
||||
|
||||
### 💜 Optional (The Extras)
|
||||
|
||||
- `PUBLIC_UMAMI_ID` — Analytics tracking ID
|
||||
- `PUBLIC_UMAMI_SCRIPT` — Umami script URL
|
||||
|
||||
See [.env.production.example](.env.production.example) for the full configuration.
|
||||
Images are pulled on the production server via Watchtower or manual `docker compose pull && docker compose up -d`.
|
||||
|
||||
---
|
||||
|
||||
@@ -225,60 +255,54 @@ graph LR
|
||||
A[💡 Idea] --> B[💻 Code]
|
||||
B --> C[🧪 Test Locally]
|
||||
C --> D[🌿 Feature Branch]
|
||||
D --> E[📤 Push & PR]
|
||||
E --> F{✅ CI Pass?}
|
||||
D --> E[📤 Push to dev.pivoine.art]
|
||||
E --> F{✅ Build Pass?}
|
||||
F -->|Yes| G[🔀 Merge to Main]
|
||||
F -->|No| B
|
||||
G --> H[🚀 Auto Deploy]
|
||||
H --> I[🏷️ Tag Release]
|
||||
I --> J[🎉 Celebrate]
|
||||
G --> H[🚀 Images Built & Pushed]
|
||||
H --> I[🎉 Deploy to Production]
|
||||
```
|
||||
|
||||
1. Create → `git checkout -b feature/my-sexy-feature`
|
||||
2. Develop → Write beautiful code
|
||||
3. Test → `pnpm dev`
|
||||
4. Push → Create PR (triggers CI build)
|
||||
5. Merge → Automatic deployment to production
|
||||
3. Test → `pnpm dev:data && pnpm dev:backend && pnpm dev`
|
||||
4. Push → `git push` to `dev.pivoine.art` (triggers CI build)
|
||||
5. Merge → Images published, deploy to production
|
||||
6. Release → `git tag v1.0.0 && git push origin v1.0.0`
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security — Protected Pleasure
|
||||
## 🌈 Environment Variables
|
||||
|
||||
- 🛡️ Daily vulnerability scans with Trivy
|
||||
- 🔒 Non-root Docker containers
|
||||
- 📊 Security reports in GitHub Security tab
|
||||
- 🤐 Confidential issue reporting available
|
||||
### Backend (required)
|
||||
|
||||
*Report security concerns privately via GitHub Security.*
|
||||
| Variable | Description |
|
||||
| --------------- | ----------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `REDIS_URL` | Redis connection string |
|
||||
| `COOKIE_SECRET` | Session cookie signing secret |
|
||||
| `CORS_ORIGIN` | Allowed frontend origin |
|
||||
| `UPLOAD_DIR` | Path for uploaded files |
|
||||
|
||||
---
|
||||
### Backend (optional)
|
||||
|
||||
## 💝 Contributing — Join the Movement
|
||||
| Variable | Default | Description |
|
||||
| ------------ | ------- | ------------------------------ |
|
||||
| `PORT` | `4000` | Backend listen port |
|
||||
| `LOG_LEVEL` | `info` | Fastify log level |
|
||||
| `SMTP_HOST` | — | Email server for auth flows |
|
||||
| `SMTP_PORT` | `587` | Email server port |
|
||||
| `EMAIL_FROM` | — | Sender address |
|
||||
| `PUBLIC_URL` | — | Frontend URL (for email links) |
|
||||
|
||||
Like Beate Uhse fought for sexual liberation, we welcome contributors who believe in freedom, pleasure, and quality code.
|
||||
### Frontend
|
||||
|
||||
1. **Fork** this repository
|
||||
2. **Create** your feature branch
|
||||
3. **Commit** your changes
|
||||
4. **Push** to your branch
|
||||
5. **Submit** a pull request
|
||||
|
||||
All contributors are bound by our code of conduct: **Respect, Consent, and Quality.**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CI/CD Pipeline — Automated Seduction
|
||||
|
||||
Our GitHub Actions workflows handle:
|
||||
|
||||
- ✅ Multi-platform Docker builds (AMD64 + ARM64)
|
||||
- ✅ Automated publishing to GHCR
|
||||
- ✅ Daily security vulnerability scans
|
||||
- ✅ Weekly cleanup of old images
|
||||
- ✅ Semantic versioning from git tags
|
||||
|
||||
**Images available at:** `ghcr.io/valknarxxx/sexy`
|
||||
| Variable | Description |
|
||||
| --------------------- | --------------------------------------------- |
|
||||
| `PUBLIC_API_URL` | Backend URL (e.g. `http://sexy_backend:4000`) |
|
||||
| `PUBLIC_URL` | Frontend public URL |
|
||||
| `PUBLIC_UMAMI_ID` | Umami analytics site ID (optional) |
|
||||
| `PUBLIC_UMAMI_SCRIPT` | Umami script URL (optional) |
|
||||
|
||||
---
|
||||
|
||||
@@ -288,20 +312,25 @@ Our GitHub Actions workflows handle:
|
||||
|
||||
### 🌸 Created with Love by 🌸
|
||||
|
||||
**[Palina](http://sexy.pivoine.art) & [Valknar](http://sexy.pivoine.art)**
|
||||
**[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)**
|
||||
|
||||
*Für die Mäuse...* 🐭💕
|
||||
_Für die Mäuse..._ 🐭💕
|
||||
|
||||
---
|
||||
|
||||
### 🙏 Built With
|
||||
|
||||
| Technology | Purpose |
|
||||
|------------|---------|
|
||||
| [SvelteKit](https://kit.svelte.dev/) | Framework |
|
||||
| [Directus](https://directus.io/) | CMS |
|
||||
| [Buttplug.io](https://buttplug.io/) | Hardware |
|
||||
| [bits-ui](https://www.bits-ui.com/) | Components |
|
||||
| Technology | Purpose |
|
||||
| --------------------------------------------------------- | -------------------- |
|
||||
| [SvelteKit](https://kit.svelte.dev/) | Frontend framework |
|
||||
| [Fastify](https://fastify.dev/) | HTTP server |
|
||||
| [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) | GraphQL server |
|
||||
| [Pothos](https://pothos-graphql.dev/) | Code-first schema |
|
||||
| [Drizzle ORM](https://orm.drizzle.team/) | Database |
|
||||
| [Sharp](https://sharp.pixelplumbing.com/) | Image transforms |
|
||||
| [Buttplug.io](https://buttplug.io/) | Hardware |
|
||||
| [bits-ui](https://www.bits-ui.com/) | UI components |
|
||||
| [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI |
|
||||
|
||||
---
|
||||
|
||||
@@ -310,7 +339,7 @@ Our GitHub Actions workflows handle:
|
||||
Pioneer of sexual liberation (1919-2001)
|
||||
Pilot, Entrepreneur, Freedom Fighter
|
||||
|
||||
*"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."*
|
||||
_"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."_
|
||||
|
||||

|
||||
|
||||
@@ -331,9 +360,9 @@ Pilot, Entrepreneur, Freedom Fighter
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/valknarxxx/sexy.pivoine.art/issues)
|
||||
[](https://github.com/valknarxxx/sexy.pivoine.art/discussions)
|
||||
[](http://sexy.pivoine.art)
|
||||
[](https://dev.pivoine.art/valknar/sexy)
|
||||
[](https://dev.pivoine.art/valknar/sexy/issues)
|
||||
[](https://sexy.pivoine.art)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -352,8 +381,8 @@ Pilot, Entrepreneur, Freedom Fighter
|
||||
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝
|
||||
</pre>
|
||||
|
||||
*Pleasure is a human right. Technology is freedom. Together, they are power.*
|
||||
_Pleasure is a human right. Technology is freedom. Together, they are power._
|
||||
|
||||
**[sexy.pivoine.art](http://sexy.pivoine.art)** | © 2025 Palina & Valknar
|
||||
**[sexy.pivoine.art](https://sexy.pivoine.art)** | © 2025 Palina & Valknar
|
||||
|
||||
</div>
|
||||
|
||||
22
compose.yml
22
compose.yml
@@ -4,6 +4,8 @@ services:
|
||||
image: postgres:16-alpine
|
||||
container_name: sexy_postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
@@ -19,6 +21,8 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: sexy_redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
@@ -60,6 +64,21 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
buttplug:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.buttplug
|
||||
container_name: sexy_buttplug
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/dist/index.js"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
@@ -74,9 +93,12 @@ services:
|
||||
HOST: 0.0.0.0
|
||||
PUBLIC_API_URL: http://sexy_backend:4000
|
||||
PUBLIC_URL: http://localhost:3000
|
||||
BUTTPLUG_URL: http://sexy_buttplug:80
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
buttplug:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
|
||||
2114
directus.yml
2114
directus.yml
File diff suppressed because it is too large
Load Diff
57
eslint.config.js
Normal file
57
eslint.config.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import js from "@eslint/js";
|
||||
import ts from "typescript-eslint";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
|
||||
export default ts.config(
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs["flat/recommended"],
|
||||
prettier,
|
||||
...svelte.configs["flat/prettier"],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
// Allow unused vars prefixed with _ (common pattern for intentional ignores)
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
// Enforce consistent type imports
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
// This rule is meant for onNavigate() callbacks only; standard SvelteKit href/goto is fine
|
||||
"svelte/no-navigation-without-resolve": "off",
|
||||
// {@html} is used intentionally for trusted content (e.g. legal page)
|
||||
"svelte/no-at-html-tags": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"**/build/",
|
||||
"**/.svelte-kit/",
|
||||
"**/dist/",
|
||||
"**/node_modules/",
|
||||
"**/migrations/",
|
||||
"**/wasm/",
|
||||
],
|
||||
},
|
||||
);
|
||||
23
nginx.buttplug.conf
Normal file
23
nginx.buttplug.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# WASM MIME type
|
||||
include /etc/nginx/mime.types;
|
||||
types {
|
||||
application/wasm wasm;
|
||||
}
|
||||
|
||||
# Cache JS and WASM aggressively (content-addressed by build)
|
||||
location ~* \.(js|wasm)$ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Cross-Origin-Resource-Policy "cross-origin";
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
79
package.json
79
package.json
@@ -1,33 +1,50 @@
|
||||
{
|
||||
"name": "sexy.pivoine.art",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
|
||||
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build",
|
||||
"dev:data": "docker compose up -d postgres redis",
|
||||
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
|
||||
"dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": {
|
||||
"name": "Valknar",
|
||||
"email": "valknar@pivoine.art"
|
||||
},
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"es5-ext",
|
||||
"esbuild",
|
||||
"svelte-preprocess",
|
||||
"wasm-pack"
|
||||
],
|
||||
"ignoredBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"node-sass"
|
||||
]
|
||||
}
|
||||
"name": "sexy.pivoine.art",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build:frontend": "pnpm --filter @sexy.pivoine.art/frontend build",
|
||||
"build:backend": "pnpm --filter @sexy.pivoine.art/backend build",
|
||||
"dev:buttplug": "pnpm --filter @sexy.pivoine.art/buttplug serve",
|
||||
"dev:data": "docker compose up -d postgres redis",
|
||||
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
|
||||
"dev": "pnpm dev:data && pnpm dev:backend & pnpm dev:buttplug & pnpm --filter @sexy.pivoine.art/frontend dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "pnpm -r --filter=!sexy.pivoine.art check"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": {
|
||||
"name": "Valknar",
|
||||
"email": "valknar@pivoine.art"
|
||||
},
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"argon2",
|
||||
"es5-ext",
|
||||
"esbuild",
|
||||
"svelte-preprocess",
|
||||
"wasm-pack"
|
||||
],
|
||||
"ignoredBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"node-sass"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"globals": "^17.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"typescript-eslint": "^8.56.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema/index.ts",
|
||||
schema: "./src/db/schema/*.ts",
|
||||
out: "./src/migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "UPLOAD_DIR=../../.data/uploads DATABASE_URL=postgresql://sexy:sexy@localhost:5432/sexy REDIS_URL=redis://localhost:6379 tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"migrate": "tsx src/scripts/data-migration.ts"
|
||||
"schema:migrate": "tsx src/scripts/migrate.ts",
|
||||
"migrate": "tsx src/scripts/data-migration.ts",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -19,7 +20,9 @@
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@pothos/core": "^4.4.0",
|
||||
"@pothos/plugin-errors": "^4.2.0",
|
||||
"@sexy.pivoine.art/types": "workspace:*",
|
||||
"argon2": "^0.43.0",
|
||||
"bullmq": "^5.70.4",
|
||||
"drizzle-orm": "^0.44.1",
|
||||
"fastify": "^5.4.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
@@ -28,21 +31,18 @@
|
||||
"graphql-ws": "^6.0.4",
|
||||
"graphql-yoga": "^5.13.4",
|
||||
"ioredis": "^5.6.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"nanoid": "^3.3.11",
|
||||
"nodemailer": "^7.0.3",
|
||||
"pg": "^8.16.0",
|
||||
"sharp": "^0.33.5",
|
||||
"slugify": "^1.6.6",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"argon2"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"tsx": "^4.19.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "./schema/index.js";
|
||||
import * as schema from "./schema/index";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || "postgresql://sexy:sexy@localhost:5432/sexy",
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "./users.js";
|
||||
import { files } from "./files.js";
|
||||
import { pgTable, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { users } from "./users";
|
||||
import { files } from "./files";
|
||||
|
||||
export const articles = pgTable(
|
||||
"articles",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
slug: text("slug").notNull(),
|
||||
title: text("title").notNull(),
|
||||
excerpt: text("excerpt"),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
index,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "./users.js";
|
||||
import { pgTable, text, timestamp, index, integer } from "drizzle-orm/pg-core";
|
||||
import { users } from "./users";
|
||||
|
||||
export const comments = pgTable(
|
||||
"comments",
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
bigint,
|
||||
integer,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { pgTable, text, timestamp, bigint, integer, index } from "drizzle-orm/pg-core";
|
||||
|
||||
export const files = pgTable(
|
||||
"files",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
title: text("title"),
|
||||
description: text("description"),
|
||||
filename: text("filename").notNull(),
|
||||
|
||||
@@ -8,18 +8,18 @@ import {
|
||||
pgEnum,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "./users.js";
|
||||
import { recordings } from "./recordings.js";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users } from "./users";
|
||||
import { recordings } from "./recordings";
|
||||
|
||||
export const achievementStatusEnum = pgEnum("achievement_status", [
|
||||
"draft",
|
||||
"published",
|
||||
]);
|
||||
export const achievementStatusEnum = pgEnum("achievement_status", ["draft", "published"]);
|
||||
|
||||
export const achievements = pgTable(
|
||||
"achievements",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
code: text("code").notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
@@ -69,6 +69,11 @@ export const user_points = pgTable(
|
||||
(t) => [
|
||||
index("user_points_user_idx").on(t.user_id),
|
||||
index("user_points_date_idx").on(t.date_created),
|
||||
uniqueIndex("user_points_unique_action_recording")
|
||||
.on(t.user_id, t.action, t.recording_id)
|
||||
.where(
|
||||
sql`"action" IN ('RECORDING_CREATE', 'RECORDING_FEATURED') AND "recording_id" IS NOT NULL`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from "./files.js";
|
||||
export * from "./users.js";
|
||||
export * from "./videos.js";
|
||||
export * from "./articles.js";
|
||||
export * from "./recordings.js";
|
||||
export * from "./comments.js";
|
||||
export * from "./gamification.js";
|
||||
export * from "./files";
|
||||
export * from "./users";
|
||||
export * from "./videos";
|
||||
export * from "./articles";
|
||||
export * from "./recordings";
|
||||
export * from "./comments";
|
||||
export * from "./gamification";
|
||||
|
||||
@@ -9,19 +9,17 @@ import {
|
||||
uniqueIndex,
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "./users.js";
|
||||
import { videos } from "./videos.js";
|
||||
import { users } from "./users";
|
||||
import { videos } from "./videos";
|
||||
|
||||
export const recordingStatusEnum = pgEnum("recording_status", [
|
||||
"draft",
|
||||
"published",
|
||||
"archived",
|
||||
]);
|
||||
export const recordingStatusEnum = pgEnum("recording_status", ["draft", "published"]);
|
||||
|
||||
export const recordings = pgTable(
|
||||
"recordings",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
slug: text("slug").notNull(),
|
||||
@@ -53,7 +51,9 @@ export const recordings = pgTable(
|
||||
export const recording_plays = pgTable(
|
||||
"recording_plays",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
recording_id: text("recording_id")
|
||||
.notNull()
|
||||
.references(() => recordings.id, { onDelete: "cascade" }),
|
||||
|
||||
@@ -8,14 +8,16 @@ import {
|
||||
uniqueIndex,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { files } from "./files.js";
|
||||
import { files } from "./files";
|
||||
|
||||
export const roleEnum = pgEnum("user_role", ["model", "viewer", "admin"]);
|
||||
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
email: text("email").notNull(),
|
||||
password_hash: text("password_hash").notNull(),
|
||||
first_name: text("first_name"),
|
||||
@@ -27,6 +29,8 @@ export const users = pgTable(
|
||||
role: roleEnum("role").notNull().default("viewer"),
|
||||
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
|
||||
banner: text("banner").references(() => files.id, { onDelete: "set null" }),
|
||||
photo: text("photo").references(() => files.id, { onDelete: "set null" }),
|
||||
is_admin: boolean("is_admin").notNull().default(false),
|
||||
email_verified: boolean("email_verified").notNull().default(false),
|
||||
email_verify_token: text("email_verify_token"),
|
||||
password_reset_token: text("password_reset_token"),
|
||||
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
uniqueIndex,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "./users.js";
|
||||
import { files } from "./files.js";
|
||||
import { users } from "./users";
|
||||
import { files } from "./files";
|
||||
|
||||
export const videos = pgTable(
|
||||
"videos",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
slug: text("slug").notNull(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
@@ -50,7 +52,9 @@ export const video_models = pgTable(
|
||||
export const video_likes = pgTable(
|
||||
"video_likes",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
video_id: text("video_id")
|
||||
.notNull()
|
||||
.references(() => videos.id, { onDelete: "cascade" }),
|
||||
@@ -68,7 +72,9 @@ export const video_likes = pgTable(
|
||||
export const video_plays = pgTable(
|
||||
"video_plays",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
video_id: text("video_id")
|
||||
.notNull()
|
||||
.references(() => videos.id, { onDelete: "cascade" }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SchemaBuilder from "@pothos/core";
|
||||
import ErrorsPlugin from "@pothos/plugin-errors";
|
||||
import type { DB } from "../db/connection.js";
|
||||
import type { SessionUser } from "../lib/auth.js";
|
||||
import type { DB } from "../db/connection";
|
||||
import type { SessionUser } from "../lib/auth";
|
||||
import type Redis from "ioredis";
|
||||
import { GraphQLDateTime, GraphQLJSON } from "graphql-scalars";
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { YogaInitialContext } from "graphql-yoga";
|
||||
import type { Context } from "./builder.js";
|
||||
import { getSession } from "../lib/auth.js";
|
||||
import { db } from "../db/connection.js";
|
||||
import { redis } from "../lib/auth.js";
|
||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||
import type { Context } from "./builder";
|
||||
import { getSession, setSession } from "../lib/auth";
|
||||
import { db } from "../db/connection";
|
||||
import { redis } from "../lib/auth";
|
||||
import { users } from "../db/schema/index";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function buildContext(ctx: YogaInitialContext & { request: Request; reply: unknown; db: typeof db; redis: typeof redis }): Promise<Context> {
|
||||
type ServerContext = {
|
||||
req: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
db: typeof db;
|
||||
redis: typeof redis;
|
||||
};
|
||||
|
||||
export async function buildContext(ctx: YogaInitialContext & ServerContext): Promise<Context> {
|
||||
const request = ctx.request;
|
||||
const cookieHeader = request.headers.get("cookie") || "";
|
||||
|
||||
@@ -17,7 +27,34 @@ export async function buildContext(ctx: YogaInitialContext & { request: Request;
|
||||
);
|
||||
|
||||
const token = cookies["session_token"];
|
||||
const currentUser = token ? await getSession(token) : null;
|
||||
let currentUser = null;
|
||||
|
||||
if (token) {
|
||||
const session = await getSession(token); // also slides TTL
|
||||
if (session) {
|
||||
const dbInstance = ctx.db || db;
|
||||
const [dbUser] = await dbInstance
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.id))
|
||||
.limit(1);
|
||||
if (dbUser) {
|
||||
currentUser = {
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
role: (dbUser.role === "admin" ? "viewer" : dbUser.role) as "model" | "viewer",
|
||||
is_admin: dbUser.is_admin,
|
||||
first_name: dbUser.first_name,
|
||||
last_name: dbUser.last_name,
|
||||
artist_name: dbUser.artist_name,
|
||||
slug: dbUser.slug,
|
||||
avatar: dbUser.avatar,
|
||||
};
|
||||
// Refresh cached session with up-to-date data
|
||||
await setSession(token, currentUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
db: ctx.db || db,
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./resolvers/recordings.js";
|
||||
import "./resolvers/comments.js";
|
||||
import "./resolvers/gamification.js";
|
||||
import "./resolvers/stats.js";
|
||||
import { builder } from "./builder.js";
|
||||
import "./resolvers/queues.js";
|
||||
import { builder } from "./builder";
|
||||
|
||||
export const schema = builder.toSchema();
|
||||
|
||||
@@ -1,47 +1,75 @@
|
||||
import { builder } from "../builder.js";
|
||||
import { ArticleType } from "../types/index.js";
|
||||
import { articles, users } from "../../db/schema/index.js";
|
||||
import { eq, and, lte, desc } from "drizzle-orm";
|
||||
import { builder } from "../builder";
|
||||
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
|
||||
import { articles, users } from "../../db/schema/index";
|
||||
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains, type SQL } from "drizzle-orm";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
import type { DB } from "../../db/connection";
|
||||
|
||||
async function enrichArticle(db: DB, article: typeof articles.$inferSelect) {
|
||||
let author = null;
|
||||
if (article.author) {
|
||||
const authorUser = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
artist_name: users.artist_name,
|
||||
slug: users.slug,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article.author))
|
||||
.limit(1);
|
||||
author = authorUser[0] || null;
|
||||
}
|
||||
return { ...article, author };
|
||||
}
|
||||
|
||||
builder.queryField("articles", (t) =>
|
||||
t.field({
|
||||
type: [ArticleType],
|
||||
type: ArticleListType,
|
||||
args: {
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
search: t.arg.string(),
|
||||
category: t.arg.string(),
|
||||
offset: t.arg.int(),
|
||||
sortBy: t.arg.string(),
|
||||
tag: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
let query = ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(lte(articles.publish_date, new Date()))
|
||||
.orderBy(desc(articles.publish_date));
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
if (args.limit) {
|
||||
query = (query as any).limit(args.limit);
|
||||
const conditions: SQL<unknown>[] = [lte(articles.publish_date, new Date())];
|
||||
if (args.featured !== null && args.featured !== undefined) {
|
||||
conditions.push(eq(articles.featured, args.featured));
|
||||
}
|
||||
if (args.category) conditions.push(eq(articles.category, args.category));
|
||||
if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag]));
|
||||
if (args.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(articles.title, `%${args.search}%`),
|
||||
ilike(articles.excerpt, `%${args.search}%`),
|
||||
) as SQL<unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
const articleList = await query;
|
||||
const where = and(...conditions);
|
||||
const baseQuery = ctx.db.select().from(articles).where(where);
|
||||
const ordered =
|
||||
args.sortBy === "name"
|
||||
? baseQuery.orderBy(asc(articles.title))
|
||||
: args.sortBy === "featured"
|
||||
? baseQuery.orderBy(desc(articles.featured), desc(articles.publish_date))
|
||||
: baseQuery.orderBy(desc(articles.publish_date));
|
||||
|
||||
return Promise.all(
|
||||
articleList.map(async (article: any) => {
|
||||
let author = null;
|
||||
if (article.author) {
|
||||
const authorUser = await ctx.db
|
||||
.select({
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article.author))
|
||||
.limit(1);
|
||||
author = authorUser[0] || null;
|
||||
}
|
||||
return { ...article, author };
|
||||
}),
|
||||
);
|
||||
const [articleList, totalRows] = await Promise.all([
|
||||
ordered.limit(pageSize).offset(offset),
|
||||
ctx.db.select({ total: count() }).from(articles).where(where),
|
||||
]);
|
||||
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -61,23 +89,163 @@ builder.queryField("article", (t) =>
|
||||
.limit(1);
|
||||
|
||||
if (!article[0]) return null;
|
||||
|
||||
let author = null;
|
||||
if (article[0].author) {
|
||||
const authorUser = await ctx.db
|
||||
.select({
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article[0].author))
|
||||
.limit(1);
|
||||
author = authorUser[0] || null;
|
||||
}
|
||||
|
||||
return { ...article[0], author };
|
||||
return enrichArticle(ctx.db, article[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("adminGetArticle", (t) =>
|
||||
t.field({
|
||||
type: ArticleType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const article = await ctx.db.select().from(articles).where(eq(articles.id, args.id)).limit(1);
|
||||
if (!article[0]) return null;
|
||||
return enrichArticle(ctx.db, article[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||
|
||||
builder.queryField("adminListArticles", (t) =>
|
||||
t.field({
|
||||
type: AdminArticleListType,
|
||||
args: {
|
||||
search: t.arg.string(),
|
||||
category: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if (args.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(articles.title, `%${args.search}%`),
|
||||
ilike(articles.excerpt, `%${args.search}%`),
|
||||
) as SQL<unknown>,
|
||||
);
|
||||
}
|
||||
if (args.category) conditions.push(eq(articles.category, args.category));
|
||||
if (args.featured !== null && args.featured !== undefined)
|
||||
conditions.push(eq(articles.featured, args.featured));
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [articleList, totalRows] = await Promise.all([
|
||||
ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(where)
|
||||
.orderBy(desc(articles.publish_date))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(articles).where(where),
|
||||
]);
|
||||
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("createArticle", (t) =>
|
||||
t.field({
|
||||
type: ArticleType,
|
||||
args: {
|
||||
title: t.arg.string({ required: true }),
|
||||
slug: t.arg.string({ required: true }),
|
||||
excerpt: t.arg.string(),
|
||||
content: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
category: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
publishDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const inserted = await ctx.db
|
||||
.insert(articles)
|
||||
.values({
|
||||
title: args.title,
|
||||
slug: args.slug,
|
||||
excerpt: args.excerpt || null,
|
||||
content: args.content || null,
|
||||
image: args.imageId || null,
|
||||
tags: args.tags || [],
|
||||
category: args.category || null,
|
||||
featured: args.featured ?? false,
|
||||
publish_date: args.publishDate ? new Date(args.publishDate) : new Date(),
|
||||
author: ctx.currentUser!.id,
|
||||
})
|
||||
.returning();
|
||||
return enrichArticle(ctx.db, inserted[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("updateArticle", (t) =>
|
||||
t.field({
|
||||
type: ArticleType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
title: t.arg.string(),
|
||||
slug: t.arg.string(),
|
||||
excerpt: t.arg.string(),
|
||||
content: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
authorId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
category: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
publishDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.title !== undefined && args.title !== null) updates.title = args.title;
|
||||
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
|
||||
if (args.excerpt !== undefined) updates.excerpt = args.excerpt;
|
||||
if (args.content !== undefined) updates.content = args.content;
|
||||
if (args.imageId !== undefined) updates.image = args.imageId;
|
||||
if (args.authorId !== undefined) updates.author = args.authorId;
|
||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||
if (args.category !== undefined) updates.category = args.category;
|
||||
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
|
||||
if (args.publishDate !== undefined && args.publishDate !== null)
|
||||
updates.publish_date = new Date(args.publishDate);
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(articles)
|
||||
.set(updates as Partial<typeof articles.$inferInsert>)
|
||||
.where(eq(articles.id, args.id))
|
||||
.returning();
|
||||
if (!updated[0]) return null;
|
||||
return enrichArticle(ctx.db, updated[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("deleteArticle", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.delete(articles).where(eq(articles.id, args.id));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { CurrentUserType } from "../types/index.js";
|
||||
import { users } from "../../db/schema/index.js";
|
||||
import { builder } from "../builder";
|
||||
import { CurrentUserType } from "../types/index";
|
||||
import { users } from "../../db/schema/index";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hash, verify as verifyArgon } from "../../lib/argon.js";
|
||||
import { setSession, deleteSession } from "../../lib/auth.js";
|
||||
import { sendVerification, sendPasswordReset } from "../../lib/email.js";
|
||||
import { slugify } from "../../lib/slugify.js";
|
||||
|
||||
interface ReplyLike {
|
||||
header?: (name: string, value: string) => void;
|
||||
}
|
||||
import { hash, verify as verifyArgon } from "../../lib/argon";
|
||||
import { setSession, deleteSession } from "../../lib/auth";
|
||||
import { enqueueVerification, enqueuePasswordReset } from "../../lib/email";
|
||||
import { slugify } from "../../lib/slugify";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
builder.mutationField("login", (t) =>
|
||||
@@ -32,7 +36,8 @@ builder.mutationField("login", (t) =>
|
||||
const sessionUser = {
|
||||
id: user[0].id,
|
||||
email: user[0].email,
|
||||
role: user[0].role,
|
||||
role: (user[0].role === "admin" ? "viewer" : user[0].role) as "model" | "viewer",
|
||||
is_admin: user[0].is_admin,
|
||||
first_name: user[0].first_name,
|
||||
last_name: user[0].last_name,
|
||||
artist_name: user[0].artist_name,
|
||||
@@ -44,13 +49,8 @@ builder.mutationField("login", (t) =>
|
||||
|
||||
// Set session cookie
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||
|
||||
// For graphql-yoga response
|
||||
if ((ctx as any).serverResponse) {
|
||||
(ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue);
|
||||
}
|
||||
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
||||
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
|
||||
|
||||
return user[0];
|
||||
},
|
||||
@@ -73,8 +73,9 @@ builder.mutationField("logout", (t) =>
|
||||
await deleteSession(token);
|
||||
}
|
||||
// Clear cookie
|
||||
const cookieValue = "session_token=; HttpOnly; Path=/; Max-Age=0";
|
||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const cookieValue = `session_token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isProduction ? "; Secure" : ""}`;
|
||||
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
@@ -129,7 +130,11 @@ builder.mutationField("register", (t) =>
|
||||
email_verified: false,
|
||||
});
|
||||
|
||||
await sendVerification(args.email, verifyToken);
|
||||
try {
|
||||
await enqueueVerification(args.email, verifyToken);
|
||||
} catch (e) {
|
||||
console.warn("Failed to enqueue verification email:", (e as Error).message);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
@@ -184,7 +189,11 @@ builder.mutationField("requestPasswordReset", (t) =>
|
||||
.set({ password_reset_token: token, password_reset_expiry: expiry })
|
||||
.where(eq(users.id, user[0].id));
|
||||
|
||||
await sendPasswordReset(args.email, token);
|
||||
try {
|
||||
await enqueuePasswordReset(args.email, token);
|
||||
} catch (e) {
|
||||
console.warn("Failed to enqueue password reset email:", (e as Error).message);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { CommentType } from "../types/index.js";
|
||||
import { comments, users } from "../../db/schema/index.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification.js";
|
||||
import { builder } from "../builder";
|
||||
import { CommentType, AdminCommentListType } from "../types/index";
|
||||
import { comments, users } from "../../db/schema/index";
|
||||
import { eq, and, desc, ilike, count } from "drizzle-orm";
|
||||
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
|
||||
import { gamificationQueue } from "../../queues/index";
|
||||
|
||||
builder.queryField("commentsForVideo", (t) =>
|
||||
t.field({
|
||||
@@ -19,9 +20,15 @@ builder.queryField("commentsForVideo", (t) =>
|
||||
.orderBy(desc(comments.date_created));
|
||||
|
||||
return Promise.all(
|
||||
commentList.map(async (c: any) => {
|
||||
commentList.map(async (c) => {
|
||||
const user = await ctx.db
|
||||
.select({ id: users.id, first_name: users.first_name, last_name: users.last_name, avatar: users.avatar })
|
||||
.select({
|
||||
id: users.id,
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
artist_name: users.artist_name,
|
||||
avatar: users.avatar,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, c.user_id))
|
||||
.limit(1);
|
||||
@@ -52,12 +59,25 @@ builder.mutationField("createCommentForVideo", (t) =>
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Gamification
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE");
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "social");
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "COMMENT_CREATE",
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "social",
|
||||
});
|
||||
|
||||
const user = await ctx.db
|
||||
.select({ id: users.id, first_name: users.first_name, last_name: users.last_name, avatar: users.avatar })
|
||||
.select({
|
||||
id: users.id,
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
artist_name: users.artist_name,
|
||||
avatar: users.avatar,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, ctx.currentUser.id))
|
||||
.limit(1);
|
||||
@@ -66,3 +86,80 @@ builder.mutationField("createCommentForVideo", (t) =>
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("deleteComment", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
id: t.arg.int({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const comment = await ctx.db.select().from(comments).where(eq(comments.id, args.id)).limit(1);
|
||||
if (!comment[0]) throw new GraphQLError("Comment not found");
|
||||
requireOwnerOrAdmin(ctx, comment[0].user_id);
|
||||
await ctx.db.delete(comments).where(eq(comments.id, args.id));
|
||||
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: comment[0].user_id,
|
||||
action: "COMMENT_CREATE",
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: comment[0].user_id,
|
||||
category: "social",
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("adminListComments", (t) =>
|
||||
t.field({
|
||||
type: AdminCommentListType,
|
||||
args: {
|
||||
search: t.arg.string(),
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions = args.search ? [ilike(comments.comment, `%${args.search}%`)] : [];
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [commentList, totalRows] = await Promise.all([
|
||||
ctx.db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(where)
|
||||
.orderBy(desc(comments.date_created))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(comments).where(where),
|
||||
]);
|
||||
|
||||
const items = await Promise.all(
|
||||
commentList.map(async (c) => {
|
||||
const user = await ctx.db
|
||||
.select({
|
||||
id: users.id,
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
artist_name: users.artist_name,
|
||||
avatar: users.avatar,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, c.user_id))
|
||||
.limit(1);
|
||||
return { ...c, user: user[0] || null };
|
||||
}),
|
||||
);
|
||||
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { builder } from "../builder.js";
|
||||
import { LeaderboardEntryType, UserGamificationType, AchievementType } from "../types/index.js";
|
||||
import { user_stats, users, user_achievements, achievements, user_points } from "../../db/schema/index.js";
|
||||
import { eq, desc, gt, count, isNotNull } from "drizzle-orm";
|
||||
import { builder } from "../builder";
|
||||
import { LeaderboardEntryType, UserGamificationType, AchievementType } from "../types/index";
|
||||
import {
|
||||
user_stats,
|
||||
users,
|
||||
user_achievements,
|
||||
achievements,
|
||||
user_points,
|
||||
} from "../../db/schema/index";
|
||||
import { eq, desc, gt, count, isNotNull, and } from "drizzle-orm";
|
||||
|
||||
builder.queryField("leaderboard", (t) =>
|
||||
t.field({
|
||||
@@ -31,7 +37,7 @@ builder.queryField("leaderboard", (t) =>
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return entries.map((e: any, i: number) => ({ ...e, rank: offset + i + 1 }));
|
||||
return entries.map((e, i) => ({ ...e, rank: offset + i + 1 }));
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -73,8 +79,12 @@ builder.queryField("userGamification", (t) =>
|
||||
})
|
||||
.from(user_achievements)
|
||||
.leftJoin(achievements, eq(user_achievements.achievement_id, achievements.id))
|
||||
.where(eq(user_achievements.user_id, args.userId))
|
||||
.where(isNotNull(user_achievements.date_unlocked))
|
||||
.where(
|
||||
and(
|
||||
eq(user_achievements.user_id, args.userId),
|
||||
isNotNull(user_achievements.date_unlocked),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(user_achievements.date_unlocked));
|
||||
|
||||
const recentPoints = await ctx.db
|
||||
@@ -91,8 +101,15 @@ builder.queryField("userGamification", (t) =>
|
||||
|
||||
return {
|
||||
stats: stats[0] ? { ...stats[0], rank } : null,
|
||||
achievements: userAchievements.map((a: any) => ({
|
||||
...a,
|
||||
achievements: userAchievements.map((a) => ({
|
||||
id: a.id!,
|
||||
code: a.code!,
|
||||
name: a.name!,
|
||||
description: a.description!,
|
||||
icon: a.icon!,
|
||||
category: a.category!,
|
||||
required_count: a.required_count!,
|
||||
progress: a.progress!,
|
||||
date_unlocked: a.date_unlocked!,
|
||||
})),
|
||||
recent_points: recentPoints,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { builder } from "../builder.js";
|
||||
import { ModelType } from "../types/index.js";
|
||||
import { users, user_photos, files } from "../../db/schema/index.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { builder } from "../builder";
|
||||
import { ModelType, ModelListType } from "../types/index";
|
||||
import { users, user_photos, files } from "../../db/schema/index";
|
||||
import { eq, and, desc, asc, ilike, count, arrayContains, type SQL } from "drizzle-orm";
|
||||
import type { DB } from "../../db/connection";
|
||||
|
||||
async function enrichModel(db: any, user: any) {
|
||||
async function enrichModel(db: DB, user: typeof users.$inferSelect) {
|
||||
// Fetch photos
|
||||
const photoRows = await db
|
||||
.select({ id: files.id, filename: files.filename })
|
||||
@@ -12,32 +13,42 @@ async function enrichModel(db: any, user: any) {
|
||||
.where(eq(user_photos.user_id, user.id))
|
||||
.orderBy(user_photos.sort);
|
||||
|
||||
return {
|
||||
...user,
|
||||
photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })),
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
const photos = photoRows
|
||||
.filter((p) => p.id !== null && !seen.has(p.id!) && seen.add(p.id!))
|
||||
.map((p) => ({ id: p.id!, filename: p.filename! }));
|
||||
|
||||
return { ...user, photos };
|
||||
}
|
||||
|
||||
builder.queryField("models", (t) =>
|
||||
t.field({
|
||||
type: [ModelType],
|
||||
type: ModelListType,
|
||||
args: {
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
search: t.arg.string(),
|
||||
offset: t.arg.int(),
|
||||
sortBy: t.arg.string(),
|
||||
tag: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
let query = ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.role, "model"))
|
||||
.orderBy(desc(users.date_created));
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
if (args.limit) {
|
||||
query = (query as any).limit(args.limit);
|
||||
}
|
||||
const conditions: SQL<unknown>[] = [eq(users.role, "model")];
|
||||
if (args.search) conditions.push(ilike(users.artist_name, `%${args.search}%`));
|
||||
if (args.tag) conditions.push(arrayContains(users.tags, [args.tag]));
|
||||
|
||||
const modelList = await query;
|
||||
return Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
|
||||
const order = args.sortBy === "recent" ? desc(users.date_created) : asc(users.artist_name);
|
||||
|
||||
const where = and(...conditions);
|
||||
const [modelList, totalRows] = await Promise.all([
|
||||
ctx.db.select().from(users).where(where).orderBy(order).limit(pageSize).offset(offset),
|
||||
ctx.db.select({ total: count() }).from(users).where(where),
|
||||
]);
|
||||
const items = await Promise.all(modelList.map((m) => enrichModel(ctx.db, m)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
151
packages/backend/src/graphql/resolvers/queues.ts
Normal file
151
packages/backend/src/graphql/resolvers/queues.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import type { Job } from "bullmq";
|
||||
import { builder } from "../builder.js";
|
||||
import { JobType, QueueInfoType } from "../types/index.js";
|
||||
import { queues } from "../../queues/index.js";
|
||||
import { requireAdmin } from "../../lib/acl.js";
|
||||
|
||||
const JOB_STATUSES = ["waiting", "active", "completed", "failed", "delayed"] as const;
|
||||
type JobStatus = (typeof JOB_STATUSES)[number];
|
||||
|
||||
async function toJobData(job: Job, queueName: string) {
|
||||
const status = await job.getState();
|
||||
return {
|
||||
id: job.id ?? "",
|
||||
name: job.name,
|
||||
queue: queueName,
|
||||
status,
|
||||
data: job.data as unknown,
|
||||
result: job.returnvalue as unknown,
|
||||
failedReason: job.failedReason ?? null,
|
||||
attemptsMade: job.attemptsMade,
|
||||
createdAt: new Date(job.timestamp),
|
||||
processedAt: job.processedOn ? new Date(job.processedOn) : null,
|
||||
finishedAt: job.finishedOn ? new Date(job.finishedOn) : null,
|
||||
progress: typeof job.progress === "number" ? job.progress : null,
|
||||
};
|
||||
}
|
||||
|
||||
builder.queryField("adminQueues", (t) =>
|
||||
t.field({
|
||||
type: [QueueInfoType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
return Promise.all(
|
||||
Object.entries(queues).map(async ([name, queue]) => {
|
||||
const counts = await queue.getJobCounts(
|
||||
"waiting",
|
||||
"active",
|
||||
"completed",
|
||||
"failed",
|
||||
"delayed",
|
||||
"paused",
|
||||
);
|
||||
const isPaused = await queue.isPaused();
|
||||
return {
|
||||
name,
|
||||
counts: {
|
||||
waiting: counts.waiting ?? 0,
|
||||
active: counts.active ?? 0,
|
||||
completed: counts.completed ?? 0,
|
||||
failed: counts.failed ?? 0,
|
||||
delayed: counts.delayed ?? 0,
|
||||
paused: counts.paused ?? 0,
|
||||
},
|
||||
isPaused,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("adminQueueJobs", (t) =>
|
||||
t.field({
|
||||
type: [JobType],
|
||||
args: {
|
||||
queue: t.arg.string({ required: true }),
|
||||
status: t.arg.string(),
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
|
||||
const limit = args.limit ?? 25;
|
||||
const offset = args.offset ?? 0;
|
||||
const statuses: JobStatus[] = args.status ? [args.status as JobStatus] : [...JOB_STATUSES];
|
||||
|
||||
const jobs = await queue.getJobs(statuses, offset, offset + limit - 1);
|
||||
return Promise.all(jobs.map((job) => toJobData(job, args.queue)));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminRetryJob", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
queue: t.arg.string({ required: true }),
|
||||
jobId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
const job = await queue.getJob(args.jobId);
|
||||
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
|
||||
await job.retry();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminRemoveJob", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
queue: t.arg.string({ required: true }),
|
||||
jobId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
const job = await queue.getJob(args.jobId);
|
||||
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
|
||||
await job.remove();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminPauseQueue", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: { queue: t.arg.string({ required: true }) },
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
await queue.pause();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminResumeQueue", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: { queue: t.arg.string({ required: true }) },
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
await queue.resume();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1,10 +1,11 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { RecordingType } from "../types/index.js";
|
||||
import { recordings, recording_plays } from "../../db/schema/index.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { slugify } from "../../lib/slugify.js";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification.js";
|
||||
import { builder } from "../builder";
|
||||
import { RecordingType, AdminRecordingListType } from "../types/index";
|
||||
import { recordings, recording_plays } from "../../db/schema/index";
|
||||
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
|
||||
import { slugify } from "../../lib/slugify";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
import { gamificationQueue } from "../../queues/index";
|
||||
|
||||
builder.queryField("recordings", (t) =>
|
||||
t.field({
|
||||
@@ -20,7 +21,7 @@ builder.queryField("recordings", (t) =>
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as any));
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
|
||||
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
|
||||
|
||||
const limit = args.limit || 50;
|
||||
@@ -114,17 +115,25 @@ builder.mutationField("createRecording", (t) =>
|
||||
user_id: ctx.currentUser.id,
|
||||
tags: args.tags || [],
|
||||
linked_video: args.linkedVideoId || null,
|
||||
status: (args.status as any) || "draft",
|
||||
status: (args.status as "draft" | "published") || "draft",
|
||||
public: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const recording = newRecording[0];
|
||||
|
||||
// Gamification: award points if published
|
||||
if (recording.status === "published") {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: recording.id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "recordings",
|
||||
});
|
||||
}
|
||||
|
||||
return recording;
|
||||
@@ -162,28 +171,61 @@ builder.mutationField("updateRecording", (t) =>
|
||||
updates.title = args.title;
|
||||
updates.slug = slugify(args.title);
|
||||
}
|
||||
if (args.description !== null && args.description !== undefined) updates.description = args.description;
|
||||
if (args.description !== null && args.description !== undefined)
|
||||
updates.description = args.description;
|
||||
if (args.tags !== null && args.tags !== undefined) updates.tags = args.tags;
|
||||
if (args.status !== null && args.status !== undefined) updates.status = args.status;
|
||||
if (args.public !== null && args.public !== undefined) updates.public = args.public;
|
||||
if (args.linkedVideoId !== null && args.linkedVideoId !== undefined) updates.linked_video = args.linkedVideoId;
|
||||
if (args.linkedVideoId !== null && args.linkedVideoId !== undefined)
|
||||
updates.linked_video = args.linkedVideoId;
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(recordings)
|
||||
.set(updates as any)
|
||||
.set(updates as Partial<typeof recordings.$inferInsert>)
|
||||
.where(eq(recordings.id, args.id))
|
||||
.returning();
|
||||
|
||||
const recording = updated[0];
|
||||
|
||||
// Gamification: if newly published
|
||||
if (args.status === "published" && existing[0].status !== "published") {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
||||
}
|
||||
if (args.status === "published" && recording.featured && !existing[0].featured) {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
||||
// draft → published: award creation points
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: recording.id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "recordings",
|
||||
});
|
||||
} else if (args.status === "draft" && existing[0].status === "published") {
|
||||
// published → draft: revoke creation points
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: recording.id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "recordings",
|
||||
});
|
||||
} else if (args.status === "published" && recording.featured && !existing[0].featured) {
|
||||
// newly featured while published: award featured bonus
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_FEATURED",
|
||||
recordingId: recording.id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "recordings",
|
||||
});
|
||||
}
|
||||
|
||||
return recording;
|
||||
@@ -209,10 +251,29 @@ builder.mutationField("deleteRecording", (t) =>
|
||||
if (!existing[0]) throw new GraphQLError("Recording not found");
|
||||
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
||||
|
||||
await ctx.db
|
||||
.update(recordings)
|
||||
.set({ status: "archived", date_updated: new Date() })
|
||||
.where(eq(recordings.id, args.id));
|
||||
if (existing[0].status === "published") {
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: args.id,
|
||||
});
|
||||
if (existing[0].featured) {
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_FEATURED",
|
||||
recordingId: args.id,
|
||||
});
|
||||
}
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "content",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -288,10 +349,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
|
||||
})
|
||||
.returning({ id: recording_plays.id });
|
||||
|
||||
// Gamification
|
||||
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_PLAY",
|
||||
recordingId: args.recordingId,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "playback",
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, play_id: play[0].id };
|
||||
@@ -319,15 +388,77 @@ builder.mutationField("updateRecordingPlay", (t) =>
|
||||
|
||||
await ctx.db
|
||||
.update(recording_plays)
|
||||
.set({ duration_played: args.durationPlayed, completed: args.completed, date_updated: new Date() })
|
||||
.set({
|
||||
duration_played: args.durationPlayed,
|
||||
completed: args.completed,
|
||||
date_updated: new Date(),
|
||||
})
|
||||
.where(eq(recording_plays.id, args.playId));
|
||||
|
||||
if (args.completed && !wasCompleted && ctx.currentUser) {
|
||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id);
|
||||
await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_COMPLETE",
|
||||
recordingId: existing[0].recording_id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "playback",
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("adminListRecordings", (t) =>
|
||||
t.field({
|
||||
type: AdminRecordingListType,
|
||||
args: {
|
||||
search: t.arg.string(),
|
||||
status: t.arg.string(),
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if (args.search) conditions.push(ilike(recordings.title, `%${args.search}%`));
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
ctx.db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.where(where)
|
||||
.orderBy(desc(recordings.date_created))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(recordings).where(where),
|
||||
]);
|
||||
|
||||
return { items: rows, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminDeleteRecording", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { builder } from "../builder.js";
|
||||
import { StatsType } from "../types/index.js";
|
||||
import { users, videos } from "../../db/schema/index.js";
|
||||
import { builder } from "../builder";
|
||||
import { StatsType } from "../types/index";
|
||||
import { users, videos } from "../../db/schema/index";
|
||||
import { eq, count } from "drizzle-orm";
|
||||
|
||||
builder.queryField("stats", (t) =>
|
||||
@@ -15,9 +15,7 @@ builder.queryField("stats", (t) =>
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.role, "viewer"));
|
||||
const videosCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(videos);
|
||||
const videosCount = await ctx.db.select({ count: count() }).from(videos);
|
||||
|
||||
return {
|
||||
models_count: modelsCount[0]?.count || 0,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { CurrentUserType, UserType } from "../types/index.js";
|
||||
import { users } from "../../db/schema/index.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { builder } from "../builder";
|
||||
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
|
||||
import { users, user_photos, files } from "../../db/schema/index";
|
||||
import { eq, ilike, or, count, and, asc, type SQL } from "drizzle-orm";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
|
||||
builder.queryField("me", (t) =>
|
||||
t.field({
|
||||
@@ -28,11 +29,7 @@ builder.queryField("userProfile", (t) =>
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, args.id))
|
||||
.limit(1);
|
||||
const user = await ctx.db.select().from(users).where(eq(users.id, args.id)).limit(1);
|
||||
return user[0] || null;
|
||||
},
|
||||
}),
|
||||
@@ -48,18 +45,26 @@ builder.mutationField("updateProfile", (t) =>
|
||||
artistName: t.arg.string(),
|
||||
description: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
avatar: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.firstName !== undefined && args.firstName !== null) updates.first_name = args.firstName;
|
||||
if (args.firstName !== undefined && args.firstName !== null)
|
||||
updates.first_name = args.firstName;
|
||||
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
|
||||
if (args.artistName !== undefined && args.artistName !== null) updates.artist_name = args.artistName;
|
||||
if (args.description !== undefined && args.description !== null) updates.description = args.description;
|
||||
if (args.artistName !== undefined && args.artistName !== null)
|
||||
updates.artist_name = args.artistName;
|
||||
if (args.description !== undefined && args.description !== null)
|
||||
updates.description = args.description;
|
||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||
if (args.avatar !== undefined) updates.avatar = args.avatar;
|
||||
|
||||
await ctx.db.update(users).set(updates as any).where(eq(users.id, ctx.currentUser.id));
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set(updates as Partial<typeof users.$inferInsert>)
|
||||
.where(eq(users.id, ctx.currentUser.id));
|
||||
|
||||
const updated = await ctx.db
|
||||
.select()
|
||||
@@ -70,3 +75,163 @@ builder.mutationField("updateProfile", (t) =>
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||
|
||||
builder.queryField("adminListUsers", (t) =>
|
||||
t.field({
|
||||
type: AdminUserListType,
|
||||
args: {
|
||||
role: t.arg.string(),
|
||||
search: t.arg.string(),
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if (args.role) {
|
||||
conditions.push(eq(users.role, args.role as "model" | "viewer" | "admin"));
|
||||
}
|
||||
if (args.search) {
|
||||
const pattern = `%${args.search}%`;
|
||||
conditions.push(
|
||||
or(ilike(users.email, pattern), ilike(users.artist_name, pattern)) as SQL<unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [items, totalRows] = await Promise.all([
|
||||
ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(where)
|
||||
.orderBy(asc(users.artist_name))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(users).where(where),
|
||||
]);
|
||||
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminUpdateUser", (t) =>
|
||||
t.field({
|
||||
type: UserType,
|
||||
nullable: true,
|
||||
args: {
|
||||
userId: t.arg.string({ required: true }),
|
||||
role: t.arg.string(),
|
||||
isAdmin: t.arg.boolean(),
|
||||
firstName: t.arg.string(),
|
||||
lastName: t.arg.string(),
|
||||
artistName: t.arg.string(),
|
||||
avatarId: t.arg.string(),
|
||||
bannerId: t.arg.string(),
|
||||
photoId: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.role !== undefined && args.role !== null)
|
||||
updates.role = args.role as "model" | "viewer" | "admin";
|
||||
if (args.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin;
|
||||
if (args.firstName !== undefined && args.firstName !== null)
|
||||
updates.first_name = args.firstName;
|
||||
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
|
||||
if (args.artistName !== undefined && args.artistName !== null)
|
||||
updates.artist_name = args.artistName;
|
||||
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
|
||||
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
|
||||
if (args.photoId !== undefined && args.photoId !== null) updates.photo = args.photoId;
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(users)
|
||||
.set(updates as Partial<typeof users.$inferInsert>)
|
||||
.where(eq(users.id, args.userId))
|
||||
.returning();
|
||||
|
||||
return updated[0] || null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminDeleteUser", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
userId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
if (args.userId === ctx.currentUser!.id) throw new GraphQLError("Cannot delete yourself");
|
||||
await ctx.db.delete(users).where(eq(users.id, args.userId));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("adminGetUser", (t) =>
|
||||
t.field({
|
||||
type: AdminUserDetailType,
|
||||
nullable: true,
|
||||
args: {
|
||||
userId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const user = await ctx.db.select().from(users).where(eq(users.id, args.userId)).limit(1);
|
||||
if (!user[0]) return null;
|
||||
const photoRows = await ctx.db
|
||||
.select({ id: files.id, filename: files.filename })
|
||||
.from(user_photos)
|
||||
.leftJoin(files, eq(user_photos.file_id, files.id))
|
||||
.where(eq(user_photos.user_id, args.userId))
|
||||
.orderBy(user_photos.sort);
|
||||
const seen = new Set<string>();
|
||||
const photos = photoRows
|
||||
.filter((p) => p.id !== null && !seen.has(p.id!) && seen.add(p.id!))
|
||||
.map((p) => ({ id: p.id!, filename: p.filename! }));
|
||||
return { ...user[0], photos };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminAddUserPhoto", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
userId: t.arg.string({ required: true }),
|
||||
fileId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.insert(user_photos).values({ user_id: args.userId, file_id: args.fileId });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminRemoveUserPhoto", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
userId: t.arg.string({ required: true }),
|
||||
fileId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
await ctx.db
|
||||
.delete(user_photos)
|
||||
.where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId)));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { VideoType, VideoLikeResponseType, VideoPlayResponseType, VideoLikeStatusType } from "../types/index.js";
|
||||
import { videos, video_models, video_likes, video_plays, users, files } from "../../db/schema/index.js";
|
||||
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
|
||||
import { builder } from "../builder";
|
||||
import {
|
||||
VideoType,
|
||||
VideoListType,
|
||||
AdminVideoListType,
|
||||
VideoLikeResponseType,
|
||||
VideoPlayResponseType,
|
||||
VideoLikeStatusType,
|
||||
} from "../types/index";
|
||||
import {
|
||||
videos,
|
||||
video_models,
|
||||
video_likes,
|
||||
video_plays,
|
||||
users,
|
||||
files,
|
||||
} from "../../db/schema/index";
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
lte,
|
||||
desc,
|
||||
asc,
|
||||
inArray,
|
||||
count,
|
||||
ilike,
|
||||
lt,
|
||||
gte,
|
||||
arrayContains,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
import type { DB } from "../../db/connection";
|
||||
|
||||
async function enrichVideo(db: any, video: any) {
|
||||
async function enrichVideo(db: DB, video: typeof videos.$inferSelect) {
|
||||
// Fetch models
|
||||
const modelRows = await db
|
||||
.select({
|
||||
@@ -12,6 +41,7 @@ async function enrichVideo(db: any, video: any) {
|
||||
artist_name: users.artist_name,
|
||||
slug: users.slug,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(video_models)
|
||||
.leftJoin(users, eq(video_models.user_id, users.id))
|
||||
@@ -25,12 +55,28 @@ async function enrichVideo(db: any, video: any) {
|
||||
}
|
||||
|
||||
// Count likes
|
||||
const likesCount = await db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, video.id));
|
||||
const playsCount = await db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, video.id));
|
||||
const likesCount = await db
|
||||
.select({ count: count() })
|
||||
.from(video_likes)
|
||||
.where(eq(video_likes.video_id, video.id));
|
||||
const playsCount = await db
|
||||
.select({ count: count() })
|
||||
.from(video_plays)
|
||||
.where(eq(video_plays.video_id, video.id));
|
||||
|
||||
const models = modelRows
|
||||
.filter((m) => m.id !== null)
|
||||
.map((m) => ({
|
||||
id: m.id!,
|
||||
artist_name: m.artist_name,
|
||||
slug: m.slug,
|
||||
avatar: m.avatar,
|
||||
description: m.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
...video,
|
||||
models: modelRows,
|
||||
models,
|
||||
movie_file: movieFile,
|
||||
likes_count: likesCount[0]?.count || 0,
|
||||
plays_count: playsCount[0]?.count || 0,
|
||||
@@ -39,55 +85,93 @@ async function enrichVideo(db: any, video: any) {
|
||||
|
||||
builder.queryField("videos", (t) =>
|
||||
t.field({
|
||||
type: [VideoType],
|
||||
type: VideoListType,
|
||||
args: {
|
||||
modelId: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
search: t.arg.string(),
|
||||
offset: t.arg.int(),
|
||||
sortBy: t.arg.string(),
|
||||
duration: t.arg.string(),
|
||||
tag: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
let query = ctx.db
|
||||
.select({ v: videos })
|
||||
.from(videos)
|
||||
.where(lte(videos.upload_date, new Date()))
|
||||
.orderBy(desc(videos.upload_date));
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: SQL<unknown>[] = [lte(videos.upload_date, new Date())];
|
||||
if (!ctx.currentUser) conditions.push(eq(videos.premium, false));
|
||||
if (args.featured !== null && args.featured !== undefined) {
|
||||
conditions.push(eq(videos.featured, args.featured));
|
||||
}
|
||||
if (args.search) {
|
||||
conditions.push(ilike(videos.title, `%${args.search}%`));
|
||||
}
|
||||
if (args.tag) {
|
||||
conditions.push(arrayContains(videos.tags, [args.tag]));
|
||||
}
|
||||
if (args.modelId) {
|
||||
const videoIds = await ctx.db
|
||||
.select({ video_id: video_models.video_id })
|
||||
.from(video_models)
|
||||
.where(eq(video_models.user_id, args.modelId));
|
||||
|
||||
if (videoIds.length === 0) return [];
|
||||
|
||||
query = ctx.db
|
||||
.select({ v: videos })
|
||||
.from(videos)
|
||||
.where(and(
|
||||
lte(videos.upload_date, new Date()),
|
||||
inArray(videos.id, videoIds.map((v: any) => v.video_id)),
|
||||
))
|
||||
.orderBy(desc(videos.upload_date));
|
||||
if (videoIds.length === 0) return { items: [], total: 0 };
|
||||
conditions.push(
|
||||
inArray(
|
||||
videos.id,
|
||||
videoIds.map((v) => v.video_id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (args.featured !== null && args.featured !== undefined) {
|
||||
query = ctx.db
|
||||
.select({ v: videos })
|
||||
.from(videos)
|
||||
.where(and(
|
||||
lte(videos.upload_date, new Date()),
|
||||
eq(videos.featured, args.featured),
|
||||
))
|
||||
.orderBy(desc(videos.upload_date));
|
||||
const order =
|
||||
args.sortBy === "most_liked"
|
||||
? desc(videos.likes_count)
|
||||
: args.sortBy === "most_played"
|
||||
? desc(videos.plays_count)
|
||||
: args.sortBy === "name"
|
||||
? asc(videos.title)
|
||||
: desc(videos.upload_date);
|
||||
|
||||
const where = and(...conditions);
|
||||
|
||||
// Duration filter requires JOIN to files table
|
||||
if (args.duration && args.duration !== "all") {
|
||||
const durationCond =
|
||||
args.duration === "short"
|
||||
? lt(files.duration, 600)
|
||||
: args.duration === "medium"
|
||||
? and(gte(files.duration, 600), lt(files.duration, 1200))
|
||||
: gte(files.duration, 1200);
|
||||
|
||||
const fullWhere = and(where, durationCond);
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
ctx.db
|
||||
.select({ v: videos })
|
||||
.from(videos)
|
||||
.leftJoin(files, eq(videos.movie, files.id))
|
||||
.where(fullWhere)
|
||||
.orderBy(order)
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
ctx.db
|
||||
.select({ total: count() })
|
||||
.from(videos)
|
||||
.leftJoin(files, eq(videos.movie, files.id))
|
||||
.where(fullWhere),
|
||||
]);
|
||||
const videoList = rows.map((r) => r.v);
|
||||
const items = await Promise.all(videoList.map((v) => enrichVideo(ctx.db, v)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
if (args.limit) {
|
||||
query = (query as any).limit(args.limit);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
const videoList = rows.map((r: any) => r.v || r);
|
||||
return Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset),
|
||||
ctx.db.select({ total: count() }).from(videos).where(where),
|
||||
]);
|
||||
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -107,6 +191,27 @@ builder.queryField("video", (t) =>
|
||||
.limit(1);
|
||||
|
||||
if (!video[0]) return null;
|
||||
|
||||
if (video[0].premium && !ctx.currentUser) {
|
||||
throw new GraphQLError("Unauthorized");
|
||||
}
|
||||
|
||||
return enrichVideo(ctx.db, video[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("adminGetVideo", (t) =>
|
||||
t.field({
|
||||
type: VideoType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const video = await ctx.db.select().from(videos).where(eq(videos.id, args.id)).limit(1);
|
||||
if (!video[0]) return null;
|
||||
return enrichVideo(ctx.db, video[0]);
|
||||
},
|
||||
}),
|
||||
@@ -123,7 +228,9 @@ builder.queryField("videoLikeStatus", (t) =>
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(video_likes)
|
||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
||||
.where(
|
||||
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||
)
|
||||
.limit(1);
|
||||
return { liked: existing.length > 0 };
|
||||
},
|
||||
@@ -142,7 +249,9 @@ builder.mutationField("likeVideo", (t) =>
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(video_likes)
|
||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
||||
.where(
|
||||
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) throw new GraphQLError("Already liked");
|
||||
@@ -154,10 +263,22 @@ builder.mutationField("likeVideo", (t) =>
|
||||
|
||||
await ctx.db
|
||||
.update(videos)
|
||||
.set({ likes_count: (await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number + 1 || 1 })
|
||||
.set({
|
||||
likes_count:
|
||||
((
|
||||
await ctx.db
|
||||
.select({ c: videos.likes_count })
|
||||
.from(videos)
|
||||
.where(eq(videos.id, args.videoId))
|
||||
.limit(1)
|
||||
)[0]?.c as number) + 1 || 1,
|
||||
})
|
||||
.where(eq(videos.id, args.videoId));
|
||||
|
||||
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
|
||||
const likesCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(video_likes)
|
||||
.where(eq(video_likes.video_id, args.videoId));
|
||||
return { liked: true, likes_count: likesCount[0]?.count || 1 };
|
||||
},
|
||||
}),
|
||||
@@ -175,21 +296,39 @@ builder.mutationField("unlikeVideo", (t) =>
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(video_likes)
|
||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)))
|
||||
.where(
|
||||
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) throw new GraphQLError("Not liked");
|
||||
|
||||
await ctx.db
|
||||
.delete(video_likes)
|
||||
.where(and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)));
|
||||
.where(
|
||||
and(eq(video_likes.video_id, args.videoId), eq(video_likes.user_id, ctx.currentUser.id)),
|
||||
);
|
||||
|
||||
await ctx.db
|
||||
.update(videos)
|
||||
.set({ likes_count: Math.max(((await ctx.db.select({ c: videos.likes_count }).from(videos).where(eq(videos.id, args.videoId)).limit(1))[0]?.c as number || 1) - 1, 0) })
|
||||
.set({
|
||||
likes_count: Math.max(
|
||||
(((
|
||||
await ctx.db
|
||||
.select({ c: videos.likes_count })
|
||||
.from(videos)
|
||||
.where(eq(videos.id, args.videoId))
|
||||
.limit(1)
|
||||
)[0]?.c as number) || 1) - 1,
|
||||
0,
|
||||
),
|
||||
})
|
||||
.where(eq(videos.id, args.videoId));
|
||||
|
||||
const likesCount = await ctx.db.select({ count: count() }).from(video_likes).where(eq(video_likes.video_id, args.videoId));
|
||||
const likesCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(video_likes)
|
||||
.where(eq(video_likes.video_id, args.videoId));
|
||||
return { liked: false, likes_count: likesCount[0]?.count || 0 };
|
||||
},
|
||||
}),
|
||||
@@ -203,13 +342,19 @@ builder.mutationField("recordVideoPlay", (t) =>
|
||||
sessionId: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const play = await ctx.db.insert(video_plays).values({
|
||||
video_id: args.videoId,
|
||||
user_id: ctx.currentUser?.id || null,
|
||||
session_id: args.sessionId || null,
|
||||
}).returning({ id: video_plays.id });
|
||||
const play = await ctx.db
|
||||
.insert(video_plays)
|
||||
.values({
|
||||
video_id: args.videoId,
|
||||
user_id: ctx.currentUser?.id || null,
|
||||
session_id: args.sessionId || null,
|
||||
})
|
||||
.returning({ id: video_plays.id });
|
||||
|
||||
const playsCount = await ctx.db.select({ count: count() }).from(video_plays).where(eq(video_plays.video_id, args.videoId));
|
||||
const playsCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(video_plays)
|
||||
.where(eq(video_plays.video_id, args.videoId));
|
||||
|
||||
await ctx.db
|
||||
.update(videos)
|
||||
@@ -235,9 +380,26 @@ builder.mutationField("updateVideoPlay", (t) =>
|
||||
completed: t.arg.boolean({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const play = await ctx.db
|
||||
.select()
|
||||
.from(video_plays)
|
||||
.where(eq(video_plays.id, args.playId))
|
||||
.limit(1);
|
||||
|
||||
if (!play[0]) return false;
|
||||
|
||||
// If play belongs to a user, verify ownership
|
||||
if (play[0].user_id && (!ctx.currentUser || play[0].user_id !== ctx.currentUser.id)) {
|
||||
throw new GraphQLError("Forbidden");
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(video_plays)
|
||||
.set({ duration_watched: args.durationWatched, completed: args.completed, date_updated: new Date() })
|
||||
.set({
|
||||
duration_watched: args.durationWatched,
|
||||
completed: args.completed,
|
||||
date_updated: new Date(),
|
||||
})
|
||||
.where(eq(video_plays.id, args.playId));
|
||||
return true;
|
||||
},
|
||||
@@ -262,25 +424,38 @@ builder.queryField("analytics", (t) =>
|
||||
.where(eq(video_models.user_id, userId));
|
||||
|
||||
if (modelVideoIds.length === 0) {
|
||||
return { total_videos: 0, total_likes: 0, total_plays: 0, plays_by_date: {}, likes_by_date: {}, videos: [] };
|
||||
return {
|
||||
total_videos: 0,
|
||||
total_likes: 0,
|
||||
total_plays: 0,
|
||||
plays_by_date: {},
|
||||
likes_by_date: {},
|
||||
videos: [],
|
||||
};
|
||||
}
|
||||
|
||||
const videoIds = modelVideoIds.map((v: any) => v.video_id);
|
||||
const videoIds = modelVideoIds.map((v) => v.video_id);
|
||||
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
|
||||
const plays = await ctx.db.select().from(video_plays).where(inArray(video_plays.video_id, videoIds));
|
||||
const likes = await ctx.db.select().from(video_likes).where(inArray(video_likes.video_id, videoIds));
|
||||
const plays = await ctx.db
|
||||
.select()
|
||||
.from(video_plays)
|
||||
.where(inArray(video_plays.video_id, videoIds));
|
||||
const likes = await ctx.db
|
||||
.select()
|
||||
.from(video_likes)
|
||||
.where(inArray(video_likes.video_id, videoIds));
|
||||
|
||||
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
||||
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
|
||||
|
||||
const playsByDate = plays.reduce((acc: any, play) => {
|
||||
const playsByDate = plays.reduce((acc: Record<string, number>, play) => {
|
||||
const date = new Date(play.date_created).toISOString().split("T")[0];
|
||||
if (!acc[date]) acc[date] = 0;
|
||||
acc[date]++;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const likesByDate = likes.reduce((acc: any, like) => {
|
||||
const likesByDate = likes.reduce((acc: Record<string, number>, like) => {
|
||||
const date = new Date(like.date_created).toISOString().split("T")[0];
|
||||
if (!acc[date]) acc[date] = 0;
|
||||
acc[date]++;
|
||||
@@ -290,9 +465,10 @@ builder.queryField("analytics", (t) =>
|
||||
const videoAnalytics = videoList.map((video) => {
|
||||
const vPlays = plays.filter((p) => p.video_id === video.id);
|
||||
const completedPlays = vPlays.filter((p) => p.completed).length;
|
||||
const avgWatchTime = vPlays.length > 0
|
||||
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
|
||||
: 0;
|
||||
const avgWatchTime =
|
||||
vPlays.length > 0
|
||||
? vPlays.reduce((sum, p) => sum + (p.duration_watched || 0), 0) / vPlays.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: video.id,
|
||||
@@ -318,3 +494,157 @@ builder.queryField("analytics", (t) =>
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||
|
||||
builder.queryField("adminListVideos", (t) =>
|
||||
t.field({
|
||||
type: AdminVideoListType,
|
||||
args: {
|
||||
search: t.arg.string(),
|
||||
premium: t.arg.boolean(),
|
||||
featured: t.arg.boolean(),
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if (args.search) conditions.push(ilike(videos.title, `%${args.search}%`));
|
||||
if (args.premium !== null && args.premium !== undefined)
|
||||
conditions.push(eq(videos.premium, args.premium));
|
||||
if (args.featured !== null && args.featured !== undefined)
|
||||
conditions.push(eq(videos.featured, args.featured));
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
ctx.db
|
||||
.select()
|
||||
.from(videos)
|
||||
.where(where)
|
||||
.orderBy(desc(videos.upload_date))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(videos).where(where),
|
||||
]);
|
||||
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("createVideo", (t) =>
|
||||
t.field({
|
||||
type: VideoType,
|
||||
args: {
|
||||
title: t.arg.string({ required: true }),
|
||||
slug: t.arg.string({ required: true }),
|
||||
description: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
movieId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
premium: t.arg.boolean(),
|
||||
featured: t.arg.boolean(),
|
||||
uploadDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const inserted = await ctx.db
|
||||
.insert(videos)
|
||||
.values({
|
||||
title: args.title,
|
||||
slug: args.slug,
|
||||
description: args.description || null,
|
||||
image: args.imageId || null,
|
||||
movie: args.movieId || null,
|
||||
tags: args.tags || [],
|
||||
premium: args.premium ?? false,
|
||||
featured: args.featured ?? false,
|
||||
upload_date: args.uploadDate ? new Date(args.uploadDate) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
return enrichVideo(ctx.db, inserted[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("updateVideo", (t) =>
|
||||
t.field({
|
||||
type: VideoType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
title: t.arg.string(),
|
||||
slug: t.arg.string(),
|
||||
description: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
movieId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
premium: t.arg.boolean(),
|
||||
featured: t.arg.boolean(),
|
||||
uploadDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (args.title !== undefined && args.title !== null) updates.title = args.title;
|
||||
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
|
||||
if (args.description !== undefined) updates.description = args.description;
|
||||
if (args.imageId !== undefined) updates.image = args.imageId;
|
||||
if (args.movieId !== undefined) updates.movie = args.movieId;
|
||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||
if (args.premium !== undefined && args.premium !== null) updates.premium = args.premium;
|
||||
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
|
||||
if (args.uploadDate !== undefined && args.uploadDate !== null)
|
||||
updates.upload_date = new Date(args.uploadDate);
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(videos)
|
||||
.set(updates as Partial<typeof videos.$inferInsert>)
|
||||
.where(eq(videos.id, args.id))
|
||||
.returning();
|
||||
if (!updated[0]) return null;
|
||||
return enrichVideo(ctx.db, updated[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("deleteVideo", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.delete(videos).where(eq(videos.id, args.id));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("setVideoModels", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
videoId: t.arg.string({ required: true }),
|
||||
userIds: t.arg.stringList({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
await ctx.db.delete(video_models).where(eq(video_models.video_id, args.videoId));
|
||||
if (args.userIds.length > 0) {
|
||||
await ctx.db.insert(video_models).values(
|
||||
args.userIds.map((userId) => ({
|
||||
video_id: args.videoId,
|
||||
user_id: userId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { builder } from "../builder.js";
|
||||
import type {
|
||||
MediaFile,
|
||||
User,
|
||||
VideoModel,
|
||||
VideoFile,
|
||||
Video,
|
||||
ModelPhoto,
|
||||
Model,
|
||||
Article,
|
||||
CommentUser,
|
||||
Comment,
|
||||
Stats,
|
||||
Recording,
|
||||
VideoLikeStatus,
|
||||
VideoLikeResponse,
|
||||
VideoPlayResponse,
|
||||
VideoAnalytics,
|
||||
Analytics,
|
||||
LeaderboardEntry,
|
||||
UserStats,
|
||||
UserAchievement,
|
||||
RecentPoint,
|
||||
UserGamification,
|
||||
Achievement,
|
||||
} from "@sexy.pivoine.art/types";
|
||||
|
||||
// File type
|
||||
export const FileType = builder.objectRef<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
filename: string;
|
||||
mime_type: string | null;
|
||||
filesize: number | null;
|
||||
duration: number | null;
|
||||
uploaded_by: string | null;
|
||||
date_created: Date;
|
||||
}>("File").implement({
|
||||
type AdminUserDetail = User & { photos: ModelPhoto[] };
|
||||
import { builder } from "../builder";
|
||||
|
||||
export const FileType = builder.objectRef<MediaFile>("File").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
title: t.exposeString("title", { nullable: true }),
|
||||
@@ -25,22 +41,7 @@ export const FileType = builder.objectRef<{
|
||||
}),
|
||||
});
|
||||
|
||||
// User type
|
||||
export const UserType = builder.objectRef<{
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
artist_name: string | null;
|
||||
slug: string | null;
|
||||
description: string | null;
|
||||
tags: string[] | null;
|
||||
role: "model" | "viewer" | "admin";
|
||||
avatar: string | null;
|
||||
banner: string | null;
|
||||
email_verified: boolean;
|
||||
date_created: Date;
|
||||
}>("User").implement({
|
||||
export const UserType = builder.objectRef<User>("User").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
email: t.exposeString("email"),
|
||||
@@ -51,29 +52,17 @@ export const UserType = builder.objectRef<{
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
tags: t.exposeStringList("tags", { nullable: true }),
|
||||
role: t.exposeString("role"),
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
photo: t.exposeString("photo", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
}),
|
||||
});
|
||||
|
||||
// CurrentUser type (same shape, used for auth context)
|
||||
export const CurrentUserType = builder.objectRef<{
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
artist_name: string | null;
|
||||
slug: string | null;
|
||||
description: string | null;
|
||||
tags: string[] | null;
|
||||
role: "model" | "viewer" | "admin";
|
||||
avatar: string | null;
|
||||
banner: string | null;
|
||||
email_verified: boolean;
|
||||
date_created: Date;
|
||||
}>("CurrentUser").implement({
|
||||
// CurrentUser is the same shape as User
|
||||
export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
email: t.exposeString("email"),
|
||||
@@ -84,30 +73,35 @@ export const CurrentUserType = builder.objectRef<{
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
tags: t.exposeStringList("tags", { nullable: true }),
|
||||
role: t.exposeString("role"),
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
photo: t.exposeString("photo", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Video type
|
||||
export const VideoType = builder.objectRef<{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
movie: string | null;
|
||||
tags: string[] | null;
|
||||
upload_date: Date;
|
||||
premium: boolean | null;
|
||||
featured: boolean | null;
|
||||
likes_count: number | null;
|
||||
plays_count: number | null;
|
||||
models?: { id: string; artist_name: string | null; slug: string | null; avatar: string | null }[];
|
||||
movie_file?: { id: string; filename: string; mime_type: string | null; duration: number | null } | null;
|
||||
}>("Video").implement({
|
||||
export const VideoModelType = builder.objectRef<VideoModel>("VideoModel").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||
slug: t.exposeString("slug", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoFileType = builder.objectRef<VideoFile>("VideoFile").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
filename: t.exposeString("filename"),
|
||||
mime_type: t.exposeString("mime_type", { nullable: true }),
|
||||
duration: t.exposeInt("duration", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoType = builder.objectRef<Video>("Video").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
slug: t.exposeString("slug"),
|
||||
@@ -126,46 +120,14 @@ export const VideoType = builder.objectRef<{
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoModelType = builder.objectRef<{
|
||||
id: string;
|
||||
artist_name: string | null;
|
||||
slug: string | null;
|
||||
avatar: string | null;
|
||||
}>("VideoModel").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||
slug: t.exposeString("slug", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoFileType = builder.objectRef<{
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string | null;
|
||||
duration: number | null;
|
||||
}>("VideoFile").implement({
|
||||
export const ModelPhotoType = builder.objectRef<ModelPhoto>("ModelPhoto").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
filename: t.exposeString("filename"),
|
||||
mime_type: t.exposeString("mime_type", { nullable: true }),
|
||||
duration: t.exposeInt("duration", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Model type (model profile, enriched user)
|
||||
export const ModelType = builder.objectRef<{
|
||||
id: string;
|
||||
slug: string | null;
|
||||
artist_name: string | null;
|
||||
description: string | null;
|
||||
avatar: string | null;
|
||||
banner: string | null;
|
||||
tags: string[] | null;
|
||||
date_created: Date;
|
||||
photos?: { id: string; filename: string }[];
|
||||
}>("Model").implement({
|
||||
export const ModelType = builder.objectRef<Model>("Model").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
slug: t.exposeString("slug", { nullable: true }),
|
||||
@@ -173,36 +135,14 @@ export const ModelType = builder.objectRef<{
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
photo: t.exposeString("photo", { nullable: true }),
|
||||
tags: t.exposeStringList("tags", { nullable: true }),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ModelPhotoType = builder.objectRef<{
|
||||
id: string;
|
||||
filename: string;
|
||||
}>("ModelPhoto").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
filename: t.exposeString("filename"),
|
||||
}),
|
||||
});
|
||||
|
||||
// Article type
|
||||
export const ArticleType = builder.objectRef<{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string | null;
|
||||
image: string | null;
|
||||
tags: string[] | null;
|
||||
publish_date: Date;
|
||||
category: string | null;
|
||||
featured: boolean | null;
|
||||
author?: { first_name: string | null; last_name: string | null; avatar: string | null; description: string | null } | null;
|
||||
}>("Article").implement({
|
||||
export const ArticleType = builder.objectRef<Article>("Article").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
slug: t.exposeString("slug"),
|
||||
@@ -214,42 +154,41 @@ export const ArticleType = builder.objectRef<{
|
||||
publish_date: t.expose("publish_date", { type: "DateTime" }),
|
||||
category: t.exposeString("category", { nullable: true }),
|
||||
featured: t.exposeBoolean("featured", { nullable: true }),
|
||||
author: t.expose("author", { type: ArticleAuthorType, nullable: true }),
|
||||
author: t.expose("author", { type: VideoModelType, nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ArticleAuthorType = builder.objectRef<{
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar: string | null;
|
||||
description: string | null;
|
||||
}>("ArticleAuthor").implement({
|
||||
export const CommentUserType = builder.objectRef<CommentUser>("CommentUser").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
first_name: t.exposeString("first_name", { nullable: true }),
|
||||
last_name: t.exposeString("last_name", { nullable: true }),
|
||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Recording type
|
||||
export const RecordingType = builder.objectRef<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
slug: string;
|
||||
duration: number;
|
||||
events: object[] | null;
|
||||
device_info: object[] | null;
|
||||
user_id: string;
|
||||
status: string;
|
||||
tags: string[] | null;
|
||||
linked_video: string | null;
|
||||
featured: boolean | null;
|
||||
public: boolean | null;
|
||||
date_created: Date;
|
||||
date_updated: Date | null;
|
||||
}>("Recording").implement({
|
||||
export const CommentType = builder.objectRef<Comment>("Comment").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeInt("id"),
|
||||
collection: t.exposeString("collection"),
|
||||
item_id: t.exposeString("item_id"),
|
||||
comment: t.exposeString("comment"),
|
||||
user_id: t.exposeString("user_id"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
user: t.expose("user", { type: CommentUserType, nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const StatsType = builder.objectRef<Stats>("Stats").implement({
|
||||
fields: (t) => ({
|
||||
videos_count: t.exposeInt("videos_count"),
|
||||
models_count: t.exposeInt("models_count"),
|
||||
viewers_count: t.exposeInt("viewers_count"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const RecordingType = builder.objectRef<Recording>("Recording").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
title: t.exposeString("title"),
|
||||
@@ -269,237 +208,32 @@ export const RecordingType = builder.objectRef<{
|
||||
}),
|
||||
});
|
||||
|
||||
// Comment type
|
||||
export const CommentType = builder.objectRef<{
|
||||
id: number;
|
||||
collection: string;
|
||||
item_id: string;
|
||||
comment: string;
|
||||
user_id: string;
|
||||
date_created: Date;
|
||||
user?: { id: string; first_name: string | null; last_name: string | null; avatar: string | null } | null;
|
||||
}>("Comment").implement({
|
||||
export const VideoLikeResponseType = builder
|
||||
.objectRef<VideoLikeResponse>("VideoLikeResponse")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
liked: t.exposeBoolean("liked"),
|
||||
likes_count: t.exposeInt("likes_count"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoPlayResponseType = builder
|
||||
.objectRef<VideoPlayResponse>("VideoPlayResponse")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
success: t.exposeBoolean("success"),
|
||||
play_id: t.exposeString("play_id"),
|
||||
plays_count: t.exposeInt("plays_count"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoLikeStatusType = builder.objectRef<VideoLikeStatus>("VideoLikeStatus").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeInt("id"),
|
||||
collection: t.exposeString("collection"),
|
||||
item_id: t.exposeString("item_id"),
|
||||
comment: t.exposeString("comment"),
|
||||
user_id: t.exposeString("user_id"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
user: t.expose("user", { type: CommentUserType, nullable: true }),
|
||||
liked: t.exposeBoolean("liked"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CommentUserType = builder.objectRef<{
|
||||
id: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar: string | null;
|
||||
}>("CommentUser").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
first_name: t.exposeString("first_name", { nullable: true }),
|
||||
last_name: t.exposeString("last_name", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Stats type
|
||||
export const StatsType = builder.objectRef<{
|
||||
videos_count: number;
|
||||
models_count: number;
|
||||
viewers_count: number;
|
||||
}>("Stats").implement({
|
||||
fields: (t) => ({
|
||||
videos_count: t.exposeInt("videos_count"),
|
||||
models_count: t.exposeInt("models_count"),
|
||||
viewers_count: t.exposeInt("viewers_count"),
|
||||
}),
|
||||
});
|
||||
|
||||
// Gamification types
|
||||
export const LeaderboardEntryType = builder.objectRef<{
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
total_weighted_points: number | null;
|
||||
total_raw_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
}>("LeaderboardEntry").implement({
|
||||
fields: (t) => ({
|
||||
user_id: t.exposeString("user_id"),
|
||||
display_name: t.exposeString("display_name", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
|
||||
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
|
||||
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
|
||||
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
|
||||
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
|
||||
rank: t.exposeInt("rank"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AchievementType = builder.objectRef<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
category: string | null;
|
||||
required_count: number;
|
||||
points_reward: number;
|
||||
}>("Achievement").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
code: t.exposeString("code"),
|
||||
name: t.exposeString("name"),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
icon: t.exposeString("icon", { nullable: true }),
|
||||
category: t.exposeString("category", { nullable: true }),
|
||||
required_count: t.exposeInt("required_count"),
|
||||
points_reward: t.exposeInt("points_reward"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const UserGamificationType = builder.objectRef<{
|
||||
stats: {
|
||||
user_id: string;
|
||||
total_raw_points: number | null;
|
||||
total_weighted_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
comments_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
} | null;
|
||||
achievements: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
category: string | null;
|
||||
date_unlocked: Date;
|
||||
progress: number | null;
|
||||
required_count: number;
|
||||
}[];
|
||||
recent_points: {
|
||||
action: string;
|
||||
points: number;
|
||||
date_created: Date;
|
||||
recording_id: string | null;
|
||||
}[];
|
||||
}>("UserGamification").implement({
|
||||
fields: (t) => ({
|
||||
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
|
||||
achievements: t.expose("achievements", { type: [UserAchievementType] }),
|
||||
recent_points: t.expose("recent_points", { type: [RecentPointType] }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const UserStatsType = builder.objectRef<{
|
||||
user_id: string;
|
||||
total_raw_points: number | null;
|
||||
total_weighted_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
comments_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
}>("UserStats").implement({
|
||||
fields: (t) => ({
|
||||
user_id: t.exposeString("user_id"),
|
||||
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
|
||||
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
|
||||
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
|
||||
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
|
||||
comments_count: t.exposeInt("comments_count", { nullable: true }),
|
||||
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
|
||||
rank: t.exposeInt("rank"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const UserAchievementType = builder.objectRef<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
category: string | null;
|
||||
date_unlocked: Date;
|
||||
progress: number | null;
|
||||
required_count: number;
|
||||
}>("UserAchievement").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
code: t.exposeString("code"),
|
||||
name: t.exposeString("name"),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
icon: t.exposeString("icon", { nullable: true }),
|
||||
category: t.exposeString("category", { nullable: true }),
|
||||
date_unlocked: t.expose("date_unlocked", { type: "DateTime" }),
|
||||
progress: t.exposeInt("progress", { nullable: true }),
|
||||
required_count: t.exposeInt("required_count"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const RecentPointType = builder.objectRef<{
|
||||
action: string;
|
||||
points: number;
|
||||
date_created: Date;
|
||||
recording_id: string | null;
|
||||
}>("RecentPoint").implement({
|
||||
fields: (t) => ({
|
||||
action: t.exposeString("action"),
|
||||
points: t.exposeInt("points"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
recording_id: t.exposeString("recording_id", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Analytics types
|
||||
export const AnalyticsType = builder.objectRef<{
|
||||
total_videos: number;
|
||||
total_likes: number;
|
||||
total_plays: number;
|
||||
plays_by_date: Record<string, number>;
|
||||
likes_by_date: Record<string, number>;
|
||||
videos: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
upload_date: Date;
|
||||
likes: number;
|
||||
plays: number;
|
||||
completed_plays: number;
|
||||
completion_rate: number;
|
||||
avg_watch_time: number;
|
||||
}[];
|
||||
}>("Analytics").implement({
|
||||
fields: (t) => ({
|
||||
total_videos: t.exposeInt("total_videos"),
|
||||
total_likes: t.exposeInt("total_likes"),
|
||||
total_plays: t.exposeInt("total_plays"),
|
||||
plays_by_date: t.expose("plays_by_date", { type: "JSON" }),
|
||||
likes_by_date: t.expose("likes_by_date", { type: "JSON" }),
|
||||
videos: t.expose("videos", { type: [VideoAnalyticsType] }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoAnalyticsType = builder.objectRef<{
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
upload_date: Date;
|
||||
likes: number;
|
||||
plays: number;
|
||||
completed_plays: number;
|
||||
completion_rate: number;
|
||||
avg_watch_time: number;
|
||||
}>("VideoAnalytics").implement({
|
||||
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
title: t.exposeString("title"),
|
||||
@@ -513,33 +247,249 @@ export const VideoAnalyticsType = builder.objectRef<{
|
||||
}),
|
||||
});
|
||||
|
||||
// Response types
|
||||
export const VideoLikeResponseType = builder.objectRef<{
|
||||
liked: boolean;
|
||||
likes_count: number;
|
||||
}>("VideoLikeResponse").implement({
|
||||
export const AnalyticsType = builder.objectRef<Analytics>("Analytics").implement({
|
||||
fields: (t) => ({
|
||||
liked: t.exposeBoolean("liked"),
|
||||
likes_count: t.exposeInt("likes_count"),
|
||||
total_videos: t.exposeInt("total_videos"),
|
||||
total_likes: t.exposeInt("total_likes"),
|
||||
total_plays: t.exposeInt("total_plays"),
|
||||
plays_by_date: t.expose("plays_by_date", { type: "JSON" }),
|
||||
likes_by_date: t.expose("likes_by_date", { type: "JSON" }),
|
||||
videos: t.expose("videos", { type: [VideoAnalyticsType] }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoPlayResponseType = builder.objectRef<{
|
||||
success: boolean;
|
||||
play_id: string;
|
||||
plays_count: number;
|
||||
}>("VideoPlayResponse").implement({
|
||||
export const LeaderboardEntryType = builder
|
||||
.objectRef<LeaderboardEntry>("LeaderboardEntry")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
user_id: t.exposeString("user_id"),
|
||||
display_name: t.exposeString("display_name", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
|
||||
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
|
||||
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
|
||||
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
|
||||
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
|
||||
rank: t.exposeInt("rank"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const UserStatsType = builder.objectRef<UserStats>("UserStats").implement({
|
||||
fields: (t) => ({
|
||||
success: t.exposeBoolean("success"),
|
||||
play_id: t.exposeString("play_id"),
|
||||
plays_count: t.exposeInt("plays_count"),
|
||||
user_id: t.exposeString("user_id"),
|
||||
total_raw_points: t.exposeInt("total_raw_points", { nullable: true }),
|
||||
total_weighted_points: t.exposeFloat("total_weighted_points", { nullable: true }),
|
||||
recordings_count: t.exposeInt("recordings_count", { nullable: true }),
|
||||
playbacks_count: t.exposeInt("playbacks_count", { nullable: true }),
|
||||
comments_count: t.exposeInt("comments_count", { nullable: true }),
|
||||
achievements_count: t.exposeInt("achievements_count", { nullable: true }),
|
||||
rank: t.exposeInt("rank"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoLikeStatusType = builder.objectRef<{
|
||||
liked: boolean;
|
||||
}>("VideoLikeStatus").implement({
|
||||
export const UserAchievementType = builder.objectRef<UserAchievement>("UserAchievement").implement({
|
||||
fields: (t) => ({
|
||||
liked: t.exposeBoolean("liked"),
|
||||
id: t.exposeString("id"),
|
||||
code: t.exposeString("code"),
|
||||
name: t.exposeString("name"),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
icon: t.exposeString("icon", { nullable: true }),
|
||||
category: t.exposeString("category", { nullable: true }),
|
||||
date_unlocked: t.expose("date_unlocked", { type: "DateTime" }),
|
||||
progress: t.exposeInt("progress", { nullable: true }),
|
||||
required_count: t.exposeInt("required_count"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const RecentPointType = builder.objectRef<RecentPoint>("RecentPoint").implement({
|
||||
fields: (t) => ({
|
||||
action: t.exposeString("action"),
|
||||
points: t.exposeInt("points"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
recording_id: t.exposeString("recording_id", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const UserGamificationType = builder
|
||||
.objectRef<UserGamification>("UserGamification")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
stats: t.expose("stats", { type: UserStatsType, nullable: true }),
|
||||
achievements: t.expose("achievements", { type: [UserAchievementType] }),
|
||||
recent_points: t.expose("recent_points", { type: [RecentPointType] }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AchievementType = builder.objectRef<Achievement>("Achievement").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
code: t.exposeString("code"),
|
||||
name: t.exposeString("name"),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
icon: t.exposeString("icon", { nullable: true }),
|
||||
category: t.exposeString("category", { nullable: true }),
|
||||
required_count: t.exposeInt("required_count"),
|
||||
points_reward: t.exposeInt("points_reward"),
|
||||
}),
|
||||
});
|
||||
|
||||
// --- Queue / Job types (admin only, not in shared types package) ---
|
||||
|
||||
type JobCounts = {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
paused: number;
|
||||
};
|
||||
|
||||
type JobData = {
|
||||
id: string;
|
||||
name: string;
|
||||
queue: string;
|
||||
status: string;
|
||||
data: unknown;
|
||||
result: unknown;
|
||||
failedReason: string | null;
|
||||
attemptsMade: number;
|
||||
createdAt: Date;
|
||||
processedAt: Date | null;
|
||||
finishedAt: Date | null;
|
||||
progress: number | null;
|
||||
};
|
||||
|
||||
type QueueInfoData = {
|
||||
name: string;
|
||||
counts: JobCounts;
|
||||
isPaused: boolean;
|
||||
};
|
||||
|
||||
export const JobCountsType = builder.objectRef<JobCounts>("JobCounts").implement({
|
||||
fields: (t) => ({
|
||||
waiting: t.exposeInt("waiting"),
|
||||
active: t.exposeInt("active"),
|
||||
completed: t.exposeInt("completed"),
|
||||
failed: t.exposeInt("failed"),
|
||||
delayed: t.exposeInt("delayed"),
|
||||
paused: t.exposeInt("paused"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const JobType = builder.objectRef<JobData>("Job").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
name: t.exposeString("name"),
|
||||
queue: t.exposeString("queue"),
|
||||
status: t.exposeString("status"),
|
||||
data: t.expose("data", { type: "JSON" }),
|
||||
result: t.expose("result", { type: "JSON", nullable: true }),
|
||||
failedReason: t.exposeString("failedReason", { nullable: true }),
|
||||
attemptsMade: t.exposeInt("attemptsMade"),
|
||||
createdAt: t.expose("createdAt", { type: "DateTime" }),
|
||||
processedAt: t.expose("processedAt", { type: "DateTime", nullable: true }),
|
||||
finishedAt: t.expose("finishedAt", { type: "DateTime", nullable: true }),
|
||||
progress: t.exposeFloat("progress", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const QueueInfoType = builder.objectRef<QueueInfoData>("QueueInfo").implement({
|
||||
fields: (t) => ({
|
||||
name: t.exposeString("name"),
|
||||
counts: t.expose("counts", { type: JobCountsType }),
|
||||
isPaused: t.exposeBoolean("isPaused"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoListType = builder
|
||||
.objectRef<{ items: Video[]; total: number }>("VideoList")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
items: t.expose("items", { type: [VideoType] }),
|
||||
total: t.exposeInt("total"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ArticleListType = builder
|
||||
.objectRef<{ items: Article[]; total: number }>("ArticleList")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
items: t.expose("items", { type: [ArticleType] }),
|
||||
total: t.exposeInt("total"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ModelListType = builder
|
||||
.objectRef<{ items: Model[]; total: number }>("ModelList")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
items: t.expose("items", { type: [ModelType] }),
|
||||
total: t.exposeInt("total"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AdminVideoListType = builder
|
||||
.objectRef<{ items: Video[]; total: number }>("AdminVideoList")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
items: t.expose("items", { type: [VideoType] }),
|
||||
total: t.exposeInt("total"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AdminArticleListType = builder
|
||||
.objectRef<{ items: Article[]; total: number }>("AdminArticleList")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
items: t.expose("items", { type: [ArticleType] }),
|
||||
total: t.exposeInt("total"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AdminCommentListType = builder
|
||||
.objectRef<{ items: Comment[]; total: number }>("AdminCommentList")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
items: t.expose("items", { type: [CommentType] }),
|
||||
total: t.exposeInt("total"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AdminRecordingListType = builder
|
||||
.objectRef<{ items: Recording[]; total: number }>("AdminRecordingList")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
items: t.expose("items", { type: [RecordingType] }),
|
||||
total: t.exposeInt("total"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AdminUserListType = builder
|
||||
.objectRef<{ items: User[]; total: number }>("AdminUserList")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
items: t.expose("items", { type: [UserType] }),
|
||||
total: t.exposeInt("total"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUserDetail").implement({
|
||||
fields: (t) => ({
|
||||
id: t.exposeString("id"),
|
||||
email: t.exposeString("email"),
|
||||
first_name: t.exposeString("first_name", { nullable: true }),
|
||||
last_name: t.exposeString("last_name", { nullable: true }),
|
||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||
slug: t.exposeString("slug", { nullable: true }),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
tags: t.exposeStringList("tags", { nullable: true }),
|
||||
role: t.exposeString("role"),
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
photo: t.exposeString("photo", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
photos: t.expose("photos", { type: [ModelPhotoType] }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,87 +1,203 @@
|
||||
import Fastify from "fastify";
|
||||
import Fastify, { type FastifyRequest, type FastifyReply } from "fastify";
|
||||
import fastifyCookie from "@fastify/cookie";
|
||||
import fastifyCors from "@fastify/cors";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { createYoga } from "graphql-yoga";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { files } from "./db/schema/index";
|
||||
import path from "path";
|
||||
import { schema } from "./graphql/index.js";
|
||||
import { buildContext } from "./graphql/context.js";
|
||||
import { db } from "./db/connection.js";
|
||||
import { redis } from "./lib/auth.js";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { writeFile, rm } from "fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { schema } from "./graphql/index";
|
||||
import { buildContext } from "./graphql/context";
|
||||
import { db } from "./db/connection";
|
||||
import { redis } from "./lib/auth";
|
||||
import { logger } from "./lib/logger";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { startMailWorker } from "./queues/workers/mail";
|
||||
import { startGamificationWorker } from "./queues/workers/gamification";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "4000");
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
},
|
||||
});
|
||||
async function main() {
|
||||
// Run pending DB migrations before starting the server
|
||||
const migrationsFolder = path.join(__dirname, "migrations");
|
||||
logger.info(`Running migrations from ${migrationsFolder}`);
|
||||
await migrate(db, { migrationsFolder });
|
||||
logger.info("Migrations complete");
|
||||
|
||||
await fastify.register(fastifyCookie, {
|
||||
secret: process.env.COOKIE_SECRET || "change-me-in-production",
|
||||
});
|
||||
// Start background workers
|
||||
startMailWorker();
|
||||
startGamificationWorker();
|
||||
logger.info("Queue workers started");
|
||||
|
||||
await fastify.register(fastifyCors, {
|
||||
origin: CORS_ORIGIN,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
});
|
||||
const fastify = Fastify({ loggerInstance: logger });
|
||||
|
||||
await fastify.register(fastifyMultipart, {
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 * 1024, // 5 GB
|
||||
},
|
||||
});
|
||||
await fastify.register(fastifyCookie, {
|
||||
secret: process.env.COOKIE_SECRET || "change-me-in-production",
|
||||
});
|
||||
|
||||
await fastify.register(fastifyStatic, {
|
||||
root: path.resolve(UPLOAD_DIR),
|
||||
prefix: "/assets/",
|
||||
decorateReply: false,
|
||||
});
|
||||
await fastify.register(fastifyCors, {
|
||||
origin: CORS_ORIGIN,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
});
|
||||
|
||||
const yoga = createYoga({
|
||||
schema,
|
||||
context: buildContext,
|
||||
graphqlEndpoint: "/graphql",
|
||||
healthCheckEndpoint: "/health",
|
||||
logging: {
|
||||
debug: (...args) => fastify.log.debug(...args),
|
||||
info: (...args) => fastify.log.info(...args),
|
||||
warn: (...args) => fastify.log.warn(...args),
|
||||
error: (...args) => fastify.log.error(...args),
|
||||
},
|
||||
});
|
||||
await fastify.register(fastifyMultipart, {
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 * 1024, // 5 GB
|
||||
},
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
url: "/graphql",
|
||||
method: ["GET", "POST", "OPTIONS"],
|
||||
handler: async (request, reply) => {
|
||||
const response = await yoga.handleNodeRequestAndResponse(request, reply, {
|
||||
request,
|
||||
reply,
|
||||
db,
|
||||
redis,
|
||||
});
|
||||
reply.status(response.status);
|
||||
for (const [key, value] of response.headers.entries()) {
|
||||
reply.header(key, value);
|
||||
// fastify-static provides reply.sendFile(); files are stored as <UPLOAD_DIR>/<id>/<filename>
|
||||
await fastify.register(fastifyStatic, {
|
||||
root: path.resolve(UPLOAD_DIR),
|
||||
prefix: "/assets/",
|
||||
serve: false, // disable auto-serving; we use a custom route below
|
||||
decorateReply: true,
|
||||
});
|
||||
|
||||
const yoga = createYoga<{
|
||||
req: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
db: typeof db;
|
||||
redis: typeof redis;
|
||||
}>({
|
||||
schema,
|
||||
context: buildContext,
|
||||
graphqlEndpoint: "/graphql",
|
||||
healthCheckEndpoint: "/health",
|
||||
logging: {
|
||||
debug: (...args) => args.forEach((arg) => fastify.log.debug(arg)),
|
||||
info: (...args) => args.forEach((arg) => fastify.log.info(arg)),
|
||||
warn: (...args) => args.forEach((arg) => fastify.log.warn(arg)),
|
||||
error: (...args) => args.forEach((arg) => fastify.log.error(arg)),
|
||||
},
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
url: "/graphql",
|
||||
method: ["GET", "POST", "OPTIONS"],
|
||||
handler: (req, reply) =>
|
||||
yoga.handleNodeRequestAndResponse(req, reply, { req, reply, db, redis }),
|
||||
});
|
||||
|
||||
// Transform presets — only banner/thumbnail force a crop; others preserve aspect ratio
|
||||
const TRANSFORMS: Record<string, { width: number; height?: number; fit?: "cover" | "inside" }> = {
|
||||
mini: { width: 80, height: 80, fit: "cover" },
|
||||
thumbnail: { width: 300, height: 300, fit: "cover" },
|
||||
preview: { width: 800, fit: "inside" },
|
||||
medium: { width: 1400, fit: "inside" },
|
||||
banner: { width: 1600, height: 480, fit: "cover" },
|
||||
};
|
||||
|
||||
// Serve uploaded files: GET /assets/:id?transform=<preset>
|
||||
// Files are stored as <UPLOAD_DIR>/<id>/<filename> — look up filename in DB
|
||||
fastify.get("/assets/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { transform } = request.query as { transform?: string };
|
||||
|
||||
const result = await db
|
||||
.select({ filename: files.filename, mime_type: files.mime_type })
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!result[0]) return reply.status(404).send({ error: "File not found" });
|
||||
|
||||
const { filename, mime_type } = result[0];
|
||||
reply.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
|
||||
const preset = transform ? TRANSFORMS[transform] : null;
|
||||
if (preset && mime_type?.startsWith("image/")) {
|
||||
const cacheFile = path.join(UPLOAD_DIR, id, `${transform}.webp`);
|
||||
if (!existsSync(cacheFile)) {
|
||||
const originalPath = path.join(UPLOAD_DIR, id, filename);
|
||||
await sharp(originalPath)
|
||||
.resize({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
fit: preset.fit ?? "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: 92 })
|
||||
.toFile(cacheFile);
|
||||
}
|
||||
reply.header("Content-Type", "image/webp");
|
||||
return reply.sendFile(path.join(id, `${transform}.webp`));
|
||||
}
|
||||
return reply.send(response.body);
|
||||
},
|
||||
});
|
||||
|
||||
fastify.get("/health", async (_request, reply) => {
|
||||
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
reply.header("Content-Type", mime_type);
|
||||
return reply.sendFile(path.join(id, filename));
|
||||
});
|
||||
|
||||
try {
|
||||
await fastify.listen({ port: PORT, host: "0.0.0.0" });
|
||||
fastify.log.info(`Backend running at http://0.0.0.0:${PORT}`);
|
||||
fastify.log.info(`GraphQL at http://0.0.0.0:${PORT}/graphql`);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
// Upload a file: POST /upload (multipart, requires session)
|
||||
fastify.post("/upload", async (request, reply) => {
|
||||
const token = request.cookies["session_token"];
|
||||
if (!token) return reply.status(401).send({ error: "Unauthorized" });
|
||||
|
||||
const sessionData = await redis.get(`session:${token}`);
|
||||
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
|
||||
const { id: userId } = JSON.parse(sessionData);
|
||||
|
||||
const data = await request.file();
|
||||
if (!data) return reply.status(400).send({ error: "No file provided" });
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const filename = data.filename;
|
||||
const mime_type = data.mimetype;
|
||||
const dir = path.join(UPLOAD_DIR, id);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const buffer = await data.toBuffer();
|
||||
await writeFile(path.join(dir, filename), buffer);
|
||||
|
||||
const [file] = await db
|
||||
.insert(files)
|
||||
.values({ id, filename, mime_type, filesize: buffer.byteLength, uploaded_by: userId })
|
||||
.returning();
|
||||
|
||||
return reply.status(201).send(file);
|
||||
});
|
||||
|
||||
// Delete a file: DELETE /assets/:id (requires session)
|
||||
fastify.delete("/assets/:id", async (request, reply) => {
|
||||
const token = request.cookies["session_token"];
|
||||
if (!token) return reply.status(401).send({ error: "Unauthorized" });
|
||||
|
||||
const sessionData = await redis.get(`session:${token}`);
|
||||
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
|
||||
|
||||
const { id } = request.params as { id: string };
|
||||
const result = await db.select().from(files).where(eq(files.id, id)).limit(1);
|
||||
if (!result[0]) return reply.status(404).send({ error: "File not found" });
|
||||
|
||||
await db.delete(files).where(eq(files.id, id));
|
||||
const dir = path.join(UPLOAD_DIR, id);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
||||
return reply.status(200).send({ ok: true });
|
||||
});
|
||||
|
||||
fastify.get("/health", async (_request, reply) => {
|
||||
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
try {
|
||||
await fastify.listen({ port: PORT, host: "0.0.0.0" });
|
||||
fastify.log.info(`Backend running at http://0.0.0.0:${PORT}`);
|
||||
fastify.log.info(`GraphQL at http://0.0.0.0:${PORT}/graphql`);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
18
packages/backend/src/lib/acl.ts
Normal file
18
packages/backend/src/lib/acl.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import type { Context } from "../graphql/builder";
|
||||
|
||||
export function requireAuth(ctx: Context): void {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
}
|
||||
|
||||
export function requireAdmin(ctx: Context): void {
|
||||
requireAuth(ctx);
|
||||
if (!ctx.currentUser!.is_admin) throw new GraphQLError("Forbidden");
|
||||
}
|
||||
|
||||
export function requireOwnerOrAdmin(ctx: Context, ownerId: string): void {
|
||||
requireAuth(ctx);
|
||||
if (ctx.currentUser!.id !== ownerId && !ctx.currentUser!.is_admin) {
|
||||
throw new GraphQLError("Forbidden");
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import Redis from "ioredis";
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "model" | "viewer" | "admin";
|
||||
role: "model" | "viewer";
|
||||
is_admin: boolean;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
artist_name: string | null;
|
||||
@@ -20,6 +21,8 @@ export async function setSession(token: string, user: SessionUser): Promise<void
|
||||
export async function getSession(token: string): Promise<SessionUser | null> {
|
||||
const data = await redis.get(`session:${token}`);
|
||||
if (!data) return null;
|
||||
// Slide the expiration window on every access
|
||||
await redis.expire(`session:${token}`, 86400);
|
||||
return JSON.parse(data) as SessionUser;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { mailQueue } from "../queues/index.js";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || "localhost",
|
||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth: process.env.SMTP_USER ? {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
} : undefined,
|
||||
auth: process.env.SMTP_USER
|
||||
? {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
|
||||
@@ -30,3 +33,13 @@ export async function sendPasswordReset(email: string, token: string): Promise<v
|
||||
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
const jobOpts = { attempts: 3, backoff: { type: "exponential" as const, delay: 5000 } };
|
||||
|
||||
export async function enqueueVerification(email: string, token: string): Promise<void> {
|
||||
await mailQueue.add("sendVerification", { email, token }, jobOpts);
|
||||
}
|
||||
|
||||
export async function enqueuePasswordReset(email: string, token: string): Promise<void> {
|
||||
await mailQueue.add("sendPasswordReset", { email, token }, jobOpts);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
|
||||
import type { DB } from "../db/connection.js";
|
||||
import { eq, sql, and, gt, isNull, isNotNull, count, sum } from "drizzle-orm";
|
||||
import type { DB } from "../db/connection";
|
||||
import {
|
||||
user_points,
|
||||
user_stats,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
user_achievements,
|
||||
achievements,
|
||||
users,
|
||||
} from "../db/schema/index.js";
|
||||
} from "../db/schema/index";
|
||||
|
||||
export const POINT_VALUES = {
|
||||
RECORDING_CREATE: 50,
|
||||
@@ -28,26 +28,62 @@ export async function awardPoints(
|
||||
recordingId?: string,
|
||||
): Promise<void> {
|
||||
const points = POINT_VALUES[action];
|
||||
await db.insert(user_points).values({
|
||||
user_id: userId,
|
||||
action,
|
||||
points,
|
||||
recording_id: recordingId || null,
|
||||
date_created: new Date(),
|
||||
});
|
||||
await db
|
||||
.insert(user_points)
|
||||
.values({
|
||||
user_id: userId,
|
||||
action,
|
||||
points,
|
||||
recording_id: recordingId || null,
|
||||
date_created: new Date(),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await updateUserStats(db, userId);
|
||||
}
|
||||
|
||||
export async function revokePoints(
|
||||
db: DB,
|
||||
userId: string,
|
||||
action: keyof typeof POINT_VALUES,
|
||||
recordingId?: string,
|
||||
): Promise<void> {
|
||||
const recordingCondition = recordingId
|
||||
? eq(user_points.recording_id, recordingId)
|
||||
: isNull(user_points.recording_id);
|
||||
|
||||
// When no recordingId (e.g. COMMENT_CREATE), delete only one row so each
|
||||
// revoke undoes exactly one prior award.
|
||||
if (!recordingId) {
|
||||
const row = await db
|
||||
.select({ id: user_points.id })
|
||||
.from(user_points)
|
||||
.where(
|
||||
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
|
||||
)
|
||||
.limit(1);
|
||||
if (row[0]) {
|
||||
await db.delete(user_points).where(eq(user_points.id, row[0].id));
|
||||
}
|
||||
} else {
|
||||
await db
|
||||
.delete(user_points)
|
||||
.where(
|
||||
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
|
||||
);
|
||||
}
|
||||
|
||||
await updateUserStats(db, userId);
|
||||
}
|
||||
|
||||
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
|
||||
const now = new Date();
|
||||
const result = await db.execute(sql`
|
||||
SELECT SUM(
|
||||
points * EXP(-${DECAY_LAMBDA} * EXTRACT(EPOCH FROM (${now}::timestamptz - date_created)) / 86400)
|
||||
points * EXP(${sql.raw(String(-DECAY_LAMBDA))} * EXTRACT(EPOCH FROM (NOW() - date_created)) / 86400)
|
||||
) as weighted_score
|
||||
FROM user_points
|
||||
WHERE user_id = ${userId}
|
||||
`);
|
||||
return parseFloat((result.rows[0] as any)?.weighted_score || "0");
|
||||
return parseFloat((result.rows[0] as { weighted_score?: string })?.weighted_score || "0");
|
||||
}
|
||||
|
||||
export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||
@@ -74,14 +110,17 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||
.where(eq(recordings.user_id, userId));
|
||||
const ownIds = ownRecordingIds.map((r) => r.id);
|
||||
|
||||
let playbacksCount = 0;
|
||||
let playbacksCount: number;
|
||||
if (ownIds.length > 0) {
|
||||
const playbacksResult = await db.execute(sql`
|
||||
SELECT COUNT(*) as count FROM recording_plays
|
||||
WHERE user_id = ${userId}
|
||||
AND recording_id NOT IN (${sql.join(ownIds.map(id => sql`${id}`), sql`, `)})
|
||||
AND recording_id NOT IN (${sql.join(
|
||||
ownIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
`);
|
||||
playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0");
|
||||
playbacksCount = parseInt((playbacksResult.rows[0] as { count?: string })?.count || "0");
|
||||
} else {
|
||||
const playbacksResult = await db
|
||||
.select({ count: count() })
|
||||
@@ -93,7 +132,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||
const commentsResult = await db
|
||||
.select({ count: count() })
|
||||
.from(comments)
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
|
||||
const commentsCount = commentsResult[0]?.count || 0;
|
||||
|
||||
const achievementsResult = await db
|
||||
@@ -135,11 +174,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAchievements(
|
||||
db: DB,
|
||||
userId: string,
|
||||
category?: string,
|
||||
): Promise<void> {
|
||||
export async function checkAchievements(db: DB, userId: string, category?: string): Promise<void> {
|
||||
let achievementsQuery = db
|
||||
.select()
|
||||
.from(achievements)
|
||||
@@ -176,7 +211,9 @@ export async function checkAchievements(
|
||||
.update(user_achievements)
|
||||
.set({
|
||||
progress,
|
||||
date_unlocked: isUnlocked ? (existing[0].date_unlocked || new Date()) : null,
|
||||
date_unlocked: isUnlocked
|
||||
? (existing[0].date_unlocked ?? new Date())
|
||||
: existing[0].date_unlocked,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
@@ -243,7 +280,7 @@ async function getAchievementProgress(
|
||||
WHERE rp.user_id = ${userId}
|
||||
AND r.user_id != ${userId}
|
||||
`);
|
||||
return parseInt((result.rows[0] as any)?.count || "0");
|
||||
return parseInt((result.rows[0] as { count?: string })?.count || "0");
|
||||
}
|
||||
|
||||
if (["completionist_10", "completionist_100"].includes(code)) {
|
||||
@@ -258,7 +295,7 @@ async function getAchievementProgress(
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(comments)
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
@@ -294,7 +331,7 @@ async function getAchievementProgress(
|
||||
WHERE rp.user_id = ${userId} AND r.user_id != ${userId}
|
||||
`);
|
||||
const rc = recordingsResult[0]?.count || 0;
|
||||
const pc = parseInt((playsResult.rows[0] as any)?.count || "0");
|
||||
const pc = parseInt((playsResult.rows[0] as { count?: string })?.count || "0");
|
||||
return rc >= 50 && pc >= 100 ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
101
packages/backend/src/lib/logger.ts
Normal file
101
packages/backend/src/lib/logger.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
|
||||
const LEVEL_VALUES: Record<LogLevel, number> = {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
info: 30,
|
||||
warn: 40,
|
||||
error: 50,
|
||||
fatal: 60,
|
||||
};
|
||||
|
||||
function createLogger(bindings: Record<string, unknown> = {}, initialLevel: LogLevel = "info") {
|
||||
let currentLevel = initialLevel;
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LEVEL_VALUES[level] >= LEVEL_VALUES[currentLevel];
|
||||
}
|
||||
|
||||
function formatMessage(level: LogLevel, arg: unknown, msg?: string): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
let message: string;
|
||||
const meta: Record<string, unknown> = { ...bindings };
|
||||
|
||||
if (typeof arg === "string") {
|
||||
message = arg;
|
||||
} else if (arg !== null && typeof arg === "object") {
|
||||
// Pino-style: log(obj, msg?) — strip internal pino keys
|
||||
const {
|
||||
msg: m,
|
||||
level: _l,
|
||||
time: _t,
|
||||
pid: _p,
|
||||
hostname: _h,
|
||||
req: _req,
|
||||
res: _res,
|
||||
reqId,
|
||||
...rest
|
||||
} = arg as Record<string, unknown>;
|
||||
message = msg || (typeof m === "string" ? m : "");
|
||||
if (reqId) meta.reqId = reqId;
|
||||
Object.assign(meta, rest);
|
||||
} else {
|
||||
message = String(arg ?? "");
|
||||
}
|
||||
|
||||
const parts = [`[${timestamp}]`, `[${level.toUpperCase()}]`, message];
|
||||
let result = parts.join(" ");
|
||||
|
||||
const metaEntries = Object.entries(meta).filter(([k]) => k !== "reqId");
|
||||
const reqId = meta.reqId;
|
||||
if (reqId) result = `[${timestamp}] [${level.toUpperCase()}] [${reqId}] ${message}`;
|
||||
|
||||
if (metaEntries.length > 0) {
|
||||
result += " " + JSON.stringify(Object.fromEntries(metaEntries));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function write(level: LogLevel, arg: unknown, msg?: string) {
|
||||
if (!shouldLog(level)) return;
|
||||
const formatted = formatMessage(level, arg, msg);
|
||||
switch (level) {
|
||||
case "trace":
|
||||
case "debug":
|
||||
console.debug(formatted);
|
||||
break;
|
||||
case "info":
|
||||
console.info(formatted);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(formatted);
|
||||
break;
|
||||
case "error":
|
||||
case "fatal":
|
||||
console.error(formatted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get level() {
|
||||
return currentLevel;
|
||||
},
|
||||
set level(l: string) {
|
||||
currentLevel = l as LogLevel;
|
||||
},
|
||||
trace: (arg: unknown, msg?: string) => write("trace", arg, msg),
|
||||
debug: (arg: unknown, msg?: string) => write("debug", arg, msg),
|
||||
info: (arg: unknown, msg?: string) => write("info", arg, msg),
|
||||
warn: (arg: unknown, msg?: string) => write("warn", arg, msg),
|
||||
error: (arg: unknown, msg?: string) => write("error", arg, msg),
|
||||
fatal: (arg: unknown, msg?: string) => write("fatal", arg, msg),
|
||||
silent: () => {},
|
||||
child: (newBindings: Record<string, unknown>) =>
|
||||
createLogger({ ...bindings, ...newBindings }, currentLevel),
|
||||
};
|
||||
}
|
||||
|
||||
export const logger = createLogger({}, (process.env.LOG_LEVEL as LogLevel) || "info");
|
||||
233
packages/backend/src/migrations/0000_pale_hellion.sql
Normal file
233
packages/backend/src/migrations/0000_pale_hellion.sql
Normal file
@@ -0,0 +1,233 @@
|
||||
CREATE TYPE "public"."achievement_status" AS ENUM('draft', 'published');--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_role" AS ENUM('model', 'viewer', 'admin');--> statement-breakpoint
|
||||
CREATE TYPE "public"."recording_status" AS ENUM('draft', 'published', 'archived');--> statement-breakpoint
|
||||
CREATE TABLE "articles" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"excerpt" text,
|
||||
"content" text,
|
||||
"image" text,
|
||||
"tags" text[] DEFAULT '{}',
|
||||
"publish_date" timestamp DEFAULT now() NOT NULL,
|
||||
"author" text,
|
||||
"category" text,
|
||||
"featured" boolean DEFAULT false,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL,
|
||||
"date_updated" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comments" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "comments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"collection" text NOT NULL,
|
||||
"item_id" text NOT NULL,
|
||||
"comment" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL,
|
||||
"date_updated" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "files" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"title" text,
|
||||
"description" text,
|
||||
"filename" text NOT NULL,
|
||||
"mime_type" text,
|
||||
"filesize" bigint,
|
||||
"duration" integer,
|
||||
"uploaded_by" text,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "achievements" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"icon" text,
|
||||
"category" text,
|
||||
"required_count" integer DEFAULT 1 NOT NULL,
|
||||
"points_reward" integer DEFAULT 0 NOT NULL,
|
||||
"status" "achievement_status" DEFAULT 'published' NOT NULL,
|
||||
"sort" integer DEFAULT 0
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_achievements" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_achievements_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" text NOT NULL,
|
||||
"achievement_id" text NOT NULL,
|
||||
"progress" integer DEFAULT 0,
|
||||
"date_unlocked" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_points" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_points_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"points" integer NOT NULL,
|
||||
"recording_id" text,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_stats" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_stats_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" text NOT NULL,
|
||||
"total_raw_points" integer DEFAULT 0,
|
||||
"total_weighted_points" real DEFAULT 0,
|
||||
"recordings_count" integer DEFAULT 0,
|
||||
"playbacks_count" integer DEFAULT 0,
|
||||
"comments_count" integer DEFAULT 0,
|
||||
"achievements_count" integer DEFAULT 0,
|
||||
"last_updated" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_photos" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "user_photos_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" text NOT NULL,
|
||||
"file_id" text NOT NULL,
|
||||
"sort" integer DEFAULT 0
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"first_name" text,
|
||||
"last_name" text,
|
||||
"artist_name" text,
|
||||
"slug" text,
|
||||
"description" text,
|
||||
"tags" text[] DEFAULT '{}',
|
||||
"role" "user_role" DEFAULT 'viewer' NOT NULL,
|
||||
"avatar" text,
|
||||
"banner" text,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"email_verify_token" text,
|
||||
"password_reset_token" text,
|
||||
"password_reset_expiry" timestamp,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL,
|
||||
"date_updated" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "video_likes" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"video_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "video_models" (
|
||||
"video_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
CONSTRAINT "video_models_video_id_user_id_pk" PRIMARY KEY("video_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "video_plays" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"video_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"session_id" text,
|
||||
"duration_watched" integer,
|
||||
"completed" boolean DEFAULT false,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL,
|
||||
"date_updated" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "videos" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"image" text,
|
||||
"movie" text,
|
||||
"tags" text[] DEFAULT '{}',
|
||||
"upload_date" timestamp DEFAULT now() NOT NULL,
|
||||
"premium" boolean DEFAULT false,
|
||||
"featured" boolean DEFAULT false,
|
||||
"likes_count" integer DEFAULT 0,
|
||||
"plays_count" integer DEFAULT 0
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "recording_plays" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"recording_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"duration_played" integer DEFAULT 0,
|
||||
"completed" boolean DEFAULT false,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL,
|
||||
"date_updated" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "recordings" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"slug" text NOT NULL,
|
||||
"duration" integer NOT NULL,
|
||||
"events" jsonb DEFAULT '[]'::jsonb,
|
||||
"device_info" jsonb DEFAULT '[]'::jsonb,
|
||||
"user_id" text NOT NULL,
|
||||
"status" "recording_status" DEFAULT 'draft' NOT NULL,
|
||||
"tags" text[] DEFAULT '{}',
|
||||
"linked_video" text,
|
||||
"featured" boolean DEFAULT false,
|
||||
"public" boolean DEFAULT false,
|
||||
"original_recording_id" text,
|
||||
"date_created" timestamp DEFAULT now() NOT NULL,
|
||||
"date_updated" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "articles" ADD CONSTRAINT "articles_image_files_id_fk" FOREIGN KEY ("image") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "articles" ADD CONSTRAINT "articles_author_users_id_fk" FOREIGN KEY ("author") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_achievements" ADD CONSTRAINT "user_achievements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_achievements" ADD CONSTRAINT "user_achievements_achievement_id_achievements_id_fk" FOREIGN KEY ("achievement_id") REFERENCES "public"."achievements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_points" ADD CONSTRAINT "user_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_points" ADD CONSTRAINT "user_points_recording_id_recordings_id_fk" FOREIGN KEY ("recording_id") REFERENCES "public"."recordings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ADD CONSTRAINT "user_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_photos" ADD CONSTRAINT "user_photos_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_photos" ADD CONSTRAINT "user_photos_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_avatar_files_id_fk" FOREIGN KEY ("avatar") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_banner_files_id_fk" FOREIGN KEY ("banner") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "video_likes" ADD CONSTRAINT "video_likes_video_id_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."videos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "video_likes" ADD CONSTRAINT "video_likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "video_models" ADD CONSTRAINT "video_models_video_id_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."videos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "video_models" ADD CONSTRAINT "video_models_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "video_plays" ADD CONSTRAINT "video_plays_video_id_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."videos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "video_plays" ADD CONSTRAINT "video_plays_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "videos" ADD CONSTRAINT "videos_image_files_id_fk" FOREIGN KEY ("image") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "videos" ADD CONSTRAINT "videos_movie_files_id_fk" FOREIGN KEY ("movie") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recording_plays" ADD CONSTRAINT "recording_plays_recording_id_recordings_id_fk" FOREIGN KEY ("recording_id") REFERENCES "public"."recordings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recording_plays" ADD CONSTRAINT "recording_plays_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ADD CONSTRAINT "recordings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ADD CONSTRAINT "recordings_linked_video_videos_id_fk" FOREIGN KEY ("linked_video") REFERENCES "public"."videos"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "articles_slug_idx" ON "articles" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "articles_publish_date_idx" ON "articles" USING btree ("publish_date");--> statement-breakpoint
|
||||
CREATE INDEX "articles_featured_idx" ON "articles" USING btree ("featured");--> statement-breakpoint
|
||||
CREATE INDEX "comments_collection_item_idx" ON "comments" USING btree ("collection","item_id");--> statement-breakpoint
|
||||
CREATE INDEX "comments_user_idx" ON "comments" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "files_uploaded_by_idx" ON "files" USING btree ("uploaded_by");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "achievements_code_idx" ON "achievements" USING btree ("code");--> statement-breakpoint
|
||||
CREATE INDEX "user_achievements_user_idx" ON "user_achievements" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_achievements_unique_idx" ON "user_achievements" USING btree ("user_id","achievement_id");--> statement-breakpoint
|
||||
CREATE INDEX "user_points_user_idx" ON "user_points" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "user_points_date_idx" ON "user_points" USING btree ("date_created");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_stats_user_idx" ON "user_stats" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "user_photos_user_idx" ON "user_photos" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_slug_idx" ON "users" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "users_role_idx" ON "users" USING btree ("role");--> statement-breakpoint
|
||||
CREATE INDEX "video_likes_video_idx" ON "video_likes" USING btree ("video_id");--> statement-breakpoint
|
||||
CREATE INDEX "video_likes_user_idx" ON "video_likes" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "video_plays_video_idx" ON "video_plays" USING btree ("video_id");--> statement-breakpoint
|
||||
CREATE INDEX "video_plays_user_idx" ON "video_plays" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "video_plays_date_idx" ON "video_plays" USING btree ("date_created");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "videos_slug_idx" ON "videos" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "videos_upload_date_idx" ON "videos" USING btree ("upload_date");--> statement-breakpoint
|
||||
CREATE INDEX "videos_featured_idx" ON "videos" USING btree ("featured");--> statement-breakpoint
|
||||
CREATE INDEX "recording_plays_recording_idx" ON "recording_plays" USING btree ("recording_id");--> statement-breakpoint
|
||||
CREATE INDEX "recording_plays_user_idx" ON "recording_plays" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "recordings_slug_idx" ON "recordings" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "recordings_user_idx" ON "recordings" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "recordings_status_idx" ON "recordings" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "recordings_public_idx" ON "recordings" USING btree ("public");
|
||||
3
packages/backend/src/migrations/0001_is_admin.sql
Normal file
3
packages/backend/src/migrations/0001_is_admin.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;--> statement-breakpoint
|
||||
UPDATE "users" SET "is_admin" = true WHERE "role" = 'admin';--> statement-breakpoint
|
||||
UPDATE "users" SET "role" = 'viewer' WHERE "role" = 'admin';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Update any archived recordings to draft before removing the status
|
||||
UPDATE "recordings" SET "status" = 'draft' WHERE "status" = 'archived';--> statement-breakpoint
|
||||
|
||||
-- Recreate enum without 'archived'
|
||||
ALTER TYPE "public"."recording_status" RENAME TO "recording_status_old";--> statement-breakpoint
|
||||
CREATE TYPE "public"."recording_status" AS ENUM('draft', 'published');--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ALTER COLUMN "status" TYPE "public"."recording_status" USING "status"::text::"public"."recording_status";--> statement-breakpoint
|
||||
DROP TYPE "public"."recording_status_old";
|
||||
1
packages/backend/src/migrations/0003_model_photo.sql
Normal file
1
packages/backend/src/migrations/0003_model_photo.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "photo" text REFERENCES "files"("id") ON DELETE set null;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Partial unique index: prevents duplicate RECORDING_CREATE / RECORDING_FEATURED points
|
||||
-- for the same recording. RECORDING_PLAY / RECORDING_COMPLETE are excluded so a user
|
||||
-- can earn play points across multiple sessions.
|
||||
CREATE UNIQUE INDEX "user_points_unique_action_recording"
|
||||
ON "user_points" ("user_id", "action", "recording_id")
|
||||
WHERE "action" IN ('RECORDING_CREATE', 'RECORDING_FEATURED') AND "recording_id" IS NOT NULL;
|
||||
1931
packages/backend/src/migrations/meta/0000_snapshot.json
Normal file
1931
packages/backend/src/migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
34
packages/backend/src/migrations/meta/_journal.json
Normal file
34
packages/backend/src/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1772645674513,
|
||||
"tag": "0000_pale_hellion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1772645674514,
|
||||
"tag": "0001_is_admin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1741337600000,
|
||||
"tag": "0002_remove_archived_recording_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1741420000000,
|
||||
"tag": "0003_model_photo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
16
packages/backend/src/queues/connection.ts
Normal file
16
packages/backend/src/queues/connection.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
function parseRedisUrl(url: string): { host: string; port: number; password?: string } {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port) || 6379,
|
||||
password: parsed.password || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// BullMQ creates its own IORedis connections from these options.
|
||||
// maxRetriesPerRequest: null is required for workers.
|
||||
export const redisConnectionOpts = {
|
||||
...parseRedisUrl(process.env.REDIS_URL || "redis://localhost:6379"),
|
||||
maxRetriesPerRequest: null as null,
|
||||
enableReadyCheck: false,
|
||||
};
|
||||
25
packages/backend/src/queues/index.ts
Normal file
25
packages/backend/src/queues/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { redisConnectionOpts } from "./connection.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
|
||||
const log = logger.child({ component: "queues" });
|
||||
|
||||
export const mailQueue = new Queue("mail", { connection: redisConnectionOpts });
|
||||
mailQueue.on("error", (err) => {
|
||||
log.error({ queue: "mail", err: err.message }, "Queue error");
|
||||
});
|
||||
|
||||
export const gamificationQueue = new Queue("gamification", {
|
||||
connection: redisConnectionOpts,
|
||||
defaultJobOptions: { attempts: 3, backoff: { type: "exponential", delay: 2000 } },
|
||||
});
|
||||
gamificationQueue.on("error", (err) => {
|
||||
log.error({ queue: "gamification", err: err.message }, "Queue error");
|
||||
});
|
||||
|
||||
log.info("Queues initialized");
|
||||
|
||||
export const queues: Record<string, Queue> = {
|
||||
mail: mailQueue,
|
||||
gamification: gamificationQueue,
|
||||
};
|
||||
52
packages/backend/src/queues/workers/gamification.ts
Normal file
52
packages/backend/src/queues/workers/gamification.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Worker } from "bullmq";
|
||||
import { redisConnectionOpts } from "../connection.js";
|
||||
import { awardPoints, revokePoints, checkAchievements } from "../../lib/gamification.js";
|
||||
import { db } from "../../db/connection.js";
|
||||
import { logger } from "../../lib/logger.js";
|
||||
import type { POINT_VALUES } from "../../lib/gamification.js";
|
||||
|
||||
const log = logger.child({ component: "gamification-worker" });
|
||||
|
||||
export type GamificationJobData =
|
||||
| { job: "awardPoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
|
||||
| { job: "revokePoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
|
||||
| { job: "checkAchievements"; userId: string; category?: string };
|
||||
|
||||
export function startGamificationWorker(): Worker {
|
||||
const worker = new Worker(
|
||||
"gamification",
|
||||
async (bullJob) => {
|
||||
const data = bullJob.data as GamificationJobData;
|
||||
log.info(
|
||||
{ jobId: bullJob.id, job: data.job, userId: data.userId },
|
||||
"Processing gamification job",
|
||||
);
|
||||
|
||||
switch (data.job) {
|
||||
case "awardPoints":
|
||||
await awardPoints(db, data.userId, data.action, data.recordingId);
|
||||
break;
|
||||
case "revokePoints":
|
||||
await revokePoints(db, data.userId, data.action, data.recordingId);
|
||||
break;
|
||||
case "checkAchievements":
|
||||
await checkAchievements(db, data.userId, data.category);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown gamification job: ${(data as GamificationJobData).job}`);
|
||||
}
|
||||
|
||||
log.info({ jobId: bullJob.id, job: data.job }, "Gamification job completed");
|
||||
},
|
||||
{ connection: redisConnectionOpts },
|
||||
);
|
||||
|
||||
worker.on("failed", (bullJob, err) => {
|
||||
log.error(
|
||||
{ jobId: bullJob?.id, job: (bullJob?.data as GamificationJobData)?.job, err: err.message },
|
||||
"Gamification job failed",
|
||||
);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
33
packages/backend/src/queues/workers/mail.ts
Normal file
33
packages/backend/src/queues/workers/mail.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Worker } from "bullmq";
|
||||
import { redisConnectionOpts } from "../connection.js";
|
||||
import { sendVerification, sendPasswordReset } from "../../lib/email.js";
|
||||
import { logger } from "../../lib/logger.js";
|
||||
|
||||
const log = logger.child({ component: "mail-worker" });
|
||||
|
||||
export function startMailWorker(): Worker {
|
||||
const worker = new Worker(
|
||||
"mail",
|
||||
async (job) => {
|
||||
log.info({ jobId: job.id, jobName: job.name }, `Processing mail job`);
|
||||
switch (job.name) {
|
||||
case "sendVerification":
|
||||
await sendVerification(job.data.email as string, job.data.token as string);
|
||||
break;
|
||||
case "sendPasswordReset":
|
||||
await sendPasswordReset(job.data.email as string, job.data.token as string);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown mail job: ${job.name}`);
|
||||
}
|
||||
log.info({ jobId: job.id, jobName: job.name }, `Mail job completed`);
|
||||
},
|
||||
{ connection: redisConnectionOpts },
|
||||
);
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
log.error({ jobId: job?.id, jobName: job?.name, err: err.message }, `Mail job failed`);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ function copyFile(src: string, dest: string) {
|
||||
async function migrateFiles() {
|
||||
console.log("📁 Migrating files...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, title, description, filename_disk, type, filesize, duration, uploaded_by, date_created
|
||||
`SELECT id, title, description, filename_disk, type, filesize, duration, uploaded_by, uploaded_on as date_created
|
||||
FROM directus_files`,
|
||||
);
|
||||
|
||||
@@ -95,8 +95,8 @@ async function migrateUsers() {
|
||||
console.log("👥 Migrating users...");
|
||||
const { rows } = await query(
|
||||
`SELECT u.id, u.email, u.password, u.first_name, u.last_name,
|
||||
u.description, u.avatar, u.date_created,
|
||||
u.artist_name, u.slug, u.email_notifications_key,
|
||||
u.description, u.avatar, u.join_date as date_created,
|
||||
u.artist_name, u.slug,
|
||||
r.name as role_name
|
||||
FROM directus_users u
|
||||
LEFT JOIN directus_roles r ON u.role = r.id
|
||||
@@ -126,9 +126,11 @@ async function migrateUsers() {
|
||||
if (tagsRes.rows[0]?.tags) {
|
||||
tags = Array.isArray(tagsRes.rows[0].tags)
|
||||
? tagsRes.rows[0].tags
|
||||
: JSON.parse(tagsRes.rows[0].tags || "[]");
|
||||
: JSON.parse(String(tagsRes.rows[0].tags || "[]"));
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
/* tags column may not exist on older Directus installs */
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO users (id, email, password_hash, first_name, last_name, artist_name, slug,
|
||||
@@ -144,10 +146,10 @@ async function migrateUsers() {
|
||||
user.artist_name,
|
||||
user.slug,
|
||||
user.description,
|
||||
JSON.stringify(tags),
|
||||
tags,
|
||||
role,
|
||||
user.avatar,
|
||||
true, // Assume existing users are verified
|
||||
true,
|
||||
user.date_created,
|
||||
],
|
||||
);
|
||||
@@ -160,7 +162,7 @@ async function migrateUsers() {
|
||||
async function migrateUserPhotos() {
|
||||
console.log("🖼️ Migrating user photos...");
|
||||
const { rows } = await query(
|
||||
`SELECT directus_users_id as user_id, directus_files_id as file_id, sort
|
||||
`SELECT directus_users_id as user_id, directus_files_id as file_id
|
||||
FROM junction_directus_users_files`,
|
||||
);
|
||||
|
||||
@@ -173,7 +175,7 @@ async function migrateUserPhotos() {
|
||||
await query(
|
||||
`INSERT INTO user_photos (user_id, file_id, sort) VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[row.user_id, row.file_id, row.sort || 0],
|
||||
[row.user_id, row.file_id, 0],
|
||||
);
|
||||
migrated++;
|
||||
}
|
||||
@@ -203,7 +205,7 @@ async function migrateArticles() {
|
||||
article.excerpt,
|
||||
article.content,
|
||||
article.image,
|
||||
Array.isArray(article.tags) ? JSON.stringify(article.tags) : article.tags,
|
||||
Array.isArray(article.tags) ? article.tags : JSON.parse(String(article.tags || "[]")),
|
||||
article.publish_date,
|
||||
article.author,
|
||||
article.category,
|
||||
@@ -222,7 +224,7 @@ async function migrateVideos() {
|
||||
console.log("🎬 Migrating videos...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, slug, title, description, image, movie, tags, upload_date,
|
||||
premium, featured, likes_count, plays_count
|
||||
premium, featured
|
||||
FROM sexy_videos`,
|
||||
);
|
||||
|
||||
@@ -240,12 +242,12 @@ async function migrateVideos() {
|
||||
video.description,
|
||||
video.image,
|
||||
video.movie,
|
||||
Array.isArray(video.tags) ? JSON.stringify(video.tags) : video.tags,
|
||||
Array.isArray(video.tags) ? video.tags : JSON.parse(String(video.tags || "[]")),
|
||||
video.upload_date,
|
||||
video.premium,
|
||||
video.featured,
|
||||
video.likes_count || 0,
|
||||
video.plays_count || 0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
);
|
||||
migrated++;
|
||||
@@ -279,9 +281,7 @@ async function migrateVideoModels() {
|
||||
|
||||
async function migrateVideoLikes() {
|
||||
console.log("❤️ Migrating video likes...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`,
|
||||
);
|
||||
const { rows } = await query(`SELECT id, video_id, user_id, date_created FROM sexy_video_likes`);
|
||||
|
||||
let migrated = 0;
|
||||
for (const row of rows) {
|
||||
@@ -329,7 +329,7 @@ async function migrateRecordings() {
|
||||
console.log("🎙️ Migrating recordings...");
|
||||
const { rows } = await query(
|
||||
`SELECT id, title, description, slug, duration, events, device_info,
|
||||
user_created as user_id, status, tags, linked_video, featured, public,
|
||||
user_created as user_id, status, tags, linked_video, public,
|
||||
original_recording_id, date_created, date_updated
|
||||
FROM sexy_recordings`,
|
||||
);
|
||||
@@ -338,25 +338,24 @@ async function migrateRecordings() {
|
||||
for (const recording of rows) {
|
||||
await query(
|
||||
`INSERT INTO recordings (id, title, description, slug, duration, events, device_info,
|
||||
user_id, status, tags, linked_video, featured, public,
|
||||
user_id, status, tags, linked_video, public,
|
||||
original_recording_id, date_created, date_updated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
recording.id,
|
||||
recording.title,
|
||||
recording.description,
|
||||
recording.slug,
|
||||
recording.duration,
|
||||
recording.duration != null ? Math.round(Number(recording.duration)) : null,
|
||||
typeof recording.events === "string" ? recording.events : JSON.stringify(recording.events),
|
||||
typeof recording.device_info === "string"
|
||||
? recording.device_info
|
||||
: JSON.stringify(recording.device_info),
|
||||
recording.user_id,
|
||||
recording.status,
|
||||
Array.isArray(recording.tags) ? JSON.stringify(recording.tags) : recording.tags,
|
||||
Array.isArray(recording.tags) ? recording.tags : JSON.parse(String(recording.tags || "[]")),
|
||||
recording.linked_video,
|
||||
recording.featured,
|
||||
recording.public,
|
||||
recording.original_recording_id,
|
||||
recording.date_created,
|
||||
|
||||
27
packages/backend/src/scripts/migrate.ts
Normal file
27
packages/backend/src/scripts/migrate.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Pool } from "pg";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import path from "path";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || "postgresql://sexy:sexy@localhost:5432/sexy",
|
||||
});
|
||||
|
||||
const db = drizzle(pool);
|
||||
|
||||
async function main() {
|
||||
console.log("Running schema migrations...");
|
||||
// In dev (tsx): __dirname = src/scripts → migrations are at src/migrations
|
||||
// In prod (node dist): __dirname = dist/scripts → migrations are at ../../migrations (package root)
|
||||
const migrationsFolder = __dirname.includes("/src/")
|
||||
? path.join(__dirname, "../migrations")
|
||||
: path.join(__dirname, "../../migrations");
|
||||
await migrate(db, { migrationsFolder });
|
||||
console.log("Schema migrations complete.");
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
5
packages/buttplug/.gitignore
vendored
Normal file
5
packages/buttplug/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
wasm/
|
||||
target/
|
||||
pkg/
|
||||
@@ -1,25 +1,27 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/buttplug",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-wasm": "3.5.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wasm-pack": "^0.14.0"
|
||||
}
|
||||
"name": "@sexy.pivoine.art/buttplug",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target web --release",
|
||||
"serve": "node serve.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-wasm": "3.5.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wasm-pack": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
39
packages/buttplug/serve.mjs
Normal file
39
packages/buttplug/serve.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
// Simple static server for local development — serves dist/ and wasm/ on port 8080
|
||||
import http from "http";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT ?? 8080;
|
||||
|
||||
const MIME = {
|
||||
".js": "application/javascript",
|
||||
".wasm": "application/wasm",
|
||||
".ts": "text/plain",
|
||||
".d.ts": "text/plain",
|
||||
};
|
||||
|
||||
http
|
||||
.createServer((req, res) => {
|
||||
const filePath = path.join(__dirname, decodeURIComponent(req.url.split("?")[0]));
|
||||
const ext = path.extname(filePath);
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Cross-Origin-Resource-Policy": "cross-origin",
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
})
|
||||
.listen(PORT, () => {
|
||||
console.log(`[buttplug] serving on http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -6,11 +6,11 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
import { IButtplugClientConnector } from './IButtplugClientConnector';
|
||||
import { ButtplugMessage } from '../core/Messages';
|
||||
import { ButtplugBrowserWebsocketConnector } from '../utils/ButtplugBrowserWebsocketConnector';
|
||||
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||
import { type ButtplugMessage } from "../core/Messages";
|
||||
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
|
||||
|
||||
export class ButtplugBrowserWebsocketClientConnector
|
||||
extends ButtplugBrowserWebsocketConnector
|
||||
@@ -18,7 +18,7 @@ export class ButtplugBrowserWebsocketClientConnector
|
||||
{
|
||||
public send = (msg: ButtplugMessage): void => {
|
||||
if (!this.Connected) {
|
||||
throw new Error('ButtplugClient not connected');
|
||||
throw new Error("ButtplugClient not connected");
|
||||
}
|
||||
this.sendMessage(msg);
|
||||
};
|
||||
|
||||
@@ -6,20 +6,16 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
import { ButtplugLogger } from '../core/Logging';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { ButtplugClientDevice } from './ButtplugClientDevice';
|
||||
import { IButtplugClientConnector } from './IButtplugClientConnector';
|
||||
import { ButtplugMessageSorter } from '../utils/ButtplugMessageSorter';
|
||||
import * as Messages from '../core/Messages';
|
||||
import {
|
||||
ButtplugError,
|
||||
ButtplugInitError,
|
||||
ButtplugMessageError,
|
||||
} from '../core/Exceptions';
|
||||
import { ButtplugClientConnectorException } from './ButtplugClientConnectorException';
|
||||
import { ButtplugLogger } from "../core/Logging";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { ButtplugClientDevice } from "./ButtplugClientDevice";
|
||||
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||
import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
|
||||
import * as Messages from "../core/Messages";
|
||||
import { ButtplugError, ButtplugInitError, ButtplugMessageError } from "../core/Exceptions";
|
||||
import { ButtplugClientConnectorException } from "./ButtplugClientConnectorException";
|
||||
|
||||
export class ButtplugClient extends EventEmitter {
|
||||
protected _pingTimer: NodeJS.Timeout | null = null;
|
||||
@@ -30,7 +26,7 @@ export class ButtplugClient extends EventEmitter {
|
||||
protected _isScanning = false;
|
||||
private _sorter: ButtplugMessageSorter = new ButtplugMessageSorter(true);
|
||||
|
||||
constructor(clientName = 'Generic Buttplug Client') {
|
||||
constructor(clientName = "Generic Buttplug Client") {
|
||||
super();
|
||||
this._clientName = clientName;
|
||||
this._logger.Debug(`ButtplugClient: Client ${clientName} created.`);
|
||||
@@ -52,18 +48,16 @@ export class ButtplugClient extends EventEmitter {
|
||||
}
|
||||
|
||||
public connect = async (connector: IButtplugClientConnector) => {
|
||||
this._logger.Info(
|
||||
`ButtplugClient: Connecting using ${connector.constructor.name}`
|
||||
);
|
||||
this._logger.Info(`ButtplugClient: Connecting using ${connector.constructor.name}`);
|
||||
await connector.connect();
|
||||
this._connector = connector;
|
||||
this._connector.addListener('message', this.parseMessages);
|
||||
this._connector.addListener('disconnect', this.disconnectHandler);
|
||||
this._connector.addListener("message", this.parseMessages);
|
||||
this._connector.addListener("disconnect", this.disconnectHandler);
|
||||
await this.initializeConnection();
|
||||
};
|
||||
|
||||
public disconnect = async () => {
|
||||
this._logger.Debug('ButtplugClient: Disconnect called');
|
||||
this._logger.Debug("ButtplugClient: Disconnect called");
|
||||
this._devices.clear();
|
||||
this.checkConnector();
|
||||
await this.shutdownConnection();
|
||||
@@ -71,25 +65,33 @@ export class ButtplugClient extends EventEmitter {
|
||||
};
|
||||
|
||||
public startScanning = async () => {
|
||||
this._logger.Debug('ButtplugClient: StartScanning called');
|
||||
this._logger.Debug("ButtplugClient: StartScanning called");
|
||||
this._isScanning = true;
|
||||
await this.sendMsgExpectOk({ StartScanning: { Id: 1 } });
|
||||
};
|
||||
|
||||
public stopScanning = async () => {
|
||||
this._logger.Debug('ButtplugClient: StopScanning called');
|
||||
this._logger.Debug("ButtplugClient: StopScanning called");
|
||||
this._isScanning = false;
|
||||
await this.sendMsgExpectOk({ StopScanning: { Id: 1 } });
|
||||
};
|
||||
|
||||
public stopAllDevices = async () => {
|
||||
this._logger.Debug('ButtplugClient: StopAllDevices');
|
||||
await this.sendMsgExpectOk({ StopCmd: { Id: 1, DeviceIndex: undefined, FeatureIndex: undefined, Inputs: true, Outputs: true } });
|
||||
this._logger.Debug("ButtplugClient: StopAllDevices");
|
||||
await this.sendMsgExpectOk({
|
||||
StopCmd: {
|
||||
Id: 1,
|
||||
DeviceIndex: undefined,
|
||||
FeatureIndex: undefined,
|
||||
Inputs: true,
|
||||
Outputs: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
protected disconnectHandler = () => {
|
||||
this._logger.Info('ButtplugClient: Disconnect event receieved.');
|
||||
this.emit('disconnect');
|
||||
this._logger.Info("ButtplugClient: Disconnect event receieved.");
|
||||
this.emit("disconnect");
|
||||
};
|
||||
|
||||
protected parseMessages = (msgs: Messages.ButtplugMessage[]) => {
|
||||
@@ -100,10 +102,10 @@ export class ButtplugClient extends EventEmitter {
|
||||
break;
|
||||
} else if (x.ScanningFinished !== undefined) {
|
||||
this._isScanning = false;
|
||||
this.emit('scanningfinished', x);
|
||||
this.emit("scanningfinished", x);
|
||||
} else if (x.InputReading !== undefined) {
|
||||
// TODO this should be emitted from the device or feature, not the client
|
||||
this.emit('inputreading', x);
|
||||
this.emit("inputreading", x);
|
||||
} else {
|
||||
console.log(`Unhandled message: ${x}`);
|
||||
}
|
||||
@@ -112,21 +114,17 @@ export class ButtplugClient extends EventEmitter {
|
||||
|
||||
protected initializeConnection = async (): Promise<boolean> => {
|
||||
this.checkConnector();
|
||||
const msg = await this.sendMessage(
|
||||
{
|
||||
RequestServerInfo: {
|
||||
ClientName: this._clientName,
|
||||
Id: 1,
|
||||
ProtocolVersionMajor: Messages.MESSAGE_SPEC_VERSION_MAJOR,
|
||||
ProtocolVersionMinor: Messages.MESSAGE_SPEC_VERSION_MINOR
|
||||
}
|
||||
}
|
||||
);
|
||||
const msg = await this.sendMessage({
|
||||
RequestServerInfo: {
|
||||
ClientName: this._clientName,
|
||||
Id: 1,
|
||||
ProtocolVersionMajor: Messages.MESSAGE_SPEC_VERSION_MAJOR,
|
||||
ProtocolVersionMinor: Messages.MESSAGE_SPEC_VERSION_MINOR,
|
||||
},
|
||||
});
|
||||
if (msg.ServerInfo !== undefined) {
|
||||
const serverinfo = msg as Messages.ServerInfo;
|
||||
this._logger.Info(
|
||||
`ButtplugClient: Connected to Server ${serverinfo.ServerName}`
|
||||
);
|
||||
this._logger.Info(`ButtplugClient: Connected to Server ${serverinfo.ServerName}`);
|
||||
// TODO: maybe store server name, do something with message template version?
|
||||
const ping = serverinfo.MaxPingTime;
|
||||
// If the server version is lower than the client version, the server will disconnect here.
|
||||
@@ -153,42 +151,37 @@ export class ButtplugClient extends EventEmitter {
|
||||
throw ButtplugError.LogAndError(
|
||||
ButtplugInitError,
|
||||
this._logger,
|
||||
`Cannot connect to server. ${err.ErrorMessage}`
|
||||
`Cannot connect to server. ${err.ErrorMessage}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
private parseDeviceList = (list: Messages.DeviceList) => {
|
||||
for (let [_, d] of Object.entries(list.Devices)) {
|
||||
for (const [_, d] of Object.entries(list.Devices)) {
|
||||
if (!this._devices.has(d.DeviceIndex)) {
|
||||
const device = ButtplugClientDevice.fromMsg(
|
||||
d,
|
||||
this.sendMessageClosure
|
||||
);
|
||||
const device = ButtplugClientDevice.fromMsg(d, this.sendMessageClosure);
|
||||
this._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
|
||||
this._devices.set(d.DeviceIndex, device);
|
||||
this.emit('deviceadded', device);
|
||||
this.emit("deviceadded", device);
|
||||
} else {
|
||||
this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
|
||||
}
|
||||
}
|
||||
for (let [index, device] of this._devices.entries()) {
|
||||
if (!list.Devices.hasOwnProperty(index.toString())) {
|
||||
for (const [index, device] of this._devices.entries()) {
|
||||
if (!Object.prototype.hasOwnProperty.call(list.Devices, index.toString())) {
|
||||
this._devices.delete(index);
|
||||
this.emit('deviceremoved', device);
|
||||
this.emit("deviceremoved", device);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
protected requestDeviceList = async () => {
|
||||
this.checkConnector();
|
||||
this._logger.Debug('ButtplugClient: ReceiveDeviceList called');
|
||||
const response = (await this.sendMessage(
|
||||
{
|
||||
RequestDeviceList: { Id: 1 }
|
||||
}
|
||||
));
|
||||
this._logger.Debug("ButtplugClient: ReceiveDeviceList called");
|
||||
const response = await this.sendMessage({
|
||||
RequestDeviceList: { Id: 1 },
|
||||
});
|
||||
this.parseDeviceList(response.DeviceList!);
|
||||
};
|
||||
|
||||
@@ -200,9 +193,7 @@ export class ButtplugClient extends EventEmitter {
|
||||
}
|
||||
};
|
||||
|
||||
protected async sendMessage(
|
||||
msg: Messages.ButtplugMessage
|
||||
): Promise<Messages.ButtplugMessage> {
|
||||
protected async sendMessage(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
|
||||
this.checkConnector();
|
||||
const p = this._sorter.PrepareOutgoingMessage(msg);
|
||||
await this._connector!.send(msg);
|
||||
@@ -211,15 +202,11 @@ export class ButtplugClient extends EventEmitter {
|
||||
|
||||
protected checkConnector() {
|
||||
if (!this.connected) {
|
||||
throw new ButtplugClientConnectorException(
|
||||
'ButtplugClient not connected'
|
||||
);
|
||||
throw new ButtplugClientConnectorException("ButtplugClient not connected");
|
||||
}
|
||||
}
|
||||
|
||||
protected sendMsgExpectOk = async (
|
||||
msg: Messages.ButtplugMessage
|
||||
): Promise<void> => {
|
||||
protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
|
||||
const response = await this.sendMessage(msg);
|
||||
if (response.Ok !== undefined) {
|
||||
return;
|
||||
@@ -229,13 +216,13 @@ export class ButtplugClient extends EventEmitter {
|
||||
throw ButtplugError.LogAndError(
|
||||
ButtplugMessageError,
|
||||
this._logger,
|
||||
`Message ${response} not handled by SendMsgExpectOk`
|
||||
`Message ${response} not handled by SendMsgExpectOk`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
protected sendMessageClosure = async (
|
||||
msg: Messages.ButtplugMessage
|
||||
msg: Messages.ButtplugMessage,
|
||||
): Promise<Messages.ButtplugMessage> => {
|
||||
return await this.sendMessage(msg);
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
import { ButtplugError } from '../core/Exceptions';
|
||||
import * as Messages from '../core/Messages';
|
||||
import { ButtplugError } from "../core/Exceptions";
|
||||
import * as Messages from "../core/Messages";
|
||||
|
||||
export class ButtplugClientConnectorException extends ButtplugError {
|
||||
public constructor(message: string) {
|
||||
|
||||
@@ -6,22 +6,17 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import * as Messages from '../core/Messages';
|
||||
import {
|
||||
ButtplugDeviceError,
|
||||
ButtplugError,
|
||||
ButtplugMessageError,
|
||||
} from '../core/Exceptions';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { ButtplugClientDeviceFeature } from './ButtplugClientDeviceFeature';
|
||||
import { DeviceOutputCommand } from './ButtplugClientDeviceCommand';
|
||||
"use strict";
|
||||
import * as Messages from "../core/Messages";
|
||||
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { ButtplugClientDeviceFeature } from "./ButtplugClientDeviceFeature";
|
||||
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||
|
||||
/**
|
||||
* Represents an abstract device, capable of taking certain kinds of messages.
|
||||
*/
|
||||
export class ButtplugClientDevice extends EventEmitter {
|
||||
|
||||
private _features: Map<number, ButtplugClientDeviceFeature>;
|
||||
|
||||
/**
|
||||
@@ -58,9 +53,7 @@ export class ButtplugClientDevice extends EventEmitter {
|
||||
|
||||
public static fromMsg(
|
||||
msg: Messages.DeviceInfo,
|
||||
sendClosure: (
|
||||
msg: Messages.ButtplugMessage
|
||||
) => Promise<Messages.ButtplugMessage>
|
||||
sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
|
||||
): ButtplugClientDevice {
|
||||
return new ButtplugClientDevice(msg, sendClosure);
|
||||
}
|
||||
@@ -72,25 +65,29 @@ export class ButtplugClientDevice extends EventEmitter {
|
||||
*/
|
||||
private constructor(
|
||||
private _deviceInfo: Messages.DeviceInfo,
|
||||
private _sendClosure: (
|
||||
msg: Messages.ButtplugMessage
|
||||
) => Promise<Messages.ButtplugMessage>
|
||||
private _sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
|
||||
) {
|
||||
super();
|
||||
this._features = new Map(Object.entries(_deviceInfo.DeviceFeatures).map(([index, v]) => [parseInt(index), new ButtplugClientDeviceFeature(_deviceInfo.DeviceIndex, _deviceInfo.DeviceName, v, _sendClosure)]));
|
||||
this._features = new Map(
|
||||
Object.entries(_deviceInfo.DeviceFeatures).map(([index, v]) => [
|
||||
parseInt(index),
|
||||
new ButtplugClientDeviceFeature(
|
||||
_deviceInfo.DeviceIndex,
|
||||
_deviceInfo.DeviceName,
|
||||
v,
|
||||
_sendClosure,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public async send(
|
||||
msg: Messages.ButtplugMessage
|
||||
): Promise<Messages.ButtplugMessage> {
|
||||
public async send(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
|
||||
// Assume we're getting the closure from ButtplugClient, which does all of
|
||||
// the index/existence/connection/message checks for us.
|
||||
return await this._sendClosure(msg);
|
||||
}
|
||||
|
||||
protected sendMsgExpectOk = async (
|
||||
msg: Messages.ButtplugMessage
|
||||
): Promise<void> => {
|
||||
protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
|
||||
const response = await this.send(msg);
|
||||
if (response.Ok !== undefined) {
|
||||
return;
|
||||
@@ -108,25 +105,50 @@ export class ButtplugClientDevice extends EventEmitter {
|
||||
};
|
||||
|
||||
protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
|
||||
if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) {
|
||||
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not exist for device ${this.name}`);
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
this._deviceInfo.DeviceFeatures,
|
||||
featureIndex.toString(),
|
||||
)
|
||||
) {
|
||||
throw new ButtplugDeviceError(
|
||||
`Feature index ${featureIndex} does not exist for device ${this.name}`,
|
||||
);
|
||||
}
|
||||
if (this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined && !this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs.hasOwnProperty(type)) {
|
||||
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`);
|
||||
if (
|
||||
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined &&
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs,
|
||||
type,
|
||||
)
|
||||
) {
|
||||
throw new ButtplugDeviceError(
|
||||
`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public hasOutput(type: Messages.OutputType): boolean {
|
||||
return this._features.values().filter((f) => f.hasOutput(type)).toArray().length > 0;
|
||||
return (
|
||||
this._features
|
||||
.values()
|
||||
.filter((f) => f.hasOutput(type))
|
||||
.toArray().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
public hasInput(type: Messages.InputType): boolean {
|
||||
return this._features.values().filter((f) => f.hasInput(type)).toArray().length > 0;
|
||||
return (
|
||||
this._features
|
||||
.values()
|
||||
.filter((f) => f.hasInput(type))
|
||||
.toArray().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
|
||||
let p: Promise<void>[] = [];
|
||||
for (let f of this._features.values()) {
|
||||
const p: Promise<void>[] = [];
|
||||
for (const f of this._features.values()) {
|
||||
if (f.hasOutput(cmd.outputType)) {
|
||||
p.push(f.runOutput(cmd));
|
||||
}
|
||||
@@ -138,15 +160,26 @@ export class ButtplugClientDevice extends EventEmitter {
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await this.sendMsgExpectOk({StopCmd: { Id: 1, DeviceIndex: this.index, FeatureIndex: undefined, Inputs: true, Outputs: true}});
|
||||
await this.sendMsgExpectOk({
|
||||
StopCmd: {
|
||||
Id: 1,
|
||||
DeviceIndex: this.index,
|
||||
FeatureIndex: undefined,
|
||||
Inputs: true,
|
||||
Outputs: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async battery(): Promise<number> {
|
||||
let p: Promise<void>[] = [];
|
||||
for (let f of this._features.values()) {
|
||||
const _p: Promise<void>[] = [];
|
||||
for (const f of this._features.values()) {
|
||||
if (f.hasInput(Messages.InputType.Battery)) {
|
||||
// Right now, we only have one battery per device, so assume the first one we find is it.
|
||||
let response = await f.runInput(Messages.InputType.Battery, Messages.InputCommandType.Read);
|
||||
const response = await f.runInput(
|
||||
Messages.InputType.Battery,
|
||||
Messages.InputCommandType.Read,
|
||||
);
|
||||
if (response === undefined) {
|
||||
throw new ButtplugMessageError("Got incorrect message back.");
|
||||
}
|
||||
@@ -160,6 +193,6 @@ export class ButtplugClientDevice extends EventEmitter {
|
||||
}
|
||||
|
||||
public emitDisconnected() {
|
||||
this.emit('deviceremoved');
|
||||
this.emit("deviceremoved");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class PercentOrSteps {
|
||||
}
|
||||
|
||||
public static createSteps(s: number): PercentOrSteps {
|
||||
let v = new PercentOrSteps;
|
||||
const v = new PercentOrSteps();
|
||||
v._steps = s;
|
||||
return v;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class PercentOrSteps {
|
||||
throw new ButtplugDeviceError(`Percent value ${p} is not in the range 0.0 <= x <= 1.0`);
|
||||
}
|
||||
|
||||
let v = new PercentOrSteps;
|
||||
const v = new PercentOrSteps();
|
||||
v._percent = p;
|
||||
return v;
|
||||
}
|
||||
@@ -35,8 +35,7 @@ export class DeviceOutputCommand {
|
||||
private _outputType: OutputType,
|
||||
private _value: PercentOrSteps,
|
||||
private _duration?: number,
|
||||
)
|
||||
{}
|
||||
) {}
|
||||
|
||||
public get outputType() {
|
||||
return this._outputType;
|
||||
@@ -52,26 +51,36 @@ export class DeviceOutputCommand {
|
||||
}
|
||||
|
||||
export class DeviceOutputValueConstructor {
|
||||
public constructor(
|
||||
private _outputType: OutputType)
|
||||
{}
|
||||
public constructor(private _outputType: OutputType) {}
|
||||
|
||||
public steps(steps: number): DeviceOutputCommand {
|
||||
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createSteps(steps), undefined);
|
||||
}
|
||||
|
||||
public percent(percent: number): DeviceOutputCommand {
|
||||
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createPercent(percent), undefined);
|
||||
return new DeviceOutputCommand(
|
||||
this._outputType,
|
||||
PercentOrSteps.createPercent(percent),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceOutputPositionWithDurationConstructor {
|
||||
public steps(steps: number, duration: number): DeviceOutputCommand {
|
||||
return new DeviceOutputCommand(OutputType.Position, PercentOrSteps.createSteps(steps), duration);
|
||||
return new DeviceOutputCommand(
|
||||
OutputType.Position,
|
||||
PercentOrSteps.createSteps(steps),
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
public percent(percent: number, duration: number): DeviceOutputCommand {
|
||||
return new DeviceOutputCommand(OutputType.HwPositionWithDuration, PercentOrSteps.createPercent(percent), duration);
|
||||
return new DeviceOutputCommand(
|
||||
OutputType.HwPositionWithDuration,
|
||||
PercentOrSteps.createPercent(percent),
|
||||
duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
|
||||
import * as Messages from "../core/Messages";
|
||||
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||
|
||||
export class ButtplugClientDeviceFeature {
|
||||
|
||||
constructor(
|
||||
private _deviceIndex: number,
|
||||
private _deviceName: string,
|
||||
private _feature: Messages.DeviceFeature,
|
||||
private _sendClosure: (
|
||||
msg: Messages.ButtplugMessage
|
||||
) => Promise<Messages.ButtplugMessage>) {
|
||||
}
|
||||
private _sendClosure: (msg: Messages.ButtplugMessage) => Promise<Messages.ButtplugMessage>,
|
||||
) {}
|
||||
|
||||
protected send = async (msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> => {
|
||||
return await this._sendClosure(msg);
|
||||
}
|
||||
};
|
||||
|
||||
protected sendMsgExpectOk = async (
|
||||
msg: Messages.ButtplugMessage
|
||||
): Promise<void> => {
|
||||
protected sendMsgExpectOk = async (msg: Messages.ButtplugMessage): Promise<void> => {
|
||||
const response = await this.send(msg);
|
||||
if (response.Ok !== undefined) {
|
||||
return;
|
||||
@@ -31,14 +26,24 @@ export class ButtplugClientDeviceFeature {
|
||||
};
|
||||
|
||||
protected isOutputValid(type: Messages.OutputType) {
|
||||
if (this._feature.Output !== undefined && !this._feature.Output.hasOwnProperty(type)) {
|
||||
throw new ButtplugDeviceError(`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`);
|
||||
if (
|
||||
this._feature.Output !== undefined &&
|
||||
!Object.prototype.hasOwnProperty.call(this._feature.Output, type)
|
||||
) {
|
||||
throw new ButtplugDeviceError(
|
||||
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected isInputValid(type: Messages.InputType) {
|
||||
if (this._feature.Input !== undefined && !this._feature.Input.hasOwnProperty(type)) {
|
||||
throw new ButtplugDeviceError(`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`);
|
||||
if (
|
||||
this._feature.Input !== undefined &&
|
||||
!Object.prototype.hasOwnProperty.call(this._feature.Input, type)
|
||||
) {
|
||||
throw new ButtplugDeviceError(
|
||||
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +54,7 @@ export class ButtplugClientDeviceFeature {
|
||||
throw new ButtplugDeviceError(`${command.outputType} requires value defined`);
|
||||
}
|
||||
|
||||
let type = command.outputType;
|
||||
const type = command.outputType;
|
||||
let duration: undefined | number = undefined;
|
||||
if (type == Messages.OutputType.HwPositionWithDuration) {
|
||||
if (command.duration === undefined) {
|
||||
@@ -58,24 +63,24 @@ export class ButtplugClientDeviceFeature {
|
||||
duration = command.duration;
|
||||
}
|
||||
let value: number;
|
||||
let p = command.value;
|
||||
const p = command.value;
|
||||
if (p.percent === undefined) {
|
||||
// TODO Check step limits here
|
||||
value = command.value.steps!;
|
||||
} else {
|
||||
value = Math.ceil(this._feature.Output[type]!.Value![1] * p.percent);
|
||||
}
|
||||
let newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
|
||||
let outCommand = {};
|
||||
const newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
|
||||
const outCommand = {};
|
||||
outCommand[type.toString()] = newCommand;
|
||||
|
||||
let cmd: Messages.ButtplugMessage = {
|
||||
const cmd: Messages.ButtplugMessage = {
|
||||
OutputCmd: {
|
||||
Id: 1,
|
||||
DeviceIndex: this._deviceIndex,
|
||||
FeatureIndex: this._feature.FeatureIndex,
|
||||
Command: outCommand
|
||||
}
|
||||
Command: outCommand,
|
||||
},
|
||||
};
|
||||
await this.sendMsgExpectOk(cmd);
|
||||
}
|
||||
@@ -112,43 +117,52 @@ export class ButtplugClientDeviceFeature {
|
||||
|
||||
public hasOutput(type: Messages.OutputType): boolean {
|
||||
if (this._feature.Output !== undefined) {
|
||||
return this._feature.Output.hasOwnProperty(type.toString());
|
||||
return Object.prototype.hasOwnProperty.call(this._feature.Output, type.toString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public hasInput(type: Messages.InputType): boolean {
|
||||
if (this._feature.Input !== undefined) {
|
||||
return this._feature.Input.hasOwnProperty(type.toString());
|
||||
return Object.prototype.hasOwnProperty.call(this._feature.Input, type.toString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
|
||||
if (this._feature.Output !== undefined && this._feature.Output.hasOwnProperty(cmd.outputType.toString())) {
|
||||
if (
|
||||
this._feature.Output !== undefined &&
|
||||
Object.prototype.hasOwnProperty.call(this._feature.Output, cmd.outputType.toString())
|
||||
) {
|
||||
return this.sendOutputCmd(cmd);
|
||||
}
|
||||
throw new ButtplugDeviceError(`Output type ${cmd.outputType} not supported by feature.`);
|
||||
}
|
||||
|
||||
public async runInput(inputType: Messages.InputType, inputCommand: Messages.InputCommandType): Promise<Messages.InputReading | undefined> {
|
||||
public async runInput(
|
||||
inputType: Messages.InputType,
|
||||
inputCommand: Messages.InputCommandType,
|
||||
): Promise<Messages.InputReading | undefined> {
|
||||
// Make sure the requested feature is valid
|
||||
this.isInputValid(inputType);
|
||||
let inputAttributes = this._feature.Input[inputType];
|
||||
const inputAttributes = this._feature.Input[inputType];
|
||||
console.log(this._feature.Input);
|
||||
if ((inputCommand === Messages.InputCommandType.Unsubscribe && !inputAttributes.Command.includes(Messages.InputCommandType.Subscribe)) && !inputAttributes.Command.includes(inputCommand)) {
|
||||
if (
|
||||
inputCommand === Messages.InputCommandType.Unsubscribe &&
|
||||
!inputAttributes.Command.includes(Messages.InputCommandType.Subscribe) &&
|
||||
!inputAttributes.Command.includes(inputCommand)
|
||||
) {
|
||||
throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`);
|
||||
}
|
||||
|
||||
let cmd: Messages.ButtplugMessage = {
|
||||
const cmd: Messages.ButtplugMessage = {
|
||||
InputCmd: {
|
||||
Id: 1,
|
||||
DeviceIndex: this._deviceIndex,
|
||||
FeatureIndex: this._feature.FeatureIndex,
|
||||
Type: inputType,
|
||||
Command: inputCommand,
|
||||
}
|
||||
},
|
||||
};
|
||||
if (inputCommand == Messages.InputCommandType.Read) {
|
||||
const response = await this.send(cmd);
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
import { ButtplugBrowserWebsocketClientConnector } from './ButtplugBrowserWebsocketClientConnector';
|
||||
import { WebSocket as NodeWebSocket } from 'ws';
|
||||
import { ButtplugBrowserWebsocketClientConnector } from "./ButtplugBrowserWebsocketClientConnector";
|
||||
import { WebSocket as NodeWebSocket } from "ws";
|
||||
|
||||
export class ButtplugNodeWebsocketClientConnector extends ButtplugBrowserWebsocketClientConnector {
|
||||
protected _websocketConstructor =
|
||||
NodeWebSocket as unknown as typeof WebSocket;
|
||||
protected _websocketConstructor = NodeWebSocket as unknown as typeof WebSocket;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
import { ButtplugMessage } from '../core/Messages';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { type ButtplugMessage } from "../core/Messages";
|
||||
import { type EventEmitter } from "eventemitter3";
|
||||
|
||||
export interface IButtplugClientConnector extends EventEmitter {
|
||||
connect: () => Promise<void>;
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
import * as Messages from './Messages';
|
||||
import { ButtplugLogger } from './Logging';
|
||||
import * as Messages from "./Messages";
|
||||
import { type ButtplugLogger } from "./Logging";
|
||||
|
||||
export class ButtplugError extends Error {
|
||||
public get ErrorClass(): Messages.ErrorClass {
|
||||
@@ -27,16 +27,16 @@ export class ButtplugError extends Error {
|
||||
Error: {
|
||||
Id: this.Id,
|
||||
ErrorCode: this.ErrorClass,
|
||||
ErrorMessage: this.message
|
||||
}
|
||||
}
|
||||
ErrorMessage: this.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static LogAndError<T extends ButtplugError>(
|
||||
constructor: new (str: string, num: number) => T,
|
||||
logger: ButtplugLogger,
|
||||
message: string,
|
||||
id: number = Messages.SYSTEM_MESSAGE_ID
|
||||
id: number = Messages.SYSTEM_MESSAGE_ID,
|
||||
): T {
|
||||
logger.Error(message);
|
||||
return new constructor(message, id);
|
||||
@@ -67,7 +67,7 @@ export class ButtplugError extends Error {
|
||||
message: string,
|
||||
errorClass: Messages.ErrorClass,
|
||||
id: number = Messages.SYSTEM_MESSAGE_ID,
|
||||
inner?: Error
|
||||
inner?: Error,
|
||||
) {
|
||||
super(message);
|
||||
this.errorClass = errorClass;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
|
||||
export enum ButtplugLogLevel {
|
||||
Off,
|
||||
@@ -69,9 +69,7 @@ export class LogMessage {
|
||||
* Returns a formatted string with timestamp, level, and message.
|
||||
*/
|
||||
public get FormattedMessage() {
|
||||
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${
|
||||
this.logMessage
|
||||
}`;
|
||||
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${this.logMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,10 +174,7 @@ export class ButtplugLogger extends EventEmitter {
|
||||
*/
|
||||
protected AddLogMessage(msg: string, level: ButtplugLogLevel) {
|
||||
// If nothing wants the log message we have, ignore it.
|
||||
if (
|
||||
level > this.maximumEventLogLevel &&
|
||||
level > this.maximumConsoleLogLevel
|
||||
) {
|
||||
if (level > this.maximumEventLogLevel && level > this.maximumConsoleLogLevel) {
|
||||
return;
|
||||
}
|
||||
const logMsg = new LogMessage(msg, level);
|
||||
@@ -191,7 +186,7 @@ export class ButtplugLogger extends EventEmitter {
|
||||
console.log(logMsg.FormattedMessage);
|
||||
}
|
||||
if (level <= this.maximumEventLogLevel) {
|
||||
this.emit('log', logMsg);
|
||||
this.emit("log", logMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
*/
|
||||
|
||||
// tslint:disable:max-classes-per-file
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
import { ButtplugMessageError } from './Exceptions';
|
||||
import { ButtplugMessageError } from "./Exceptions";
|
||||
|
||||
export const SYSTEM_MESSAGE_ID = 0;
|
||||
export const DEFAULT_MESSAGE_ID = 1;
|
||||
@@ -36,7 +36,7 @@ export interface ButtplugMessage {
|
||||
}
|
||||
|
||||
export function msgId(msg: ButtplugMessage): number {
|
||||
for (let [_, entry] of Object.entries(msg)) {
|
||||
for (const [_, entry] of Object.entries(msg)) {
|
||||
if (entry != undefined) {
|
||||
return entry.Id;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function msgId(msg: ButtplugMessage): number {
|
||||
}
|
||||
|
||||
export function setMsgId(msg: ButtplugMessage, id: number) {
|
||||
for (let [_, entry] of Object.entries(msg)) {
|
||||
for (const [_, entry] of Object.entries(msg)) {
|
||||
if (entry != undefined) {
|
||||
entry.Id = id;
|
||||
return;
|
||||
@@ -132,34 +132,34 @@ export interface DeviceList {
|
||||
}
|
||||
|
||||
export enum OutputType {
|
||||
Unknown = 'Unknown',
|
||||
Vibrate = 'Vibrate',
|
||||
Rotate = 'Rotate',
|
||||
Oscillate = 'Oscillate',
|
||||
Constrict = 'Constrict',
|
||||
Inflate = 'Inflate',
|
||||
Position = 'Position',
|
||||
HwPositionWithDuration = 'HwPositionWithDuration',
|
||||
Temperature = 'Temperature',
|
||||
Spray = 'Spray',
|
||||
Led = 'Led',
|
||||
Unknown = "Unknown",
|
||||
Vibrate = "Vibrate",
|
||||
Rotate = "Rotate",
|
||||
Oscillate = "Oscillate",
|
||||
Constrict = "Constrict",
|
||||
Inflate = "Inflate",
|
||||
Position = "Position",
|
||||
HwPositionWithDuration = "HwPositionWithDuration",
|
||||
Temperature = "Temperature",
|
||||
Spray = "Spray",
|
||||
Led = "Led",
|
||||
}
|
||||
|
||||
export enum InputType {
|
||||
Unknown = 'Unknown',
|
||||
Battery = 'Battery',
|
||||
RSSI = 'RSSI',
|
||||
Button = 'Button',
|
||||
Pressure = 'Pressure',
|
||||
Unknown = "Unknown",
|
||||
Battery = "Battery",
|
||||
RSSI = "RSSI",
|
||||
Button = "Button",
|
||||
Pressure = "Pressure",
|
||||
// Temperature,
|
||||
// Accelerometer,
|
||||
// Gyro,
|
||||
}
|
||||
|
||||
export enum InputCommandType {
|
||||
Read = 'Read',
|
||||
Subscribe = 'Subscribe',
|
||||
Unsubscribe = 'Unsubscribe',
|
||||
Read = "Read",
|
||||
Subscribe = "Subscribe",
|
||||
Unsubscribe = "Unsubscribe",
|
||||
}
|
||||
|
||||
export interface DeviceFeatureInput {
|
||||
|
||||
4
packages/buttplug/src/core/index.d.ts
vendored
4
packages/buttplug/src/core/index.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
declare module "*.json" {
|
||||
const content: string;
|
||||
export default content;
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
@@ -6,27 +6,24 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
import { ButtplugMessage } from './core/Messages';
|
||||
import { IButtplugClientConnector } from './client/IButtplugClientConnector';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { type ButtplugMessage } from "./core/Messages";
|
||||
import { type IButtplugClientConnector } from "./client/IButtplugClientConnector";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
|
||||
export * from './client/ButtplugClient';
|
||||
export * from './client/ButtplugClientDevice';
|
||||
export * from './client/ButtplugBrowserWebsocketClientConnector';
|
||||
export * from './client/ButtplugNodeWebsocketClientConnector';
|
||||
export * from './client/ButtplugClientConnectorException';
|
||||
export * from './utils/ButtplugMessageSorter';
|
||||
export * from './client/ButtplugClientDeviceCommand';
|
||||
export * from './client/ButtplugClientDeviceFeature';
|
||||
export * from './client/IButtplugClientConnector';
|
||||
export * from './core/Messages';
|
||||
export * from './core/Logging';
|
||||
export * from './core/Exceptions';
|
||||
export * from "./client/ButtplugClient";
|
||||
export * from "./client/ButtplugClientDevice";
|
||||
export * from "./client/ButtplugBrowserWebsocketClientConnector";
|
||||
export * from "./client/ButtplugNodeWebsocketClientConnector";
|
||||
export * from "./client/ButtplugClientConnectorException";
|
||||
export * from "./utils/ButtplugMessageSorter";
|
||||
export * from "./client/ButtplugClientDeviceCommand";
|
||||
export * from "./client/ButtplugClientDeviceFeature";
|
||||
export * from "./client/IButtplugClientConnector";
|
||||
export * from "./core/Messages";
|
||||
export * from "./core/Logging";
|
||||
export * from "./core/Exceptions";
|
||||
|
||||
export class ButtplugWasmClientConnector
|
||||
extends EventEmitter
|
||||
implements IButtplugClientConnector
|
||||
{
|
||||
export class ButtplugWasmClientConnector extends EventEmitter implements IButtplugClientConnector {
|
||||
private static _loggingActivated = false;
|
||||
private static wasmInstance;
|
||||
private _connected: boolean = false;
|
||||
@@ -43,35 +40,32 @@ export class ButtplugWasmClientConnector
|
||||
|
||||
private static maybeLoadWasm = async () => {
|
||||
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
|
||||
ButtplugWasmClientConnector.wasmInstance = await import(
|
||||
'../wasm/index.js'
|
||||
);
|
||||
const wasmModule = await import("../wasm/index.js");
|
||||
await wasmModule.default(); // --target web requires calling init() before using exports
|
||||
ButtplugWasmClientConnector.wasmInstance = wasmModule;
|
||||
}
|
||||
};
|
||||
|
||||
public static activateLogging = async (logLevel: string = 'debug') => {
|
||||
public static activateLogging = async (logLevel: string = "debug") => {
|
||||
await ButtplugWasmClientConnector.maybeLoadWasm();
|
||||
if (this._loggingActivated) {
|
||||
console.log('Logging already activated, ignoring.');
|
||||
console.log("Logging already activated, ignoring.");
|
||||
return;
|
||||
}
|
||||
console.log('Turning on logging.');
|
||||
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(
|
||||
logLevel,
|
||||
);
|
||||
console.log("Turning on logging.");
|
||||
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(logLevel);
|
||||
};
|
||||
|
||||
public initialize = async (): Promise<void> => {};
|
||||
|
||||
public connect = async (): Promise<void> => {
|
||||
await ButtplugWasmClientConnector.maybeLoadWasm();
|
||||
this.client =
|
||||
ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
|
||||
(msgs) => {
|
||||
this.emitMessage(msgs);
|
||||
},
|
||||
this.serverPtr,
|
||||
);
|
||||
this.client = ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
|
||||
(msgs) => {
|
||||
this.emitMessage(msgs);
|
||||
},
|
||||
this.serverPtr,
|
||||
);
|
||||
this._connected = true;
|
||||
};
|
||||
|
||||
@@ -80,7 +74,7 @@ export class ButtplugWasmClientConnector
|
||||
public send = (msg: ButtplugMessage): void => {
|
||||
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
|
||||
this.client,
|
||||
new TextEncoder().encode('[' + JSON.stringify(msg) + ']'),
|
||||
new TextEncoder().encode("[" + JSON.stringify(msg) + "]"),
|
||||
(output) => {
|
||||
this.emitMessage(output);
|
||||
},
|
||||
@@ -90,6 +84,6 @@ export class ButtplugWasmClientConnector
|
||||
private emitMessage = (msg: Uint8Array) => {
|
||||
const str = new TextDecoder().decode(msg);
|
||||
const msgs: ButtplugMessage[] = JSON.parse(str);
|
||||
this.emit('message', msgs);
|
||||
this.emit("message", msgs);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type FFICallback = js_sys::Function;
|
||||
type FFICallbackContext = u32;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[allow(dead_code)]
|
||||
pub struct FFICallbackContextWrapper(FFICallbackContext);
|
||||
|
||||
unsafe impl Send for FFICallbackContextWrapper {
|
||||
@@ -50,7 +51,7 @@ pub fn send_server_message(
|
||||
let buf = json.as_bytes();
|
||||
let this = JsValue::null();
|
||||
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
|
||||
callback.call1(&this, &JsValue::from(uint8buf));
|
||||
let _ = callback.call1(&this, &JsValue::from(uint8buf));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ pub fn buttplug_client_send_json_message(
|
||||
let buf = json.as_bytes();
|
||||
let this = JsValue::null();
|
||||
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
|
||||
callback.call1(&this, &JsValue::from(uint8buf));
|
||||
let _ = callback.call1(&this, &JsValue::from(uint8buf));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { ButtplugMessage } from '../core/Messages';
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { type ButtplugMessage } from "../core/Messages";
|
||||
|
||||
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||
protected _ws: WebSocket | undefined;
|
||||
@@ -26,18 +26,20 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||
public connect = async (): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new (this._websocketConstructor ?? WebSocket)(this._url);
|
||||
const onErrorCallback = (event: Event) => {reject(event)}
|
||||
const onCloseCallback = (event: CloseEvent) => reject(event.reason)
|
||||
ws.addEventListener('open', async () => {
|
||||
const onErrorCallback = (event: Event) => {
|
||||
reject(event);
|
||||
};
|
||||
const onCloseCallback = (event: CloseEvent) => reject(event.reason);
|
||||
ws.addEventListener("open", async () => {
|
||||
this._ws = ws;
|
||||
try {
|
||||
await this.initialize();
|
||||
this._ws.addEventListener('message', (msg) => {
|
||||
this._ws.addEventListener("message", (msg) => {
|
||||
this.parseIncomingMessage(msg);
|
||||
});
|
||||
this._ws.removeEventListener('close', onCloseCallback);
|
||||
this._ws.removeEventListener('error', onErrorCallback);
|
||||
this._ws.addEventListener('close', this.disconnect);
|
||||
this._ws.removeEventListener("close", onCloseCallback);
|
||||
this._ws.removeEventListener("error", onErrorCallback);
|
||||
this._ws.addEventListener("close", this.disconnect);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
@@ -47,8 +49,8 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||
// browsers usually only throw Error Code 1006. It's up to those using this
|
||||
// library to state what the problem might be.
|
||||
|
||||
ws.addEventListener('error', onErrorCallback)
|
||||
ws.addEventListener('close', onCloseCallback);
|
||||
ws.addEventListener("error", onErrorCallback);
|
||||
ws.addEventListener("close", onCloseCallback);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -58,14 +60,14 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||
}
|
||||
this._ws!.close();
|
||||
this._ws = undefined;
|
||||
this.emit('disconnect');
|
||||
this.emit("disconnect");
|
||||
};
|
||||
|
||||
public sendMessage(msg: ButtplugMessage) {
|
||||
if (!this.Connected) {
|
||||
throw new Error('ButtplugBrowserWebsocketConnector not connected');
|
||||
throw new Error("ButtplugBrowserWebsocketConnector not connected");
|
||||
}
|
||||
this._ws!.send('[' + JSON.stringify(msg) + ']');
|
||||
this._ws!.send("[" + JSON.stringify(msg) + "]");
|
||||
}
|
||||
|
||||
public initialize = async (): Promise<void> => {
|
||||
@@ -73,9 +75,9 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||
};
|
||||
|
||||
protected parseIncomingMessage(event: MessageEvent) {
|
||||
if (typeof event.data === 'string') {
|
||||
if (typeof event.data === "string") {
|
||||
const msgs: ButtplugMessage[] = JSON.parse(event.data);
|
||||
this.emit('message', msgs);
|
||||
this.emit("message", msgs);
|
||||
} else if (event.data instanceof Blob) {
|
||||
// No-op, we only use text message types.
|
||||
}
|
||||
@@ -83,6 +85,6 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||
|
||||
protected onReaderLoad(event: Event) {
|
||||
const msgs: ButtplugMessage[] = JSON.parse((event.target as FileReader).result as string);
|
||||
this.emit('message', msgs);
|
||||
this.emit("message", msgs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
import * as Messages from '../core/Messages';
|
||||
import { ButtplugError } from '../core/Exceptions';
|
||||
import * as Messages from "../core/Messages";
|
||||
import { ButtplugError } from "../core/Exceptions";
|
||||
|
||||
export class ButtplugMessageSorter {
|
||||
protected _counter = 1;
|
||||
@@ -21,9 +21,7 @@ export class ButtplugMessageSorter {
|
||||
// One of the places we should actually return a promise, as we need to store
|
||||
// them while waiting for them to return across the line.
|
||||
// tslint:disable:promise-function-async
|
||||
public PrepareOutgoingMessage(
|
||||
msg: Messages.ButtplugMessage
|
||||
): Promise<Messages.ButtplugMessage> {
|
||||
public PrepareOutgoingMessage(msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> {
|
||||
if (this._useCounter) {
|
||||
Messages.setMsgId(msg, this._counter);
|
||||
// Always increment last, otherwise we might lose sync
|
||||
@@ -31,22 +29,18 @@ export class ButtplugMessageSorter {
|
||||
}
|
||||
let res;
|
||||
let rej;
|
||||
const msgPromise = new Promise<Messages.ButtplugMessage>(
|
||||
(resolve, reject) => {
|
||||
res = resolve;
|
||||
rej = reject;
|
||||
}
|
||||
);
|
||||
const msgPromise = new Promise<Messages.ButtplugMessage>((resolve, reject) => {
|
||||
res = resolve;
|
||||
rej = reject;
|
||||
});
|
||||
this._waitingMsgs.set(Messages.msgId(msg), [res, rej]);
|
||||
return msgPromise;
|
||||
}
|
||||
|
||||
public ParseIncomingMessages(
|
||||
msgs: Messages.ButtplugMessage[]
|
||||
): Messages.ButtplugMessage[] {
|
||||
public ParseIncomingMessages(msgs: Messages.ButtplugMessage[]): Messages.ButtplugMessage[] {
|
||||
const noMatch: Messages.ButtplugMessage[] = [];
|
||||
for (const x of msgs) {
|
||||
let id = Messages.msgId(x);
|
||||
const id = Messages.msgId(x);
|
||||
if (id !== Messages.SYSTEM_MESSAGE_ID && this._waitingMsgs.has(id)) {
|
||||
const [res, rej] = this._waitingMsgs.get(id)!;
|
||||
this._waitingMsgs.delete(id);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function getRandomInt(max: number) {
|
||||
return Math.floor(Math.random() * Math.floor(max));
|
||||
return Math.floor(Math.random() * Math.floor(max));
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ impl HardwareSpecializer for WebBluetoothHardwareSpecializer {
|
||||
pub enum WebBluetoothEvent {
|
||||
// This is the only way we have to get our endpoints back to device creation
|
||||
// right now. My god this is a mess.
|
||||
#[allow(dead_code)]
|
||||
Connected(Vec<Endpoint>),
|
||||
Disconnected,
|
||||
}
|
||||
@@ -201,6 +202,7 @@ pub enum WebBluetoothDeviceCommand {
|
||||
HardwareSubscribeCmd,
|
||||
oneshot::Sender<Result<(), ButtplugDeviceError>>,
|
||||
),
|
||||
#[allow(dead_code)]
|
||||
Unsubscribe(
|
||||
HardwareUnsubscribeCmd,
|
||||
oneshot::Sender<Result<(), ButtplugDeviceError>>,
|
||||
@@ -271,7 +273,7 @@ async fn run_webbluetooth_loop(
|
||||
//let web_btle_device = WebBluetoothDeviceImpl::new(device, char_map);
|
||||
info!("device created!");
|
||||
let endpoints = char_map.keys().into_iter().cloned().collect();
|
||||
device_local_event_sender
|
||||
let _ = device_local_event_sender
|
||||
.send(WebBluetoothEvent::Connected(endpoints))
|
||||
.await;
|
||||
while let Some(msg) = device_command_receiver.recv().await {
|
||||
@@ -337,6 +339,7 @@ async fn run_webbluetooth_loop(
|
||||
#[derive(Debug)]
|
||||
pub struct WebBluetoothHardware {
|
||||
device_command_sender: mpsc::Sender<WebBluetoothDeviceCommand>,
|
||||
#[allow(dead_code)]
|
||||
device_event_receiver: mpsc::Receiver<WebBluetoothEvent>,
|
||||
event_sender: broadcast::Sender<HardwareEvent>,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"outDir": "dist",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"outDir": "dist",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -3,19 +3,19 @@ import path from "path";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [wasm()], // include wasm plugin
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, "src/index.ts"),
|
||||
name: "buttplug",
|
||||
fileName: "index",
|
||||
formats: ["es"], // this is important
|
||||
},
|
||||
minify: false, // for demo purposes
|
||||
target: "esnext", // this is important as well
|
||||
outDir: "dist",
|
||||
rollupOptions: {
|
||||
external: [/\.\/wasm\//, /\.\.\/wasm\//],
|
||||
},
|
||||
},
|
||||
plugins: [wasm()], // include wasm plugin
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, "src/index.ts"),
|
||||
name: "buttplug",
|
||||
fileName: "index",
|
||||
formats: ["es"], // this is important
|
||||
},
|
||||
minify: false, // for demo purposes
|
||||
target: "esnext", // this is important as well
|
||||
outDir: "dist",
|
||||
rollupOptions: {
|
||||
external: [/\.\/wasm\//, /\.\.\/wasm\//],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json",
|
||||
"repos": ["@ieedan/shadcn-svelte-extras"],
|
||||
"includeTests": false,
|
||||
"includeDocs": false,
|
||||
"watermark": true,
|
||||
"formatter": "prettier",
|
||||
"configFiles": {},
|
||||
"paths": {
|
||||
"*": "$lib/blocks",
|
||||
"ui": "$lib/components/ui",
|
||||
"actions": "$lib/actions",
|
||||
"hooks": "$lib/hooks",
|
||||
"utils": "$lib/utils"
|
||||
}
|
||||
"$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json",
|
||||
"repos": ["@ieedan/shadcn-svelte-extras"],
|
||||
"includeTests": false,
|
||||
"includeDocs": false,
|
||||
"watermark": true,
|
||||
"formatter": "prettier",
|
||||
"configFiles": {},
|
||||
"paths": {
|
||||
"*": "$lib/blocks",
|
||||
"ui": "$lib/components/ui",
|
||||
"actions": "$lib/actions",
|
||||
"hooks": "$lib/hooks",
|
||||
"utils": "$lib/utils"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/frontend",
|
||||
"version": "1.0.0",
|
||||
"author": "valknarogg",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node ./build"
|
||||
"start": "node ./build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json --threshold warning"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||
"@iconify-json/ri": "^1.2.10",
|
||||
"@iconify/tailwind4": "^1.2.1",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@lucide/svelte": "^0.577.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
@@ -28,22 +29,22 @@
|
||||
"glob": "^13.0.6",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"super-sitemap": "^1.0.7",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"svelte-sonner": "^1.0.8",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-wasm": "3.5.0"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||
"@sexy.pivoine.art/types": "workspace:*",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-request": "^7.1.2",
|
||||
"javascript-time-ago": "^2.6.4",
|
||||
"marked": "^17.0.4",
|
||||
"media-chrome": "^4.18.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
}
|
||||
|
||||
@@ -3,85 +3,94 @@
|
||||
|
||||
@plugin "@iconify/tailwind4";
|
||||
|
||||
@utility scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@custom-variant hover (&:hover);
|
||||
|
||||
@theme {
|
||||
--animate-vibrate: vibrate 0.3s linear infinite;
|
||||
--animate-fade-in: fadeIn 0.3s ease-out;
|
||||
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--animate-pulse-glow: pulseGlow 2s infinite;
|
||||
--animate-vibrate: vibrate 0.3s linear infinite;
|
||||
--animate-fade-in: fadeIn 0.3s ease-out;
|
||||
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--animate-pulse-glow: pulseGlow 2s infinite;
|
||||
|
||||
@keyframes vibrate {
|
||||
0% {
|
||||
transform: translate(0);
|
||||
}
|
||||
@keyframes vibrate {
|
||||
0% {
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-2px, 2px);
|
||||
}
|
||||
20% {
|
||||
transform: translate(-2px, 2px);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
40% {
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
60% {
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
80% {
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
@keyframes zoomIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%,
|
||||
100% {
|
||||
boxShadow: 0 0 20px rgba(183, 0, 217, 0.3);
|
||||
}
|
||||
@keyframes pulseGlow {
|
||||
0%,
|
||||
100% {
|
||||
boxshadow: 0 0 20px rgba(183, 0, 217, 0.3);
|
||||
}
|
||||
|
||||
50% {
|
||||
boxShadow: 0 0 40px rgba(183, 0, 217, 0.6);
|
||||
}
|
||||
}
|
||||
50% {
|
||||
boxshadow: 0 0 40px rgba(183, 0, 217, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -93,134 +102,159 @@
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
* {
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
|
||||
}
|
||||
}
|
||||
* {
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
outline-color: var(--ring);
|
||||
}
|
||||
* {
|
||||
border-color: var(--border);
|
||||
outline-color: var(--ring);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklab, var(--primary) 40%, transparent) transparent;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(in oklab, var(--primary) 40%, transparent);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
@apply mb-4 pl-6;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(in oklab, var(--primary) 70%, transparent);
|
||||
}
|
||||
|
||||
.prose li {
|
||||
@apply mb-2;
|
||||
}
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklab, var(--primary) 40%, transparent) transparent;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
@apply mb-4 pl-6;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
@apply mb-2;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--default-font-family: "Noto Sans", sans-serif;
|
||||
--background: oklch(0.98 0.01 320);
|
||||
--foreground: oklch(0.08 0.02 280);
|
||||
--muted: oklch(0.95 0.01 280);
|
||||
--muted-foreground: oklch(0.4 0.02 280);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--card: oklch(0.99 0.005 320);
|
||||
--card-foreground: oklch(0.08 0.02 280);
|
||||
--border: oklch(0.85 0.02 280);
|
||||
--input: oklch(0.922 0 0);
|
||||
--primary: oklch(56.971% 0.27455 319.257);
|
||||
--primary-foreground: oklch(0.98 0.01 320);
|
||||
--secondary: oklch(0.92 0.02 260);
|
||||
--secondary-foreground: oklch(0.15 0.05 260);
|
||||
--accent: oklch(0.45 0.35 280);
|
||||
--accent-foreground: oklch(0.98 0.01 280);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.55 0.3 320);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--default-font-family: "Noto Sans", sans-serif;
|
||||
--background: oklch(0.98 0.01 320);
|
||||
--foreground: oklch(0.08 0.02 280);
|
||||
--muted: oklch(0.95 0.01 280);
|
||||
--muted-foreground: oklch(0.4 0.02 280);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--card: oklch(0.99 0.005 320);
|
||||
--card-foreground: oklch(0.08 0.02 280);
|
||||
--border: oklch(0.85 0.02 280);
|
||||
--input: oklch(0.922 0 0);
|
||||
--primary: oklch(56.971% 0.27455 319.257);
|
||||
--primary-foreground: oklch(0.98 0.01 320);
|
||||
--secondary: oklch(0.92 0.02 260);
|
||||
--secondary-foreground: oklch(0.15 0.05 260);
|
||||
--accent: oklch(0.45 0.35 280);
|
||||
--accent-foreground: oklch(0.98 0.01 280);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.55 0.3 320);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.08 0.02 280);
|
||||
--foreground: oklch(0.98 0.01 280);
|
||||
--muted: oklch(0.12 0.03 280);
|
||||
--muted-foreground: oklch(0.6 0.02 280);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.1 0.02 280);
|
||||
--card-foreground: oklch(0.95 0.01 280);
|
||||
--border: oklch(0.2 0.05 280);
|
||||
--input: oklch(1 0 0 / 0.15);
|
||||
--primary: oklch(0.65 0.25 320);
|
||||
--primary-foreground: oklch(0.98 0.01 320);
|
||||
--secondary: oklch(0.15 0.05 260);
|
||||
--secondary-foreground: oklch(0.9 0.02 260);
|
||||
--accent: oklch(0.55 0.3 280);
|
||||
--accent-foreground: oklch(0.98 0.01 280);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.65 0.25 320);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 0.1);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.08 0.02 280);
|
||||
--foreground: oklch(0.98 0.01 280);
|
||||
--muted: oklch(0.12 0.03 280);
|
||||
--muted-foreground: oklch(0.6 0.02 280);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.1 0.02 280);
|
||||
--card-foreground: oklch(0.95 0.01 280);
|
||||
--border: oklch(0.2 0.05 280);
|
||||
--input: oklch(1 0 0 / 0.15);
|
||||
--primary: oklch(65.054% 0.25033 319.934);
|
||||
--primary-foreground: oklch(0.98 0.01 320);
|
||||
--secondary: oklch(0.15 0.05 260);
|
||||
--secondary-foreground: oklch(0.9 0.02 260);
|
||||
--accent: oklch(0.55 0.3 280);
|
||||
--accent-foreground: oklch(0.98 0.01 280);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.65 0.25 320);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 0.1);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
}
|
||||
|
||||
32
packages/frontend/src/app.d.ts
vendored
32
packages/frontend/src/app.d.ts
vendored
@@ -4,22 +4,22 @@ import type { AuthStatus } from "$lib/types";
|
||||
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
authStatus: AuthStatus;
|
||||
requestId: string;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
interface Window {
|
||||
sidebar: {
|
||||
addPanel: () => void;
|
||||
};
|
||||
opera: object;
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
authStatus: AuthStatus;
|
||||
requestId: string;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
interface Window {
|
||||
sidebar: {
|
||||
addPanel: () => void;
|
||||
};
|
||||
opera: object;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
||||
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover" class="dark">
|
||||
<body data-sveltekit-preload-data="hover" class="dark">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,96 +2,92 @@ import { isAuthenticated } from "$lib/services";
|
||||
import { logger, generateRequestId } from "$lib/logger";
|
||||
import type { Handle } from "@sveltejs/kit";
|
||||
|
||||
// Log startup info once
|
||||
let hasLoggedStartup = false;
|
||||
if (!hasLoggedStartup) {
|
||||
logger.startup();
|
||||
hasLoggedStartup = true;
|
||||
}
|
||||
// Log startup info once (module-level code runs exactly once on import)
|
||||
logger.startup();
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { cookies, locals, url, request } = event;
|
||||
const startTime = Date.now();
|
||||
const { cookies, locals, url, request } = event;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Generate unique request ID
|
||||
const requestId = generateRequestId();
|
||||
// Generate unique request ID
|
||||
const requestId = generateRequestId();
|
||||
|
||||
// Add request ID to locals for access in other handlers
|
||||
locals.requestId = requestId;
|
||||
// Add request ID to locals for access in other handlers
|
||||
locals.requestId = requestId;
|
||||
|
||||
// Log incoming request
|
||||
logger.request(request.method, url.pathname, {
|
||||
requestId,
|
||||
context: {
|
||||
userAgent: request.headers.get('user-agent')?.substring(0, 100),
|
||||
referer: request.headers.get('referer'),
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
},
|
||||
});
|
||||
// Log incoming request
|
||||
logger.request(request.method, url.pathname, {
|
||||
requestId,
|
||||
context: {
|
||||
userAgent: request.headers.get("user-agent")?.substring(0, 100),
|
||||
referer: request.headers.get("referer"),
|
||||
ip: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
|
||||
},
|
||||
});
|
||||
|
||||
// Handle authentication
|
||||
const token = cookies.get("session_token");
|
||||
// Handle authentication
|
||||
const token = cookies.get("session_token");
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
locals.authStatus = await isAuthenticated(token);
|
||||
if (token) {
|
||||
try {
|
||||
locals.authStatus = await isAuthenticated(token);
|
||||
|
||||
if (locals.authStatus.authenticated) {
|
||||
logger.auth('Token validated', true, {
|
||||
requestId,
|
||||
userId: locals.authStatus.user?.id,
|
||||
context: {
|
||||
email: locals.authStatus.user?.email,
|
||||
role: locals.authStatus.user?.role,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.auth('Token invalid', false, { requestId });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Authentication check failed', {
|
||||
requestId,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
locals.authStatus = { authenticated: false };
|
||||
}
|
||||
} else {
|
||||
logger.debug('No session token found', { requestId });
|
||||
locals.authStatus = { authenticated: false };
|
||||
}
|
||||
if (locals.authStatus.authenticated) {
|
||||
logger.auth("Token validated", true, {
|
||||
requestId,
|
||||
userId: locals.authStatus.user?.id,
|
||||
context: {
|
||||
email: locals.authStatus.user?.email,
|
||||
role: locals.authStatus.user?.role,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.auth("Token invalid", false, { requestId });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Authentication check failed", {
|
||||
requestId,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
locals.authStatus = { authenticated: false };
|
||||
}
|
||||
} else {
|
||||
logger.debug("No session token found", { requestId });
|
||||
locals.authStatus = { authenticated: false };
|
||||
}
|
||||
|
||||
// Resolve the request
|
||||
let response: Response;
|
||||
try {
|
||||
response = await resolve(event, {
|
||||
filterSerializedResponseHeaders: (key) => {
|
||||
return key.toLowerCase() === "content-type";
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error('Request handler error', {
|
||||
requestId,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
duration,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
// Resolve the request
|
||||
let response: Response;
|
||||
try {
|
||||
response = await resolve(event, {
|
||||
filterSerializedResponseHeaders: (key) => {
|
||||
return key.toLowerCase() === "content-type";
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error("Request handler error", {
|
||||
requestId,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
duration,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log response
|
||||
const duration = Date.now() - startTime;
|
||||
logger.response(request.method, url.pathname, response.status, duration, {
|
||||
requestId,
|
||||
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
|
||||
context: {
|
||||
cached: response.headers.get('x-sveltekit-page') === 'true',
|
||||
},
|
||||
});
|
||||
// Log response
|
||||
const duration = Date.now() - startTime;
|
||||
logger.response(request.method, url.pathname, response.status, duration, {
|
||||
requestId,
|
||||
userId: locals.authStatus.authenticated ? locals.authStatus.user?.id : undefined,
|
||||
context: {
|
||||
cached: response.headers.get("x-sveltekit-page") === "true",
|
||||
},
|
||||
});
|
||||
|
||||
// Add request ID to response headers (useful for debugging)
|
||||
response.headers.set('x-request-id', requestId);
|
||||
// Add request ID to response headers (useful for debugging)
|
||||
response.headers.set("x-request-id", requestId);
|
||||
|
||||
return response;
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export const getGraphQLClient = (fetchFn?: typeof globalThis.fetch) =>
|
||||
});
|
||||
|
||||
export const getAssetUrl = (
|
||||
id: string,
|
||||
id: string | null | undefined,
|
||||
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
|
||||
) => {
|
||||
if (!id) {
|
||||
|
||||
@@ -1,77 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "$lib/components/ui/dialog";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "$lib/components/ui/dialog";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const AGE_VERIFICATION_KEY = "age-verified";
|
||||
const AGE_VERIFICATION_KEY = "age-verified";
|
||||
|
||||
let isOpen = true;
|
||||
let isOpen = $state(false);
|
||||
|
||||
function handleAgeConfirmation() {
|
||||
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
|
||||
isOpen = false;
|
||||
}
|
||||
function handleAgeConfirmation() {
|
||||
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
|
||||
if (storedVerification === "true") {
|
||||
isOpen = false;
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (localStorage.getItem(AGE_VERIFICATION_KEY) !== "true") {
|
||||
isOpen = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog bind:open={isOpen}>
|
||||
<DialogContent
|
||||
class="sm:max-w-md"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-primary-foreground text-sm"
|
||||
>{$_("age_verification_dialog.age")}</span
|
||||
>
|
||||
</div>
|
||||
<div class="">
|
||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||
>{$_("age_verification_dialog.title")}</DialogTitle
|
||||
>
|
||||
<DialogDescription class="text-left text-sm">
|
||||
{$_("age_verification_dialog.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-end gap-4">
|
||||
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
||||
{$_("age_verification_dialog.exit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onclick={handleAgeConfirmation}
|
||||
class="cursor-pointer"
|
||||
<DialogContent
|
||||
class="sm:max-w-md"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-primary-foreground text-sm">{$_("age_verification_dialog.age")}</span>
|
||||
</div>
|
||||
<div class="">
|
||||
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||
>{$_("age_verification_dialog.title")}</DialogTitle
|
||||
>
|
||||
<span class="icon-[ri--check-line]"></span>
|
||||
{$_("age_verification_dialog.confirm")}
|
||||
</Button>
|
||||
<DialogDescription class="text-left text-sm">
|
||||
{$_("age_verification_dialog.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator class="my-4" />
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-end gap-4">
|
||||
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
||||
{$_("age_verification_dialog.exit")}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onclick={handleAgeConfirmation} class="cursor-pointer">
|
||||
<span class="icon-[ri--check-line]"></span>
|
||||
{$_("age_verification_dialog.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
<!-- Advanced Plasma Background -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<!-- Primary gradient layers -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
|
||||
></div>
|
||||
<!-- Primary gradient layers -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
|
||||
></div>
|
||||
|
||||
<!-- Large floating orbs -->
|
||||
<!-- <div
|
||||
<!-- Large floating orbs -->
|
||||
<!-- <div
|
||||
class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000"
|
||||
></div> -->
|
||||
|
||||
<!-- Medium morphing elements -->
|
||||
<!-- <div
|
||||
<!-- Medium morphing elements -->
|
||||
<!-- <div
|
||||
class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000"
|
||||
></div> -->
|
||||
|
||||
<!-- Soft particle effects -->
|
||||
<!-- <div
|
||||
<!-- Soft particle effects -->
|
||||
<!-- <div
|
||||
class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000"
|
||||
></div> -->
|
||||
|
||||
<!-- Premium glassmorphism overlay -->
|
||||
<!-- <div
|
||||
<!-- Premium glassmorphism overlay -->
|
||||
<!-- <div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
|
||||
></div> -->
|
||||
|
||||
<!-- Animated Plasma Background -->
|
||||
<div
|
||||
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
|
||||
></div>
|
||||
<!-- Animated Plasma Background -->
|
||||
<div
|
||||
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
|
||||
></div>
|
||||
|
||||
<!-- Global Plasma Background -->
|
||||
<!-- <div
|
||||
<!-- Global Plasma Background -->
|
||||
<!-- <div
|
||||
class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob"
|
||||
></div>
|
||||
<div
|
||||
@@ -1,12 +1,8 @@
|
||||
<script lang="ts">
|
||||
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
|
||||
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="block rounded-full cursor-pointer"
|
||||
onclick={onclick}
|
||||
aria-label={label}
|
||||
>
|
||||
<button class="block rounded-full cursor-pointer" {onclick} aria-label={label}>
|
||||
<div
|
||||
class="relative flex overflow-hidden items-center justify-center rounded-full w-[50px] h-[50px] transform transition-all duration-200 shadow-md opacity-90 translate-x-3"
|
||||
>
|
||||
@@ -14,23 +10,23 @@ const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
|
||||
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||
class={`bg-foreground h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
|
||||
<div
|
||||
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? 'translate-x-0 w-12' : ''}`}
|
||||
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
|
||||
>
|
||||
<div
|
||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? 'rotate-45' : ''}`}
|
||||
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? '-rotate-45' : ''}`}
|
||||
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,99 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import { Slider } from "$lib/components/ui/slider";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||
import type { BluetoothDevice } from "$lib/types";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { cn } from "$lib/utils";
|
||||
import { Slider } from "$lib/components/ui/slider";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||
import type { BluetoothDevice } from "$lib/types";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
device: BluetoothDevice;
|
||||
onChange: (scalarIndex: number, val: number) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
interface Props {
|
||||
device: BluetoothDevice;
|
||||
onChange: (scalarIndex: number, val: number) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
let { device, onChange, onStop }: Props = $props();
|
||||
let { device, onChange, onStop }: Props = $props();
|
||||
|
||||
function getBatteryColor(level: number) {
|
||||
if (!device.hasBattery) {
|
||||
return "text-gray-400";
|
||||
}
|
||||
if (level > 60) return "text-green-400";
|
||||
if (level > 30) return "text-yellow-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
function getBatteryColor(level: number) {
|
||||
if (!device.hasBattery) {
|
||||
return "text-gray-400";
|
||||
}
|
||||
if (level > 60) return "text-green-400";
|
||||
if (level > 30) return "text-yellow-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
function getBatteryBgColor(level: number) {
|
||||
if (!device.hasBattery) {
|
||||
return "bg-gray-400/20";
|
||||
}
|
||||
if (level > 60) return "bg-green-400/20";
|
||||
if (level > 30) return "bg-yellow-400/20";
|
||||
return "bg-red-400/20";
|
||||
}
|
||||
function getBatteryBgColor(level: number) {
|
||||
if (!device.hasBattery) {
|
||||
return "bg-gray-400/20";
|
||||
}
|
||||
if (level > 60) return "bg-green-400/20";
|
||||
if (level > 30) return "bg-yellow-400/20";
|
||||
return "bg-red-400/20";
|
||||
}
|
||||
|
||||
function getScalarAnimations() {
|
||||
return device.actuators
|
||||
.filter((a) => a.value > 0)
|
||||
.map((a) => `animate-${a.outputType.toLowerCase()}`);
|
||||
}
|
||||
function getScalarAnimations() {
|
||||
return device.actuators
|
||||
.filter((a) => a.value > 0)
|
||||
.map((a) => `animate-${a.outputType.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function isActive() {
|
||||
return device.actuators.some((a) => a.value > 0);
|
||||
}
|
||||
function isActive() {
|
||||
return device.actuators.some((a) => a.value > 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card
|
||||
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||
>
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
||||
>
|
||||
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class={`font-semibold text-card-foreground group-hover:text-primary transition-colors`}
|
||||
>
|
||||
{device.name}
|
||||
</h3>
|
||||
<!-- <p class="text-sm text-muted-foreground">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
||||
>
|
||||
<span
|
||||
class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}
|
||||
></span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||
{device.name}
|
||||
</h3>
|
||||
<!-- <p class="text-sm text-muted-foreground">
|
||||
{device.deviceType}
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full {isActive()
|
||||
? 'bg-green-400'
|
||||
: 'bg-red-400'}"
|
||||
></div>
|
||||
{#if isActive()}
|
||||
<div
|
||||
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium {isActive()
|
||||
? 'text-green-400'
|
||||
: 'text-red-400'}"
|
||||
>
|
||||
{isActive()
|
||||
? $_("device_card.active")
|
||||
: $_("device_card.paused")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</div>
|
||||
<button
|
||||
class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`}
|
||||
onclick={() => isActive() && onStop()}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="w-2 h-2 rounded-full {isActive() ? 'bg-green-400' : 'bg-red-400'}"></div>
|
||||
{#if isActive()}
|
||||
<div
|
||||
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs font-medium {isActive() ? 'text-green-400' : 'text-red-400'}">
|
||||
{isActive() ? $_("device_card.active") : $_("device_card.paused")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Current Value -->
|
||||
<!-- <div
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Current Value -->
|
||||
<!-- <div
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
@@ -103,58 +96,54 @@ function isActive() {
|
||||
>
|
||||
</div> -->
|
||||
|
||||
<!-- Battery Level -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(
|
||||
device.batteryLevel,
|
||||
)}"
|
||||
></span>
|
||||
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
||||
</div>
|
||||
{#if device.hasBattery}
|
||||
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
|
||||
{device.batteryLevel}%
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
|
||||
device.batteryLevel,
|
||||
)} bg-gradient-to-r from-current to-current/80"
|
||||
style="width: {device.batteryLevel}%"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Battery Level -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(device.batteryLevel)}"
|
||||
></span>
|
||||
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
||||
</div>
|
||||
{#if device.hasBattery}
|
||||
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
|
||||
{device.batteryLevel}%
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
|
||||
device.batteryLevel,
|
||||
)} bg-gradient-to-r from-current to-current/80"
|
||||
style="width: {device.batteryLevel}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Seen -->
|
||||
<!-- <div
|
||||
<!-- Last Seen -->
|
||||
<!-- <div
|
||||
class="flex items-center justify-between text-xs text-muted-foreground"
|
||||
>
|
||||
<span>{$_("device_card.last_seen")}</span>
|
||||
<span>{device.lastSeen.toLocaleTimeString()}</span>
|
||||
</div> -->
|
||||
|
||||
<!-- Action Button -->
|
||||
{#each device.actuators as actuator, idx}
|
||||
<div class="space-y-2">
|
||||
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||
>{$_(
|
||||
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`,
|
||||
)}</Label
|
||||
>
|
||||
<Slider
|
||||
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||
type="single"
|
||||
value={actuator.value}
|
||||
onValueChange={(val) => onChange(idx, val)}
|
||||
max={actuator.maxSteps}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</CardContent>
|
||||
<!-- Action Button -->
|
||||
{#each device.actuators as actuator, idx (idx)}
|
||||
<div class="space-y-2">
|
||||
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||
>{$_(`device_card.actuator_types.${actuator.outputType.toLowerCase()}`)}</Label
|
||||
>
|
||||
<Slider
|
||||
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
|
||||
type="single"
|
||||
value={actuator.value}
|
||||
onValueChange={(val) => onChange(idx, val)}
|
||||
max={actuator.maxSteps}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,121 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
</script>
|
||||
|
||||
<footer
|
||||
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
|
||||
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
|
||||
>
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-xl font-bold">
|
||||
<Logo />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
aria-label="Email"
|
||||
href="mailto:{$_('footer.contact.email')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
<a
|
||||
aria-label="X"
|
||||
href="https://www.x.com/{$_('footer.contact.x')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
<a
|
||||
aria-label="YouTube"
|
||||
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">
|
||||
{$_("footer.quick_links")}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="/models"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.models")}</a
|
||||
>
|
||||
<a
|
||||
href="/videos"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.videos")}</a
|
||||
>
|
||||
<a
|
||||
href="/magazine"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.magazine")}</a
|
||||
>
|
||||
<a
|
||||
href="/about"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.about")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="mailto:{$_('footer.contact_support_email')}"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.contact_support")}</a
|
||||
>
|
||||
<a
|
||||
href="mailto:{$_('footer.model_applications_email')}"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.model_applications")}</a
|
||||
>
|
||||
<a
|
||||
href="/faq"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.faq")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="/legal"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.privacy_policy")}</a
|
||||
>
|
||||
<a
|
||||
href="/legal"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.terms_of_service")}</a
|
||||
>
|
||||
<a
|
||||
href="/imprint"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.imprint")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-xl font-bold">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/50 mt-8 pt-8 text-center">
|
||||
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
|
||||
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
aria-label="Email"
|
||||
href="mailto:{$_('footer.contact.email')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
<a
|
||||
aria-label="X"
|
||||
href="https://www.x.com/{$_('footer.contact.x')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
<a
|
||||
aria-label="YouTube"
|
||||
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">
|
||||
{$_("footer.quick_links")}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="/models"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.models")}</a
|
||||
>
|
||||
<a
|
||||
href="/videos"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.videos")}</a
|
||||
>
|
||||
<a
|
||||
href="/magazine"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.magazine")}</a
|
||||
>
|
||||
<a
|
||||
href="/about"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.about")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="mailto:{$_('footer.contact_support_email')}"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.contact_support")}</a
|
||||
>
|
||||
<a
|
||||
href="mailto:{$_('footer.model_applications_email')}"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.model_applications")}</a
|
||||
>
|
||||
<a
|
||||
href="/faq"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.faq")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
href="/legal"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.privacy_policy")}</a
|
||||
>
|
||||
<a
|
||||
href="/legal"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.terms_of_service")}</a
|
||||
>
|
||||
<a
|
||||
href="/imprint"
|
||||
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>{$_("footer.imprint")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/50 mt-8 pt-8 text-center">
|
||||
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
<div class="w-full h-auto">
|
||||
<svg
|
||||
version="1.0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1280.000000 904.000000"
|
||||
stroke-width="5"
|
||||
stroke="#ce47eb"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<metadata>
|
||||
Created by potrace 1.15, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
|
||||
<path
|
||||
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
|
||||
-19 -69 -66 -96 -104 -116 -164 -130 -314 -59 -664 32 -164 36 -217 18 -256
|
||||
-13 -30 -14 -30 -140 -52 -75 -12 -105 -13 -129 -5 -18 6 -59 11 -93 11 -123
|
||||
-1 -213 -66 -379 -275 -245 -308 -501 -567 -686 -693 l-92 -64 -82 7 c-53 5
|
||||
-88 13 -100 23 -21 18 -66 20 -167 7 -73 -9 -124 -31 -159 -69 -22 -23 -23
|
||||
-31 -18 -94 6 -58 4 -71 -11 -84 -44 -40 -203 -119 -295 -149 -56 -18 -144
|
||||
-50 -195 -71 -50 -21 -138 -51 -195 -67 -232 -65 -369 -131 -595 -284 -182
|
||||
-124 -172 -123 -208 -27 -23 60 -39 81 -189 245 -279 305 -319 354 -368 458
|
||||
-46 94 -47 98 -32 127 8 16 15 36 15 43 0 8 14 41 30 72 17 31 30 63 30 70 0
|
||||
7 7 18 15 25 8 7 15 26 15 42 0 42 15 65 49 71 17 4 37 17 46 30 14 23 14 30
|
||||
-9 101 -28 88 -21 130 22 141 20 5 23 10 18 31 -4 13 -1 34 5 46 13 25 33 239
|
||||
31 336 0 42 -8 78 -23 108 -31 65 -121 158 -209 217 -41 28 -77 55 -79 60 -2
|
||||
5 -17 24 -33 43 -23 26 -48 39 -111 58 -183 55 -239 61 -361 36 -156 -33 -333
|
||||
-185 -425 -368 -72 -143 -93 -280 -96 -622 -2 -240 -5 -288 -24 -379 -12 -57
|
||||
-30 -120 -40 -140 -11 -20 -61 -84 -113 -142 -52 -58 -105 -121 -118 -140 -13
|
||||
-19 -45 -58 -72 -88 -93 -106 -127 -193 -237 -616 -33 -127 -67 -251 -76 -275
|
||||
-9 -25 -48 -153 -86 -285 -78 -264 -163 -502 -334 -935 -135 -340 -194 -526
|
||||
-290 -910 -20 -80 -47 -180 -61 -223 -13 -43 -24 -92 -24 -109 0 -42 -43 -79
|
||||
-132 -112 -56 -20 -108 -52 -213 -132 -77 -58 -162 -117 -190 -131 -85 -43
|
||||
-107 -75 -62 -89 12 -3 30 -15 40 -25 10 -11 30 -19 45 -19 29 0 146 52 175
|
||||
77 9 9 19 14 22 12 2 -3 -21 -24 -51 -47 -55 -43 -63 -59 -42 -80 30 -30 130
|
||||
5 198 69 54 52 127 109 139 109 20 0 11 -27 -25 -80 -38 -56 -38 -74 0 -91 33
|
||||
-16 67 7 135 89 31 37 70 71 95 84 l42 20 82 -21 c45 -11 95 -21 111 -21 17 0
|
||||
50 -11 75 -25 58 -32 136 -35 166 -5 35 35 26 57 -40 90 -59 30 -156 132 -186
|
||||
195 -30 63 -31 124 -3 258 43 213 95 336 279 657 126 219 231 423 267 520 14
|
||||
36 40 128 58 205 19 77 50 185 69 240 55 159 182 450 195 447 7 -1 9 7 5 23
|
||||
-10 38 0 30 37 -30 42 -69 60 -53 28 27 -36 92 -39 98 -34 98 3 0 14 -18 25
|
||||
-41 14 -26 26 -39 35 -35 9 3 28 -22 59 -81 65 -121 162 -266 237 -353 35 -41
|
||||
174 -196 309 -345 359 -394 379 -421 409 -549 25 -103 90 -214 169 -287 74
|
||||
-67 203 -135 332 -173 110 -33 472 -112 575 -125 325 -44 688 -30 1453 54 172
|
||||
19 352 35 400 35 112 1 156 11 272 66 139 66 171 103 171 197 0 64 -11 95 -52
|
||||
141 -17 20 -30 38 -28 39 2 1 13 7 24 13 11 6 21 23 23 38 2 14 12 31 23 36
|
||||
12 7 19 21 19 38 0 19 7 30 23 37 14 6 23 21 25 39 2 16 10 36 18 44 10 9 13
|
||||
24 9 41 -4 20 -1 28 16 36 58 26 47 86 -21 106 -38 12 -40 14 -40 51 0 51 -18
|
||||
82 -82 145 -73 70 -132 105 -358 213 -547 260 -919 419 -1210 517 -13 5 -13 6
|
||||
0 10 8 3 22 13 30 22 23 26 363 124 434 125 l60 1 21 -85 c29 -118 59 -175
|
||||
129 -245 118 -117 234 -156 461 -158 171 -1 271 17 445 80 268 96 361 157 602
|
||||
396 93 92 171 159 246 209 155 105 513 381 595 458 131 122 189 224 277 485
|
||||
109 325 149 342 163 70 9 -163 30 -242 143 -531 53 -137 98 -258 101 -270 3
|
||||
-14 -5 -28 -29 -46 -18 -14 -94 -80 -168 -147 -137 -123 -261 -216 -306 -227
|
||||
-17 -4 -46 4 -92 27 -60 29 -80 34 -192 41 -69 4 -144 11 -166 14 -103 15
|
||||
-115 -61 -15 -95 19 -6 46 -11 61 -11 44 0 91 -20 88 -38 -2 -8 -15 -24 -30
|
||||
-35 -22 -17 -30 -18 -42 -7 -21 16 -46 6 -46 -19 0 -25 -29 -35 -110 -35 -57
|
||||
-1 -65 -3 -68 -21 -4 -29 44 -54 120 -62 35 -3 66 -12 71 -19 4 -7 31 -25 59
|
||||
-39 41 -21 60 -24 93 -19 25 3 45 2 49 -4 3 -5 34 -9 69 -7 52 1 72 7 108 32
|
||||
58 40 97 59 135 66 32 6 462 230 516 269 18 12 33 17 35 12 2 -6 30 -62 62
|
||||
-126 l58 -116 -3 -112 c-2 -61 -6 -115 -9 -119 -2 -5 -100 -8 -217 -8 -221 0
|
||||
-452 -23 -868 -88 -85 -13 -225 -33 -310 -45 -189 -26 -314 -52 -440 -92 -203
|
||||
-65 -284 -132 -304 -254 -15 -90 30 -173 137 -251 28 -20 113 -85 187 -142 74
|
||||
-58 171 -129 215 -158 105 -71 324 -181 563 -283 106 -45 194 -86 197 -90 9
|
||||
-14 -260 -265 -361 -337 -100 -71 -130 -102 -188 -193 -16 -24 -53 -73 -82
|
||||
-107 -30 -35 -67 -89 -83 -121 -20 -41 -63 -92 -135 -163 -86 -87 -106 -112
|
||||
-112 -144 -4 -22 -15 -53 -26 -70 -23 -38 -23 -73 -1 -105 39 -56 94 -81 132
|
||||
-60 18 9 21 8 21 -9 0 -33 11 -51 41 -67 20 -10 35 -12 46 -5 13 7 21 3 36
|
||||
-15 11 -14 29 -24 44 -24 15 0 34 -7 44 -16 9 -8 27 -16 40 -16 13 -1 33 -8
|
||||
44 -15 11 -7 29 -13 40 -13 50 0 129 132 140 232 21 203 78 389 136 444 17 16
|
||||
51 56 74 89 89 124 200 212 433 343 l142 81 14 -27 c16 -32 36 -151 36 -220 0
|
||||
-35 6 -54 21 -71 43 -46 143 -68 168 -37 6 8 14 37 18 65 5 46 11 56 47 85 23
|
||||
18 61 44 86 58 91 53 151 145 153 234 0 38 -5 50 -33 79 -19 19 -53 42 -77 51
|
||||
-24 9 -43 19 -43 23 0 3 28 24 62 46 81 52 213 178 298 284 63 79 75 89 148
|
||||
122 l80 37 32 -49 c79 -122 233 -192 370 -170 222 37 395 196 428 396 18 107
|
||||
35 427 30 560 -9 217 -63 344 -223 514 -52 56 -95 106 -95 111 0 5 4 12 10 15
|
||||
55 34 235 523 290 785 10 52 28 118 39 145 10 28 29 103 41 169 27 142 24 271
|
||||
-7 352 -28 72 -115 215 -185 303 -65 82 -118 184 -125 241 -11 82 59 182 93
|
||||
135 9 -12 17 -14 31 -7 10 6 25 7 33 2 8 -4 27 -6 41 -3 28 5 44 45 33 80 -5
|
||||
15 -4 15 4 4 12 -17 17 -6 76 144 39 99 43 100 22 10 -8 -33 -13 -62 -10 -64
|
||||
10 -10 65 154 83 249 6 30 16 80 22 110 19 85 16 216 -5 278 -11 32 -22 50
|
||||
-29 45 -7 -4 -8 0 -3 13 4 10 4 15 0 12 -6 -7 -89 109 -89 124 0 4 -6 13 -14
|
||||
20 -10 10 -12 10 -7 1 14 -24 -10 -13 -40 19 -16 17 -23 27 -15 23 9 -5 12 -4
|
||||
8 2 -11 18 -131 71 -188 82 -50 11 -127 14 -259 12 -25 -1 -57 -7 -72 -15 -17
|
||||
-9 -28 -11 -28 -4 0 6 -9 8 -22 3 -13 -4 -31 -7 -41 -6 -9 0 -15 -4 -12 -9 3
|
||||
-6 0 -7 -8 -4 -20 7 -127 -84 -176 -149 -43 -57 -111 -185 -111 -208 0 -19
|
||||
-55 -135 -69 -143 -6 -4 -11 -12 -11 -18 0 -19 29 13 66 73 19 33 37 59 40 59
|
||||
10 0 -65 -126 -103 -173 -30 -36 -39 -53 -30 -59 9 -6 9 -8 0 -8 -9 0 -10 -7
|
||||
-2 -27 6 -16 10 -29 10 -30 -1 -11 23 -63 29 -63 4 0 20 10 36 22 30 24 26 14
|
||||
-13 -39 -13 -18 -20 -33 -14 -33 19 0 74 65 97 115 13 27 24 43 24 34 0 -25
|
||||
-21 -81 -42 -111 -23 -34 -23 -46 0 -25 18 16 19 14 21 -70 3 -183 25 -289 76
|
||||
-381 26 -46 33 -96 15 -107 -6 -3 -86 -17 -178 -30 -240 -35 -301 -61 -360
|
||||
-152 -62 -96 -73 -147 -83 -378 -9 -214 -20 -312 -32 -285 -20 45 -77 356 -91
|
||||
492 -18 174 -34 243 -72 325 -58 121 -120 163 -243 163 -63 0 -80 3 -85 16
|
||||
-11 29 -6 103 13 196 43 209 51 282 51 479 -1 301 -22 464 -76 571 -32 64
|
||||
-132 168 -191 200 -79 43 -224 72 -303 61z m2438 -421 c18 -14 38 -35 44 -46
|
||||
9 -16 -39 22 -102 82 -11 11 27 -13 58 -36z m142 -188 c17 -52 7 -51 -11 1 -9
|
||||
25 -13 42 -8 40 4 -3 13 -21 19 -41z m-1000 -42 c0 -5 -7 -17 -15 -28 -14 -18
|
||||
-14 -17 -4 9 12 27 19 34 19 19z m1037 -14 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13
|
||||
3 -3 4 -12 1 -19z m10 -40 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1
|
||||
-19z m-53 -327 c-4 -23 -9 -40 -11 -37 -3 3 -2 23 2 46 4 23 9 39 11 37 3 -2
|
||||
2 -23 -2 -46z m-17 -73 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1 -19z
|
||||
m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
|
||||
-64 -175 -84 -175 -6 1 -23 18 -38 40 -31 44 -71 60 -155 60 -29 0 -53 3 -52
|
||||
8 0 4 63 59 141 122 182 149 293 258 347 343 24 37 45 67 47 67 3 0 -10 -28
|
||||
-26 -62z m-4768 -415 c-37 -46 -160 -176 -140 -148 21 29 160 185 165 185 3 0
|
||||
-9 -17 -25 -37z m38 -52 c-11 -21 -30 -37 -30 -25 0 8 30 44 37 44 2 0 -1 -9
|
||||
-7 -19z m1692 -588 c22 -30 39 -56 36 -58 -5 -5 -107 115 -122 143 -15 28 42
|
||||
-29 86 -85z m-100 -108 c6 -11 -13 3 -42 30 -28 28 -56 59 -62 70 -6 11 13 -2
|
||||
42 -30 28 -27 56 -59 62 -70z m1587 -1 c29 -6 22 -10 -71 -40 -57 -19 -128
|
||||
-41 -158 -49 -58 -15 -288 -41 -296 -33 -2 3 23 19 56 37 45 24 98 40 208 61
|
||||
153 29 208 34 261 24z m-860 -1488 c150 -59 299 -94 495 -114 l68 -7 -42 -27
|
||||
-42 -28 -111 20 c-62 11 -196 28 -300 38 -103 10 -189 21 -192 23 -2 3 -1 21
|
||||
4 40 5 19 12 46 15 62 4 15 9 27 13 27 3 0 45 -15 92 -34z m3893 -371 l37 -6
|
||||
-55 -72 c-31 -40 -59 -72 -62 -73 -4 -1 -51 44 -104 100 l-97 101 122 -22 c67
|
||||
-13 139 -25 159 -28z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -1,56 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import type { AuthStatus } from "$lib/types";
|
||||
import { logout } from "$lib/services";
|
||||
import { goto } from "$app/navigation";
|
||||
import { getAssetUrl, isModel } from "$lib/directus";
|
||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
||||
import Separator from "../ui/separator/separator.svelte";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||
import Girls from "../girls/girls.svelte";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import type { AuthStatus } from "$lib/types";
|
||||
import { logout } from "$lib/services";
|
||||
import { goto } from "$app/navigation";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
import Separator from "../ui/separator/separator.svelte";
|
||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
|
||||
interface Props {
|
||||
authStatus: AuthStatus;
|
||||
}
|
||||
interface Props {
|
||||
authStatus: AuthStatus;
|
||||
}
|
||||
|
||||
let { authStatus }: Props = $props();
|
||||
let { authStatus }: Props = $props();
|
||||
|
||||
let isMobileMenuOpen = $state(false);
|
||||
let isMobileMenuOpen = $state(false);
|
||||
|
||||
const navLinks = [
|
||||
{ name: $_("header.home"), href: "/" },
|
||||
{ name: $_("header.models"), href: "/models" },
|
||||
{ name: $_("header.videos"), href: "/videos" },
|
||||
{ name: $_("header.magazine"), href: "/magazine" },
|
||||
{ name: $_("header.about"), href: "/about" },
|
||||
];
|
||||
const navLinks = [
|
||||
{ name: $_("header.home"), href: "/" },
|
||||
{ name: $_("header.models"), href: "/models" },
|
||||
{ name: $_("header.videos"), href: "/videos" },
|
||||
{ name: $_("header.magazine"), href: "/magazine" },
|
||||
{ name: $_("header.about"), href: "/about" },
|
||||
];
|
||||
|
||||
async function handleLogout() {
|
||||
closeMenu();
|
||||
await logout();
|
||||
goto("/login", { invalidateAll: true });
|
||||
}
|
||||
async function handleLogout() {
|
||||
closeMenu();
|
||||
await logout();
|
||||
goto("/login", { invalidateAll: true });
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
isMobileMenuOpen = false;
|
||||
}
|
||||
function closeMenu() {
|
||||
isMobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function isActiveLink(link: any) {
|
||||
return (
|
||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||
);
|
||||
}
|
||||
function isActiveLink(link: { name?: string; href: string }) {
|
||||
return (
|
||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
class="sticky top-0 z-50 w-full backdrop-blur-xl shadow-[0_4px_24px_-8px_color-mix(in_oklab,var(--color-primary)_12%,transparent)] bg-card/50"
|
||||
>
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-evenly h-16">
|
||||
@@ -59,336 +56,301 @@ function isActiveLink(link: any) {
|
||||
href="/"
|
||||
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
||||
>
|
||||
<Logo hideName={true} />
|
||||
<Logo />
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden w-full lg:flex items-center justify-center gap-8">
|
||||
{#each navLinks as link}
|
||||
{#each navLinks as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
|
||||
isActiveLink(link) ? 'text-foreground' : 'text-foreground/85'
|
||||
isActiveLink(link) ? "text-foreground" : "text-foreground/85"
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
<span
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? 'w-full' : 'group-hover:w-full'}`}
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? "w-full" : "group-hover:w-full"}`}
|
||||
></span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Desktop Login Button -->
|
||||
<!-- Auth Actions -->
|
||||
{#if authStatus.authenticated}
|
||||
<div class="w-full flex items-center justify-end">
|
||||
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
||||
<!-- Notifications -->
|
||||
<!-- <Button variant="ghost" size="sm" class="relative h-9 w-9 rounded-full p-0 hover:bg-background/80">
|
||||
<BellIcon class="h-4 w-4" />
|
||||
<Badge class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground">3</Badge>
|
||||
<span class="sr-only">Notifications</span>
|
||||
</Button> -->
|
||||
|
||||
<!-- <Separator orientation="vertical" class="mx-1 h-6 bg-border/50" /> -->
|
||||
|
||||
<!-- User Actions -->
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/me' }) ? 'text-foreground' : 'hover:text-foreground'}`}
|
||||
href="/me"
|
||||
title={$_('header.dashboard')}
|
||||
>
|
||||
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
|
||||
<span
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/me' }) ? 'w-full' : 'group-hover:w-full'}`}
|
||||
></span>
|
||||
<span class="sr-only">{$_('header.dashboard')}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/play' }) ? 'text-foreground' : 'hover:text-foreground'}`}
|
||||
class={`flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||
href="/play"
|
||||
title={$_('header.play')}
|
||||
title={$_("header.play")}
|
||||
>
|
||||
<span class="icon-[ri--rocket-line] h-4 w-4"></span>
|
||||
<span
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/play' }) ? 'w-full' : 'group-hover:w-full'}`}
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/play" }) ? "w-full" : "group-hover:w-full"}`}
|
||||
></span>
|
||||
<span class="sr-only">{$_('header.play')}</span>
|
||||
<span class="sr-only">{$_("header.play")}</span>
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
|
||||
{#if authStatus.user?.is_admin}
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class={`flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||
href="/admin/users"
|
||||
title="Admin"
|
||||
>
|
||||
<span class="icon-[ri--settings-3-line] h-4 w-4"></span>
|
||||
<span
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/admin" }) ? "w-full" : "group-hover:w-full"}`}
|
||||
></span>
|
||||
<span class="sr-only">Admin</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Slide Logout Button -->
|
||||
<Separator orientation="vertical" class="hidden lg:block mx-1 h-6 bg-border/50" />
|
||||
|
||||
<LogoutButton
|
||||
user={{
|
||||
name: authStatus.user!.artist_name || authStatus.user!.email.split('@')[0] || 'User',
|
||||
avatar: getAssetUrl(authStatus.user!.avatar?.id, 'mini')!,
|
||||
email: authStatus.user!.email
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
|
||||
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
||||
<AvatarImage
|
||||
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
||||
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold"
|
||||
>
|
||||
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
class="hidden lg:inline text-sm font-medium text-foreground/90 max-w-24 truncate"
|
||||
>
|
||||
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class="hidden lg:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group hover:text-destructive"
|
||||
onclick={handleLogout}
|
||||
title={$_("header.logout")}
|
||||
>
|
||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="lg:hidden ml-2">
|
||||
<BurgerMenuButton
|
||||
label={$_("header.navigation")}
|
||||
bind:isMobileMenuOpen
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex w-full items-center justify-end gap-4">
|
||||
<Button variant="outline" class="font-medium" href="/login"
|
||||
>{$_('header.login')}</Button
|
||||
>
|
||||
<Button
|
||||
href="/signup"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
||||
>{$_('header.signup')}</Button
|
||||
>
|
||||
<div class="w-full flex items-center justify-end gap-2">
|
||||
<div class="flex gap-4">
|
||||
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button
|
||||
>
|
||||
<Button
|
||||
href="/signup"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
||||
>{$_("header.signup")}</Button
|
||||
>
|
||||
</div>
|
||||
<div class="lg:hidden ml-2">
|
||||
<BurgerMenuButton
|
||||
label={$_("header.navigation")}
|
||||
bind:isMobileMenuOpen
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<BurgerMenuButton
|
||||
label={$_('header.navigation')}
|
||||
bind:isMobileMenuOpen
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Navigation -->
|
||||
<div
|
||||
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
{#if isMobileMenuOpen}
|
||||
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
|
||||
<div class="hidden lg:flex col-span-2">
|
||||
<Girls />
|
||||
</div>
|
||||
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95 ">
|
||||
<!-- User Profile Card -->
|
||||
{#if authStatus.authenticated}
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"
|
||||
></div>
|
||||
<div class="relative flex items-center gap-4">
|
||||
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
|
||||
<AvatarImage
|
||||
src={getAssetUrl(authStatus.user!.avatar?.id, 'mini')}
|
||||
alt={authStatus.user!.artist_name}
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
|
||||
>
|
||||
{getUserInitials(authStatus.user!.artist_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<p class="text-base font-semibold text-foreground">
|
||||
{authStatus.user!.artist_name}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{authStatus.user!.email}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span class="text-xs text-muted-foreground">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notifications Badge -->
|
||||
<!-- <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="relative h-10 w-10 rounded-full p-0"
|
||||
>
|
||||
<BellIcon class="h-4 w-4" />
|
||||
<Badge
|
||||
class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground"
|
||||
>3</Badge
|
||||
>
|
||||
</Button> -->
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Navigation Cards -->
|
||||
<div class="space-y-3">
|
||||
<h3
|
||||
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
{$_('header.navigation')}
|
||||
</h3>
|
||||
<div class="grid gap-2">
|
||||
{#each navLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
|
||||
link
|
||||
)
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: ''}"
|
||||
onclick={() => (isMobileMenuOpen = false)}
|
||||
>
|
||||
<span class="font-medium text-foreground">{link.name}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- {#if isActiveLink(link)}
|
||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if} -->
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
|
||||
></span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Actions -->
|
||||
<div class="space-y-3">
|
||||
<h3
|
||||
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
{$_('header.account')}
|
||||
</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
{#if authStatus.authenticated}
|
||||
<a
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/me' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||
href="/me"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.dashboard')}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.dashboard_hint')}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||
></span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/play' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||
href="/play"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.play')}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.play_hint')}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||
></span>
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/login' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||
href="/login"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.login')}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.login_hint')}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||
></span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/signup' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||
href="/signup"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.signup')}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.signup_hint')}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||
></span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authStatus.authenticated}
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
class="cursor-pointer flex w-full items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-4 text-left backdrop-blur-sm transition-all hover:bg-destructive/10 hover:border-destructive/30 group"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<span class="font-medium text-foreground"
|
||||
>{$_('header.logout')}</span
|
||||
>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_('header.logout_hint')}</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
role="presentation"
|
||||
class={`fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 lg:hidden ${isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
onclick={closeMenu}
|
||||
></div>
|
||||
|
||||
<!-- Flyout panel -->
|
||||
<div
|
||||
class={`fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw] bg-card/95 backdrop-blur-xl shadow-2xl shadow-primary/20 border-r border-border/30 transform transition-transform duration-300 ease-in-out lg:hidden overflow-y-auto flex flex-col ${isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"}`}
|
||||
inert={!isMobileMenuOpen || undefined}
|
||||
>
|
||||
<!-- Panel header -->
|
||||
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 py-6 px-5 space-y-6">
|
||||
<!-- User card -->
|
||||
{#if authStatus.authenticated}
|
||||
<div class="flex items-center gap-3 rounded-xl border border-border/40 bg-card/50 px-4 py-3">
|
||||
<Avatar class="h-10 w-10 ring-2 ring-primary/20 shrink-0">
|
||||
<AvatarImage
|
||||
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
||||
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-sm font-semibold"
|
||||
>
|
||||
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-sm font-semibold text-foreground truncate">
|
||||
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground truncate">{authStatus.user!.email}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
|
||||
onclick={handleLogout}
|
||||
title={$_("header.logout")}
|
||||
>
|
||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{$_("header.navigation")}
|
||||
</h3>
|
||||
<div class="grid gap-1.5">
|
||||
{#each navLinks as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class={`flex items-center justify-between rounded-xl border px-4 py-3 transition-all duration-200 hover:border-primary/30 hover:bg-primary/5 ${
|
||||
isActiveLink(link)
|
||||
? "border-primary/40 bg-primary/8 text-foreground"
|
||||
: "border-border/40 bg-card/50 text-foreground/85"
|
||||
}`}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<span class="font-medium text-sm">{link.name}</span>
|
||||
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{$_("header.account")}
|
||||
</h3>
|
||||
<div class="grid gap-1.5">
|
||||
{#if authStatus.authenticated}
|
||||
<a
|
||||
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/me" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||
href="/me"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-foreground">{$_("header.dashboard")}</span>
|
||||
<span class="text-xs text-muted-foreground">{$_("header.dashboard_hint")}</span>
|
||||
</div>
|
||||
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/play" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||
href="/play"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-foreground">{$_("header.play")}</span>
|
||||
<span class="text-xs text-muted-foreground">{$_("header.play_hint")}</span>
|
||||
</div>
|
||||
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||
</a>
|
||||
|
||||
{#if authStatus.user?.is_admin}
|
||||
<a
|
||||
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/admin" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||
href="/admin/users"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--settings-3-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-foreground">Admin</span>
|
||||
<span class="text-xs text-muted-foreground">Manage content</span>
|
||||
</div>
|
||||
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||
</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/login" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||
href="/login"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-foreground">{$_("header.login")}</span>
|
||||
<span class="text-xs text-muted-foreground">{$_("header.login_hint")}</span>
|
||||
</div>
|
||||
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/signup" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
|
||||
href="/signup"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-accent transition-colors"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-foreground">{$_("header.signup")}</span>
|
||||
<span class="text-xs text-muted-foreground">{$_("header.signup_hint")}</span>
|
||||
</div>
|
||||
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
48
packages/frontend/src/lib/components/icon/icon.svelte
Normal file
48
packages/frontend/src/lib/components/icon/icon.svelte
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user