Compare commits
99 Commits
c90c09da9a
...
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 |
@@ -7,9 +7,17 @@ on:
|
|||||||
- develop
|
- develop
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*.*.*"
|
||||||
|
paths:
|
||||||
|
- "packages/backend/**"
|
||||||
|
- "packages/types/**"
|
||||||
|
- "Dockerfile.backend"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths:
|
||||||
|
- "packages/backend/**"
|
||||||
|
- "packages/types/**"
|
||||||
|
- "Dockerfile.backend"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
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
|
||||||
@@ -7,9 +7,17 @@ on:
|
|||||||
- develop
|
- develop
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*.*.*"
|
||||||
|
paths:
|
||||||
|
- "packages/frontend/**"
|
||||||
|
- "packages/types/**"
|
||||||
|
- "Dockerfile"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths:
|
||||||
|
- "packages/frontend/**"
|
||||||
|
- "packages/types/**"
|
||||||
|
- "Dockerfile"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
47
Dockerfile
47
Dockerfile
@@ -3,7 +3,7 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Base stage - shared dependencies
|
# Base stage - shared dependencies
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
FROM node:22.11.0-slim AS base
|
FROM node:22.14.0-slim AS base
|
||||||
|
|
||||||
# Enable corepack for pnpm
|
# Enable corepack for pnpm
|
||||||
RUN npm install -g corepack@latest && corepack enable
|
RUN npm install -g corepack@latest && corepack enable
|
||||||
@@ -20,48 +20,22 @@ RUN mkdir -p ./packages/frontend && \
|
|||||||
printf 'PUBLIC_API_URL=\nPUBLIC_URL=\nPUBLIC_UMAMI_ID=\nPUBLIC_UMAMI_SCRIPT=\n' > ./packages/frontend/.env
|
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
|
FROM base AS builder
|
||||||
ARG CI=false
|
ARG CI=false
|
||||||
ENV CI=$CI
|
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 source files
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
|
|
||||||
# Install all dependencies
|
# Install all dependencies
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Build packages in correct order with WASM support
|
# Generate SvelteKit type definitions (creates .svelte-kit/tsconfig.json)
|
||||||
# 1. Build buttplug WASM
|
RUN pnpm --filter @sexy.pivoine.art/frontend exec svelte-kit sync
|
||||||
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
|
|
||||||
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
|
|
||||||
|
|
||||||
# 2. Build buttplug TypeScript
|
# Build frontend
|
||||||
RUN pnpm --filter @sexy.pivoine.art/buttplug build
|
|
||||||
|
|
||||||
# 3. Build frontend
|
|
||||||
RUN pnpm --filter @sexy.pivoine.art/frontend build
|
RUN pnpm --filter @sexy.pivoine.art/frontend build
|
||||||
|
|
||||||
# Prune dev dependencies for production
|
# Prune dev dependencies for production
|
||||||
@@ -70,7 +44,7 @@ RUN CI=true pnpm install -rP
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Runner stage - minimal production image
|
# 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
|
# Install dumb-init for proper signal handling
|
||||||
RUN apt-get update && apt-get install -y \
|
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-lock.yaml ./pnpm-lock.yaml
|
||||||
COPY --from=builder --chown=node:node /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=builder --chown=node:node /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
|
|
||||||
# Create package directories
|
# Create package directory
|
||||||
RUN mkdir -p packages/frontend packages/buttplug
|
RUN mkdir -p packages/frontend
|
||||||
|
|
||||||
# Copy frontend artifacts
|
# 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/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/node_modules ./packages/frontend/node_modules
|
||||||
COPY --from=builder --chown=node:node /app/packages/frontend/package.json ./packages/frontend/package.json
|
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
|
# Switch to non-root user
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Builder stage
|
# 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
|
RUN npm install -g corepack@latest && corepack enable
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ RUN pnpm rebuild argon2 sharp
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Runner stage
|
# 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 \
|
RUN apt-get update && apt-get install -y \
|
||||||
dumb-init \
|
dumb-init \
|
||||||
@@ -55,7 +55,7 @@ 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/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/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/package.json ./packages/backend/package.json
|
||||||
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/migrations
|
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
|
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
|
||||||
18
compose.yml
18
compose.yml
@@ -64,6 +64,21 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -78,9 +93,12 @@ services:
|
|||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PUBLIC_API_URL: http://sexy_backend:4000
|
PUBLIC_API_URL: http://sexy_backend:4000
|
||||||
PUBLIC_URL: http://localhost:3000
|
PUBLIC_URL: http://localhost:3000
|
||||||
|
BUTTPLUG_URL: http://sexy_buttplug:80
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
buttplug:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export default ts.config(
|
|||||||
"error",
|
"error",
|
||||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
],
|
],
|
||||||
// Allow explicit any sparingly — we're adults here
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
|
||||||
// Enforce consistent type imports
|
// Enforce consistent type imports
|
||||||
"@typescript-eslint/consistent-type-imports": [
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
"error",
|
"error",
|
||||||
@@ -53,7 +51,7 @@ export default ts.config(
|
|||||||
"**/dist/",
|
"**/dist/",
|
||||||
"**/node_modules/",
|
"**/node_modules/",
|
||||||
"**/migrations/",
|
"**/migrations/",
|
||||||
"packages/buttplug/**",
|
"**/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
|
"build:frontend": "pnpm --filter @sexy.pivoine.art/frontend build",
|
||||||
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend 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:data": "docker compose up -d postgres redis",
|
||||||
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
|
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
|
||||||
"dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev",
|
"dev": "pnpm dev:data && pnpm dev:backend & pnpm dev:buttplug & pnpm --filter @sexy.pivoine.art/frontend dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
"email": "valknar@pivoine.art"
|
"email": "valknar@pivoine.art"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"packageManager": "pnpm@10.19.0",
|
"packageManager": "pnpm@10.31.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -14,14 +14,15 @@
|
|||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sexy.pivoine.art/types": "workspace:*",
|
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^10.0.2",
|
"@fastify/cors": "^10.0.2",
|
||||||
"@fastify/multipart": "^9.0.3",
|
"@fastify/multipart": "^9.0.3",
|
||||||
"@fastify/static": "^8.1.1",
|
"@fastify/static": "^8.1.1",
|
||||||
"@pothos/core": "^4.4.0",
|
"@pothos/core": "^4.4.0",
|
||||||
"@pothos/plugin-errors": "^4.2.0",
|
"@pothos/plugin-errors": "^4.2.0",
|
||||||
|
"@sexy.pivoine.art/types": "workspace:*",
|
||||||
"argon2": "^0.43.0",
|
"argon2": "^0.43.0",
|
||||||
|
"bullmq": "^5.70.4",
|
||||||
"drizzle-orm": "^0.44.1",
|
"drizzle-orm": "^0.44.1",
|
||||||
"fastify": "^5.4.0",
|
"fastify": "^5.4.0",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
pgEnum,
|
pgEnum,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { recordings } from "./recordings";
|
import { recordings } from "./recordings";
|
||||||
|
|
||||||
@@ -68,6 +69,11 @@ export const user_points = pgTable(
|
|||||||
(t) => [
|
(t) => [
|
||||||
index("user_points_user_idx").on(t.user_id),
|
index("user_points_user_idx").on(t.user_id),
|
||||||
index("user_points_date_idx").on(t.date_created),
|
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`,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { videos } from "./videos";
|
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(
|
export const recordings = pgTable(
|
||||||
"recordings",
|
"recordings",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const users = pgTable(
|
|||||||
role: roleEnum("role").notNull().default("viewer"),
|
role: roleEnum("role").notNull().default("viewer"),
|
||||||
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
|
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
|
||||||
banner: text("banner").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),
|
is_admin: boolean("is_admin").notNull().default(false),
|
||||||
email_verified: boolean("email_verified").notNull().default(false),
|
email_verified: boolean("email_verified").notNull().default(false),
|
||||||
email_verify_token: text("email_verify_token"),
|
email_verify_token: text("email_verify_token"),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { YogaInitialContext } from "graphql-yoga";
|
import type { YogaInitialContext } from "graphql-yoga";
|
||||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||||
import type { Context } from "./builder";
|
import type { Context } from "./builder";
|
||||||
import { getSession } from "../lib/auth";
|
import { getSession, setSession } from "../lib/auth";
|
||||||
import { db } from "../db/connection";
|
import { db } from "../db/connection";
|
||||||
import { redis } from "../lib/auth";
|
import { redis } from "../lib/auth";
|
||||||
|
import { users } from "../db/schema/index";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
type ServerContext = {
|
type ServerContext = {
|
||||||
req: FastifyRequest;
|
req: FastifyRequest;
|
||||||
@@ -25,7 +27,34 @@ export async function buildContext(ctx: YogaInitialContext & ServerContext): Pro
|
|||||||
);
|
);
|
||||||
|
|
||||||
const token = cookies["session_token"];
|
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 {
|
return {
|
||||||
db: ctx.db || db,
|
db: ctx.db || db,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import "./resolvers/recordings.js";
|
|||||||
import "./resolvers/comments.js";
|
import "./resolvers/comments.js";
|
||||||
import "./resolvers/gamification.js";
|
import "./resolvers/gamification.js";
|
||||||
import "./resolvers/stats.js";
|
import "./resolvers/stats.js";
|
||||||
|
import "./resolvers/queues.js";
|
||||||
import { builder } from "./builder";
|
import { builder } from "./builder";
|
||||||
|
|
||||||
export const schema = builder.toSchema();
|
export const schema = builder.toSchema();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { ArticleType } from "../types/index";
|
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
|
||||||
import { articles, users } from "../../db/schema/index";
|
import { articles, users } from "../../db/schema/index";
|
||||||
import { eq, and, lte, desc } from "drizzle-orm";
|
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains, type SQL } from "drizzle-orm";
|
||||||
import { requireAdmin } from "../../lib/acl";
|
import { requireAdmin } from "../../lib/acl";
|
||||||
|
import type { DB } from "../../db/connection";
|
||||||
|
|
||||||
async function enrichArticle(db: any, article: any) {
|
async function enrichArticle(db: DB, article: typeof articles.$inferSelect) {
|
||||||
let author = null;
|
let author = null;
|
||||||
if (article.author) {
|
if (article.author) {
|
||||||
const authorUser = await db
|
const authorUser = await db
|
||||||
@@ -13,6 +14,7 @@ async function enrichArticle(db: any, article: any) {
|
|||||||
artist_name: users.artist_name,
|
artist_name: users.artist_name,
|
||||||
slug: users.slug,
|
slug: users.slug,
|
||||||
avatar: users.avatar,
|
avatar: users.avatar,
|
||||||
|
description: users.description,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, article.author))
|
.where(eq(users.id, article.author))
|
||||||
@@ -24,30 +26,50 @@ async function enrichArticle(db: any, article: any) {
|
|||||||
|
|
||||||
builder.queryField("articles", (t) =>
|
builder.queryField("articles", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [ArticleType],
|
type: ArticleListType,
|
||||||
args: {
|
args: {
|
||||||
featured: t.arg.boolean(),
|
featured: t.arg.boolean(),
|
||||||
limit: t.arg.int(),
|
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) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
const dateFilter = lte(articles.publish_date, new Date());
|
const pageSize = args.limit ?? 24;
|
||||||
const whereCondition =
|
const offset = args.offset ?? 0;
|
||||||
args.featured !== null && args.featured !== undefined
|
|
||||||
? and(dateFilter, eq(articles.featured, args.featured))
|
|
||||||
: dateFilter;
|
|
||||||
|
|
||||||
let query = ctx.db
|
const conditions: SQL<unknown>[] = [lte(articles.publish_date, new Date())];
|
||||||
.select()
|
if (args.featured !== null && args.featured !== undefined) {
|
||||||
.from(articles)
|
conditions.push(eq(articles.featured, args.featured));
|
||||||
.where(whereCondition)
|
}
|
||||||
.orderBy(desc(articles.publish_date));
|
if (args.category) conditions.push(eq(articles.category, args.category));
|
||||||
|
if (args.tag) conditions.push(arrayContains(articles.tags, [args.tag]));
|
||||||
if (args.limit) {
|
if (args.search) {
|
||||||
query = (query as any).limit(args.limit);
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(articles.title, `%${args.search}%`),
|
||||||
|
ilike(articles.excerpt, `%${args.search}%`),
|
||||||
|
) as SQL<unknown>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const articleList = await query;
|
const where = and(...conditions);
|
||||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
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));
|
||||||
|
|
||||||
|
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 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -72,15 +94,65 @@ builder.queryField("article", (t) =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 ────────────────────────────────────────────────
|
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||||
|
|
||||||
builder.queryField("adminListArticles", (t) =>
|
builder.queryField("adminListArticles", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [ArticleType],
|
type: AdminArticleListType,
|
||||||
resolve: async (_root, _args, ctx) => {
|
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);
|
requireAdmin(ctx);
|
||||||
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
|
const limit = args.limit ?? 50;
|
||||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
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 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -155,7 +227,7 @@ builder.mutationField("updateArticle", (t) =>
|
|||||||
|
|
||||||
const updated = await ctx.db
|
const updated = await ctx.db
|
||||||
.update(articles)
|
.update(articles)
|
||||||
.set(updates as any)
|
.set(updates as Partial<typeof articles.$inferInsert>)
|
||||||
.where(eq(articles.id, args.id))
|
.where(eq(articles.id, args.id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!updated[0]) return null;
|
if (!updated[0]) return null;
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import { builder } from "../builder";
|
|||||||
import { CurrentUserType } from "../types/index";
|
import { CurrentUserType } from "../types/index";
|
||||||
import { users } from "../../db/schema/index";
|
import { users } from "../../db/schema/index";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
interface ReplyLike {
|
||||||
|
header?: (name: string, value: string) => void;
|
||||||
|
}
|
||||||
import { hash, verify as verifyArgon } from "../../lib/argon";
|
import { hash, verify as verifyArgon } from "../../lib/argon";
|
||||||
import { setSession, deleteSession } from "../../lib/auth";
|
import { setSession, deleteSession } from "../../lib/auth";
|
||||||
import { sendVerification, sendPasswordReset } from "../../lib/email";
|
import { enqueueVerification, enqueuePasswordReset } from "../../lib/email";
|
||||||
import { slugify } from "../../lib/slugify";
|
import { slugify } from "../../lib/slugify";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -45,13 +49,8 @@ builder.mutationField("login", (t) =>
|
|||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
||||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
|
||||||
|
|
||||||
// For graphql-yoga response
|
|
||||||
if ((ctx as any).serverResponse) {
|
|
||||||
(ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user[0];
|
return user[0];
|
||||||
},
|
},
|
||||||
@@ -74,8 +73,9 @@ builder.mutationField("logout", (t) =>
|
|||||||
await deleteSession(token);
|
await deleteSession(token);
|
||||||
}
|
}
|
||||||
// Clear cookie
|
// Clear cookie
|
||||||
const cookieValue = "session_token=; HttpOnly; Path=/; Max-Age=0";
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
const cookieValue = `session_token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isProduction ? "; Secure" : ""}`;
|
||||||
|
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -131,9 +131,9 @@ builder.mutationField("register", (t) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendVerification(args.email, verifyToken);
|
await enqueueVerification(args.email, verifyToken);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to send verification email:", (e as Error).message);
|
console.warn("Failed to enqueue verification email:", (e as Error).message);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -190,9 +190,9 @@ builder.mutationField("requestPasswordReset", (t) =>
|
|||||||
.where(eq(users.id, user[0].id));
|
.where(eq(users.id, user[0].id));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendPasswordReset(args.email, token);
|
await enqueuePasswordReset(args.email, token);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to send password reset email:", (e as Error).message);
|
console.warn("Failed to enqueue password reset email:", (e as Error).message);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { GraphQLError } from "graphql";
|
import { GraphQLError } from "graphql";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { CommentType } from "../types/index";
|
import { CommentType, AdminCommentListType } from "../types/index";
|
||||||
import { comments, users } from "../../db/schema/index";
|
import { comments, users } from "../../db/schema/index";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc, ilike, count } from "drizzle-orm";
|
||||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
|
||||||
import { requireOwnerOrAdmin } from "../../lib/acl";
|
import { gamificationQueue } from "../../queues/index";
|
||||||
|
|
||||||
builder.queryField("commentsForVideo", (t) =>
|
builder.queryField("commentsForVideo", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
@@ -20,7 +20,7 @@ builder.queryField("commentsForVideo", (t) =>
|
|||||||
.orderBy(desc(comments.date_created));
|
.orderBy(desc(comments.date_created));
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
commentList.map(async (c: any) => {
|
commentList.map(async (c) => {
|
||||||
const user = await ctx.db
|
const user = await ctx.db
|
||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
@@ -59,9 +59,16 @@ builder.mutationField("createCommentForVideo", (t) =>
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Gamification
|
await gamificationQueue.add("awardPoints", {
|
||||||
await awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE");
|
job: "awardPoints",
|
||||||
await checkAchievements(ctx.db, ctx.currentUser.id, "social");
|
userId: ctx.currentUser.id,
|
||||||
|
action: "COMMENT_CREATE",
|
||||||
|
});
|
||||||
|
await gamificationQueue.add("checkAchievements", {
|
||||||
|
job: "checkAchievements",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
category: "social",
|
||||||
|
});
|
||||||
|
|
||||||
const user = await ctx.db
|
const user = await ctx.db
|
||||||
.select({
|
.select({
|
||||||
@@ -91,7 +98,68 @@ builder.mutationField("deleteComment", (t) =>
|
|||||||
if (!comment[0]) throw new GraphQLError("Comment not found");
|
if (!comment[0]) throw new GraphQLError("Comment not found");
|
||||||
requireOwnerOrAdmin(ctx, comment[0].user_id);
|
requireOwnerOrAdmin(ctx, comment[0].user_id);
|
||||||
await ctx.db.delete(comments).where(eq(comments.id, args.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;
|
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 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ builder.queryField("leaderboard", (t) =>
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
return entries.map((e: any, i: number) => ({ ...e, rank: offset + i + 1 }));
|
return entries.map((e, i) => ({ ...e, rank: offset + i + 1 }));
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -101,8 +101,15 @@ builder.queryField("userGamification", (t) =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
stats: stats[0] ? { ...stats[0], rank } : null,
|
stats: stats[0] ? { ...stats[0], rank } : null,
|
||||||
achievements: userAchievements.map((a: any) => ({
|
achievements: userAchievements.map((a) => ({
|
||||||
...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!,
|
date_unlocked: a.date_unlocked!,
|
||||||
})),
|
})),
|
||||||
recent_points: recentPoints,
|
recent_points: recentPoints,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { ModelType } from "../types/index";
|
import { ModelType, ModelListType } from "../types/index";
|
||||||
import { users, user_photos, files } from "../../db/schema/index";
|
import { users, user_photos, files } from "../../db/schema/index";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
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
|
// Fetch photos
|
||||||
const photoRows = await db
|
const photoRows = await db
|
||||||
.select({ id: files.id, filename: files.filename })
|
.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))
|
.where(eq(user_photos.user_id, user.id))
|
||||||
.orderBy(user_photos.sort);
|
.orderBy(user_photos.sort);
|
||||||
|
|
||||||
return {
|
const seen = new Set<string>();
|
||||||
...user,
|
const photos = photoRows
|
||||||
photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })),
|
.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) =>
|
builder.queryField("models", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [ModelType],
|
type: ModelListType,
|
||||||
args: {
|
args: {
|
||||||
featured: t.arg.boolean(),
|
featured: t.arg.boolean(),
|
||||||
limit: t.arg.int(),
|
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) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
let query = ctx.db
|
const pageSize = args.limit ?? 24;
|
||||||
.select()
|
const offset = args.offset ?? 0;
|
||||||
.from(users)
|
|
||||||
.where(eq(users.role, "model"))
|
|
||||||
.orderBy(desc(users.date_created));
|
|
||||||
|
|
||||||
if (args.limit) {
|
const conditions: SQL<unknown>[] = [eq(users.role, "model")];
|
||||||
query = (query as any).limit(args.limit);
|
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;
|
const order = args.sortBy === "recent" ? desc(users.date_created) : asc(users.artist_name);
|
||||||
return Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
|
|
||||||
|
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 { GraphQLError } from "graphql";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { RecordingType } from "../types/index";
|
import { RecordingType, AdminRecordingListType } from "../types/index";
|
||||||
import { recordings, recording_plays } from "../../db/schema/index";
|
import { recordings, recording_plays } from "../../db/schema/index";
|
||||||
import { eq, and, desc, ne } from "drizzle-orm";
|
import { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
|
||||||
import { slugify } from "../../lib/slugify";
|
import { slugify } from "../../lib/slugify";
|
||||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
import { requireAdmin } from "../../lib/acl";
|
||||||
|
import { gamificationQueue } from "../../queues/index";
|
||||||
|
|
||||||
builder.queryField("recordings", (t) =>
|
builder.queryField("recordings", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
@@ -20,8 +21,7 @@ builder.queryField("recordings", (t) =>
|
|||||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||||
|
|
||||||
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
|
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"));
|
||||||
else conditions.push(ne(recordings.status, "archived" as any));
|
|
||||||
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
|
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
|
||||||
|
|
||||||
const limit = args.limit || 50;
|
const limit = args.limit || 50;
|
||||||
@@ -115,17 +115,25 @@ builder.mutationField("createRecording", (t) =>
|
|||||||
user_id: ctx.currentUser.id,
|
user_id: ctx.currentUser.id,
|
||||||
tags: args.tags || [],
|
tags: args.tags || [],
|
||||||
linked_video: args.linkedVideoId || null,
|
linked_video: args.linkedVideoId || null,
|
||||||
status: (args.status as any) || "draft",
|
status: (args.status as "draft" | "published") || "draft",
|
||||||
public: false,
|
public: false,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const recording = newRecording[0];
|
const recording = newRecording[0];
|
||||||
|
|
||||||
// Gamification: award points if published
|
|
||||||
if (recording.status === "published") {
|
if (recording.status === "published") {
|
||||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id);
|
await gamificationQueue.add("awardPoints", {
|
||||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
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;
|
return recording;
|
||||||
@@ -173,20 +181,51 @@ builder.mutationField("updateRecording", (t) =>
|
|||||||
|
|
||||||
const updated = await ctx.db
|
const updated = await ctx.db
|
||||||
.update(recordings)
|
.update(recordings)
|
||||||
.set(updates as any)
|
.set(updates as Partial<typeof recordings.$inferInsert>)
|
||||||
.where(eq(recordings.id, args.id))
|
.where(eq(recordings.id, args.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const recording = updated[0];
|
const recording = updated[0];
|
||||||
|
|
||||||
// Gamification: if newly published
|
|
||||||
if (args.status === "published" && existing[0].status !== "published") {
|
if (args.status === "published" && existing[0].status !== "published") {
|
||||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id);
|
// draft → published: award creation points
|
||||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
await gamificationQueue.add("awardPoints", {
|
||||||
}
|
job: "awardPoints",
|
||||||
if (args.status === "published" && recording.featured && !existing[0].featured) {
|
userId: ctx.currentUser.id,
|
||||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id);
|
action: "RECORDING_CREATE",
|
||||||
await checkAchievements(ctx.db, ctx.currentUser.id, "recordings");
|
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;
|
return recording;
|
||||||
@@ -212,6 +251,28 @@ builder.mutationField("deleteRecording", (t) =>
|
|||||||
if (!existing[0]) throw new GraphQLError("Recording not found");
|
if (!existing[0]) throw new GraphQLError("Recording not found");
|
||||||
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
||||||
|
|
||||||
|
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));
|
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -288,10 +349,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
|
|||||||
})
|
})
|
||||||
.returning({ id: recording_plays.id });
|
.returning({ id: recording_plays.id });
|
||||||
|
|
||||||
// Gamification
|
|
||||||
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
||||||
await awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId);
|
await gamificationQueue.add("awardPoints", {
|
||||||
await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
|
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 };
|
return { success: true, play_id: play[0].id };
|
||||||
@@ -327,16 +396,69 @@ builder.mutationField("updateRecordingPlay", (t) =>
|
|||||||
.where(eq(recording_plays.id, args.playId));
|
.where(eq(recording_plays.id, args.playId));
|
||||||
|
|
||||||
if (args.completed && !wasCompleted && ctx.currentUser) {
|
if (args.completed && !wasCompleted && ctx.currentUser) {
|
||||||
await awardPoints(
|
await gamificationQueue.add("awardPoints", {
|
||||||
ctx.db,
|
job: "awardPoints",
|
||||||
ctx.currentUser.id,
|
userId: ctx.currentUser.id,
|
||||||
"RECORDING_COMPLETE",
|
action: "RECORDING_COMPLETE",
|
||||||
existing[0].recording_id,
|
recordingId: existing[0].recording_id,
|
||||||
);
|
});
|
||||||
await checkAchievements(ctx.db, ctx.currentUser.id, "playback");
|
await gamificationQueue.add("checkAchievements", {
|
||||||
|
job: "checkAchievements",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
category: "playback",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
|
|||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
|
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
|
||||||
import { users, user_photos, files } from "../../db/schema/index";
|
import { users, user_photos, files } from "../../db/schema/index";
|
||||||
import { eq, ilike, or, count, and } from "drizzle-orm";
|
import { eq, ilike, or, count, and, asc, type SQL } from "drizzle-orm";
|
||||||
import { requireAdmin } from "../../lib/acl";
|
import { requireAdmin } from "../../lib/acl";
|
||||||
|
|
||||||
builder.queryField("me", (t) =>
|
builder.queryField("me", (t) =>
|
||||||
@@ -45,6 +45,7 @@ builder.mutationField("updateProfile", (t) =>
|
|||||||
artistName: t.arg.string(),
|
artistName: t.arg.string(),
|
||||||
description: t.arg.string(),
|
description: t.arg.string(),
|
||||||
tags: t.arg.stringList(),
|
tags: t.arg.stringList(),
|
||||||
|
avatar: t.arg.string(),
|
||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||||
@@ -58,10 +59,11 @@ builder.mutationField("updateProfile", (t) =>
|
|||||||
if (args.description !== undefined && args.description !== null)
|
if (args.description !== undefined && args.description !== null)
|
||||||
updates.description = args.description;
|
updates.description = args.description;
|
||||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||||
|
if (args.avatar !== undefined) updates.avatar = args.avatar;
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set(updates as any)
|
.set(updates as Partial<typeof users.$inferInsert>)
|
||||||
.where(eq(users.id, ctx.currentUser.id));
|
.where(eq(users.id, ctx.currentUser.id));
|
||||||
|
|
||||||
const updated = await ctx.db
|
const updated = await ctx.db
|
||||||
@@ -91,27 +93,27 @@ builder.queryField("adminListUsers", (t) =>
|
|||||||
const limit = args.limit ?? 50;
|
const limit = args.limit ?? 50;
|
||||||
const offset = args.offset ?? 0;
|
const offset = args.offset ?? 0;
|
||||||
|
|
||||||
let query = ctx.db.select().from(users);
|
const conditions: SQL<unknown>[] = [];
|
||||||
let countQuery = ctx.db.select({ total: count() }).from(users);
|
|
||||||
|
|
||||||
const conditions: any[] = [];
|
|
||||||
if (args.role) {
|
if (args.role) {
|
||||||
conditions.push(eq(users.role, args.role as any));
|
conditions.push(eq(users.role, args.role as "model" | "viewer" | "admin"));
|
||||||
}
|
}
|
||||||
if (args.search) {
|
if (args.search) {
|
||||||
const pattern = `%${args.search}%`;
|
const pattern = `%${args.search}%`;
|
||||||
conditions.push(or(ilike(users.email, pattern), ilike(users.artist_name, pattern)));
|
conditions.push(
|
||||||
}
|
or(ilike(users.email, pattern), ilike(users.artist_name, pattern)) as SQL<unknown>,
|
||||||
|
);
|
||||||
if (conditions.length > 0) {
|
|
||||||
const where = conditions.length === 1 ? conditions[0] : and(...conditions);
|
|
||||||
query = (query as any).where(where);
|
|
||||||
countQuery = (countQuery as any).where(where);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
const [items, totalRows] = await Promise.all([
|
const [items, totalRows] = await Promise.all([
|
||||||
(query as any).limit(limit).offset(offset),
|
ctx.db
|
||||||
countQuery,
|
.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 };
|
return { items, total: totalRows[0]?.total ?? 0 };
|
||||||
@@ -132,12 +134,14 @@ builder.mutationField("adminUpdateUser", (t) =>
|
|||||||
artistName: t.arg.string(),
|
artistName: t.arg.string(),
|
||||||
avatarId: t.arg.string(),
|
avatarId: t.arg.string(),
|
||||||
bannerId: t.arg.string(),
|
bannerId: t.arg.string(),
|
||||||
|
photoId: t.arg.string(),
|
||||||
},
|
},
|
||||||
resolve: async (_root, args, ctx) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
requireAdmin(ctx);
|
requireAdmin(ctx);
|
||||||
|
|
||||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||||
if (args.role !== undefined && args.role !== null) updates.role = args.role as any;
|
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.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin;
|
||||||
if (args.firstName !== undefined && args.firstName !== null)
|
if (args.firstName !== undefined && args.firstName !== null)
|
||||||
updates.first_name = args.firstName;
|
updates.first_name = args.firstName;
|
||||||
@@ -146,10 +150,11 @@ builder.mutationField("adminUpdateUser", (t) =>
|
|||||||
updates.artist_name = args.artistName;
|
updates.artist_name = args.artistName;
|
||||||
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
|
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
|
||||||
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
|
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
|
const updated = await ctx.db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set(updates as any)
|
.set(updates as Partial<typeof users.$inferInsert>)
|
||||||
.where(eq(users.id, args.userId))
|
.where(eq(users.id, args.userId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -192,8 +197,8 @@ builder.queryField("adminGetUser", (t) =>
|
|||||||
.orderBy(user_photos.sort);
|
.orderBy(user_photos.sort);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const photos = photoRows
|
const photos = photoRows
|
||||||
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
|
.filter((p) => p.id !== null && !seen.has(p.id!) && seen.add(p.id!))
|
||||||
.map((p: any) => ({ id: p.id, filename: p.filename }));
|
.map((p) => ({ id: p.id!, filename: p.filename! }));
|
||||||
return { ...user[0], photos };
|
return { ...user[0], photos };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { GraphQLError } from "graphql";
|
|||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import {
|
import {
|
||||||
VideoType,
|
VideoType,
|
||||||
|
VideoListType,
|
||||||
|
AdminVideoListType,
|
||||||
VideoLikeResponseType,
|
VideoLikeResponseType,
|
||||||
VideoPlayResponseType,
|
VideoPlayResponseType,
|
||||||
VideoLikeStatusType,
|
VideoLikeStatusType,
|
||||||
@@ -14,10 +16,24 @@ import {
|
|||||||
users,
|
users,
|
||||||
files,
|
files,
|
||||||
} from "../../db/schema/index";
|
} from "../../db/schema/index";
|
||||||
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
|
import {
|
||||||
|
eq,
|
||||||
|
and,
|
||||||
|
lte,
|
||||||
|
desc,
|
||||||
|
asc,
|
||||||
|
inArray,
|
||||||
|
count,
|
||||||
|
ilike,
|
||||||
|
lt,
|
||||||
|
gte,
|
||||||
|
arrayContains,
|
||||||
|
type SQL,
|
||||||
|
} from "drizzle-orm";
|
||||||
import { requireAdmin } from "../../lib/acl";
|
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
|
// Fetch models
|
||||||
const modelRows = await db
|
const modelRows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -25,6 +41,7 @@ async function enrichVideo(db: any, video: any) {
|
|||||||
artist_name: users.artist_name,
|
artist_name: users.artist_name,
|
||||||
slug: users.slug,
|
slug: users.slug,
|
||||||
avatar: users.avatar,
|
avatar: users.avatar,
|
||||||
|
description: users.description,
|
||||||
})
|
})
|
||||||
.from(video_models)
|
.from(video_models)
|
||||||
.leftJoin(users, eq(video_models.user_id, users.id))
|
.leftJoin(users, eq(video_models.user_id, users.id))
|
||||||
@@ -47,9 +64,19 @@ async function enrichVideo(db: any, video: any) {
|
|||||||
.from(video_plays)
|
.from(video_plays)
|
||||||
.where(eq(video_plays.video_id, video.id));
|
.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 {
|
return {
|
||||||
...video,
|
...video,
|
||||||
models: modelRows,
|
models,
|
||||||
movie_file: movieFile,
|
movie_file: movieFile,
|
||||||
likes_count: likesCount[0]?.count || 0,
|
likes_count: likesCount[0]?.count || 0,
|
||||||
plays_count: playsCount[0]?.count || 0,
|
plays_count: playsCount[0]?.count || 0,
|
||||||
@@ -58,67 +85,93 @@ async function enrichVideo(db: any, video: any) {
|
|||||||
|
|
||||||
builder.queryField("videos", (t) =>
|
builder.queryField("videos", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [VideoType],
|
type: VideoListType,
|
||||||
args: {
|
args: {
|
||||||
modelId: t.arg.string(),
|
modelId: t.arg.string(),
|
||||||
featured: t.arg.boolean(),
|
featured: t.arg.boolean(),
|
||||||
limit: t.arg.int(),
|
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) => {
|
resolve: async (_root, args, ctx) => {
|
||||||
// Unauthenticated users cannot see premium videos
|
const pageSize = args.limit ?? 24;
|
||||||
const premiumFilter = !ctx.currentUser ? eq(videos.premium, false) : undefined;
|
const offset = args.offset ?? 0;
|
||||||
|
|
||||||
let query = ctx.db
|
|
||||||
.select({ v: videos })
|
|
||||||
.from(videos)
|
|
||||||
.where(and(lte(videos.upload_date, new Date()), premiumFilter))
|
|
||||||
.orderBy(desc(videos.upload_date));
|
|
||||||
|
|
||||||
|
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) {
|
if (args.modelId) {
|
||||||
const videoIds = await ctx.db
|
const videoIds = await ctx.db
|
||||||
.select({ video_id: video_models.video_id })
|
.select({ video_id: video_models.video_id })
|
||||||
.from(video_models)
|
.from(video_models)
|
||||||
.where(eq(video_models.user_id, args.modelId));
|
.where(eq(video_models.user_id, args.modelId));
|
||||||
|
if (videoIds.length === 0) return { items: [], total: 0 };
|
||||||
if (videoIds.length === 0) return [];
|
conditions.push(
|
||||||
|
inArray(
|
||||||
query = ctx.db
|
videos.id,
|
||||||
.select({ v: videos })
|
videoIds.map((v) => v.video_id),
|
||||||
.from(videos)
|
),
|
||||||
.where(
|
);
|
||||||
and(
|
|
||||||
lte(videos.upload_date, new Date()),
|
|
||||||
premiumFilter,
|
|
||||||
inArray(
|
|
||||||
videos.id,
|
|
||||||
videoIds.map((v: any) => v.video_id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(videos.upload_date));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.featured !== null && args.featured !== undefined) {
|
const order =
|
||||||
query = ctx.db
|
args.sortBy === "most_liked"
|
||||||
.select({ v: videos })
|
? desc(videos.likes_count)
|
||||||
.from(videos)
|
: args.sortBy === "most_played"
|
||||||
.where(
|
? desc(videos.plays_count)
|
||||||
and(
|
: args.sortBy === "name"
|
||||||
lte(videos.upload_date, new Date()),
|
? asc(videos.title)
|
||||||
premiumFilter,
|
: desc(videos.upload_date);
|
||||||
eq(videos.featured, args.featured),
|
|
||||||
),
|
const where = and(...conditions);
|
||||||
)
|
|
||||||
.orderBy(desc(videos.upload_date));
|
// 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) {
|
const [rows, totalRows] = await Promise.all([
|
||||||
query = (query as any).limit(args.limit);
|
ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset),
|
||||||
}
|
ctx.db.select({ total: count() }).from(videos).where(where),
|
||||||
|
]);
|
||||||
const rows = await query;
|
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
|
||||||
const videoList = rows.map((r: any) => r.v || r);
|
return { items, total: totalRows[0]?.total ?? 0 };
|
||||||
return Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -148,6 +201,22 @@ builder.queryField("video", (t) =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
builder.queryField("videoLikeStatus", (t) =>
|
builder.queryField("videoLikeStatus", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: VideoLikeStatusType,
|
type: VideoLikeStatusType,
|
||||||
@@ -365,7 +434,7 @@ builder.queryField("analytics", (t) =>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
|
||||||
const plays = await ctx.db
|
const plays = await ctx.db
|
||||||
.select()
|
.select()
|
||||||
@@ -379,14 +448,14 @@ builder.queryField("analytics", (t) =>
|
|||||||
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
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 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];
|
const date = new Date(play.date_created).toISOString().split("T")[0];
|
||||||
if (!acc[date]) acc[date] = 0;
|
if (!acc[date]) acc[date] = 0;
|
||||||
acc[date]++;
|
acc[date]++;
|
||||||
return acc;
|
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];
|
const date = new Date(like.date_created).toISOString().split("T")[0];
|
||||||
if (!acc[date]) acc[date] = 0;
|
if (!acc[date]) acc[date] = 0;
|
||||||
acc[date]++;
|
acc[date]++;
|
||||||
@@ -430,11 +499,39 @@ builder.queryField("analytics", (t) =>
|
|||||||
|
|
||||||
builder.queryField("adminListVideos", (t) =>
|
builder.queryField("adminListVideos", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
type: [VideoType],
|
type: AdminVideoListType,
|
||||||
resolve: async (_root, _args, ctx) => {
|
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);
|
requireAdmin(ctx);
|
||||||
const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date));
|
const limit = args.limit ?? 50;
|
||||||
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
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 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -506,7 +603,7 @@ builder.mutationField("updateVideo", (t) =>
|
|||||||
|
|
||||||
const updated = await ctx.db
|
const updated = await ctx.db
|
||||||
.update(videos)
|
.update(videos)
|
||||||
.set(updates as any)
|
.set(updates as Partial<typeof videos.$inferInsert>)
|
||||||
.where(eq(videos.id, args.id))
|
.where(eq(videos.id, args.id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!updated[0]) return null;
|
if (!updated[0]) return null;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const UserType = builder.objectRef<User>("User").implement({
|
|||||||
is_admin: t.exposeBoolean("is_admin"),
|
is_admin: t.exposeBoolean("is_admin"),
|
||||||
avatar: t.exposeString("avatar", { nullable: true }),
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
banner: t.exposeString("banner", { nullable: true }),
|
banner: t.exposeString("banner", { nullable: true }),
|
||||||
|
photo: t.exposeString("photo", { nullable: true }),
|
||||||
email_verified: t.exposeBoolean("email_verified"),
|
email_verified: t.exposeBoolean("email_verified"),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
}),
|
}),
|
||||||
@@ -75,6 +76,7 @@ export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement(
|
|||||||
is_admin: t.exposeBoolean("is_admin"),
|
is_admin: t.exposeBoolean("is_admin"),
|
||||||
avatar: t.exposeString("avatar", { nullable: true }),
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
banner: t.exposeString("banner", { nullable: true }),
|
banner: t.exposeString("banner", { nullable: true }),
|
||||||
|
photo: t.exposeString("photo", { nullable: true }),
|
||||||
email_verified: t.exposeBoolean("email_verified"),
|
email_verified: t.exposeBoolean("email_verified"),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
}),
|
}),
|
||||||
@@ -86,6 +88,7 @@ export const VideoModelType = builder.objectRef<VideoModel>("VideoModel").implem
|
|||||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||||
slug: t.exposeString("slug", { nullable: true }),
|
slug: t.exposeString("slug", { nullable: true }),
|
||||||
avatar: t.exposeString("avatar", { nullable: true }),
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
|
description: t.exposeString("description", { nullable: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,6 +135,7 @@ export const ModelType = builder.objectRef<Model>("Model").implement({
|
|||||||
description: t.exposeString("description", { nullable: true }),
|
description: t.exposeString("description", { nullable: true }),
|
||||||
avatar: t.exposeString("avatar", { nullable: true }),
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
banner: t.exposeString("banner", { nullable: true }),
|
banner: t.exposeString("banner", { nullable: true }),
|
||||||
|
photo: t.exposeString("photo", { nullable: true }),
|
||||||
tags: t.exposeStringList("tags", { nullable: true }),
|
tags: t.exposeStringList("tags", { nullable: true }),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
|
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
|
||||||
@@ -329,6 +333,137 @@ export const AchievementType = builder.objectRef<Achievement>("Achievement").imp
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 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
|
export const AdminUserListType = builder
|
||||||
.objectRef<{ items: User[]; total: number }>("AdminUserList")
|
.objectRef<{ items: User[]; total: number }>("AdminUserList")
|
||||||
.implement({
|
.implement({
|
||||||
@@ -338,24 +473,23 @@ export const AdminUserListType = builder
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AdminUserDetailType = builder
|
export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUserDetail").implement({
|
||||||
.objectRef<AdminUserDetail>("AdminUserDetail")
|
fields: (t) => ({
|
||||||
.implement({
|
id: t.exposeString("id"),
|
||||||
fields: (t) => ({
|
email: t.exposeString("email"),
|
||||||
id: t.exposeString("id"),
|
first_name: t.exposeString("first_name", { nullable: true }),
|
||||||
email: t.exposeString("email"),
|
last_name: t.exposeString("last_name", { nullable: true }),
|
||||||
first_name: t.exposeString("first_name", { nullable: true }),
|
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||||
last_name: t.exposeString("last_name", { nullable: true }),
|
slug: t.exposeString("slug", { nullable: true }),
|
||||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
description: t.exposeString("description", { nullable: true }),
|
||||||
slug: t.exposeString("slug", { nullable: true }),
|
tags: t.exposeStringList("tags", { nullable: true }),
|
||||||
description: t.exposeString("description", { nullable: true }),
|
role: t.exposeString("role"),
|
||||||
tags: t.exposeStringList("tags", { nullable: true }),
|
is_admin: t.exposeBoolean("is_admin"),
|
||||||
role: t.exposeString("role"),
|
avatar: t.exposeString("avatar", { nullable: true }),
|
||||||
is_admin: t.exposeBoolean("is_admin"),
|
banner: t.exposeString("banner", { nullable: true }),
|
||||||
avatar: t.exposeString("avatar", { nullable: true }),
|
photo: t.exposeString("photo", { nullable: true }),
|
||||||
banner: t.exposeString("banner", { nullable: true }),
|
email_verified: t.exposeBoolean("email_verified"),
|
||||||
email_verified: t.exposeBoolean("email_verified"),
|
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
photos: t.expose("photos", { type: [ModelPhotoType] }),
|
||||||
photos: t.expose("photos", { type: [ModelPhotoType] }),
|
}),
|
||||||
}),
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,19 +7,34 @@ import { createYoga } from "graphql-yoga";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { files } from "./db/schema/index";
|
import { files } from "./db/schema/index";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { existsSync } from "fs";
|
import { existsSync, mkdirSync } from "fs";
|
||||||
|
import { writeFile, rm } from "fs/promises";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { schema } from "./graphql/index";
|
import { schema } from "./graphql/index";
|
||||||
import { buildContext } from "./graphql/context";
|
import { buildContext } from "./graphql/context";
|
||||||
import { db } from "./db/connection";
|
import { db } from "./db/connection";
|
||||||
import { redis } from "./lib/auth";
|
import { redis } from "./lib/auth";
|
||||||
import { logger } from "./lib/logger";
|
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 PORT = parseInt(process.env.PORT || "4000");
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
|
||||||
|
|
||||||
async function main() {
|
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");
|
||||||
|
|
||||||
|
// Start background workers
|
||||||
|
startMailWorker();
|
||||||
|
startGamificationWorker();
|
||||||
|
logger.info("Queue workers started");
|
||||||
|
|
||||||
const fastify = Fastify({ loggerInstance: logger });
|
const fastify = Fastify({ loggerInstance: logger });
|
||||||
|
|
||||||
await fastify.register(fastifyCookie, {
|
await fastify.register(fastifyCookie, {
|
||||||
@@ -120,6 +135,54 @@ async function main() {
|
|||||||
return reply.sendFile(path.join(id, filename));
|
return reply.sendFile(path.join(id, filename));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
fastify.get("/health", async (_request, reply) => {
|
||||||
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
|
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export async function setSession(token: string, user: SessionUser): Promise<void
|
|||||||
export async function getSession(token: string): Promise<SessionUser | null> {
|
export async function getSession(token: string): Promise<SessionUser | null> {
|
||||||
const data = await redis.get(`session:${token}`);
|
const data = await redis.get(`session:${token}`);
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
// Slide the expiration window on every access
|
||||||
|
await redis.expire(`session:${token}`, 86400);
|
||||||
return JSON.parse(data) as SessionUser;
|
return JSON.parse(data) as SessionUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
import { mailQueue } from "../queues/index.js";
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST || "localhost",
|
host: process.env.SMTP_HOST || "localhost",
|
||||||
@@ -32,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>`,
|
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,4 +1,4 @@
|
|||||||
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
|
import { eq, sql, and, gt, isNull, isNotNull, count, sum } from "drizzle-orm";
|
||||||
import type { DB } from "../db/connection";
|
import type { DB } from "../db/connection";
|
||||||
import {
|
import {
|
||||||
user_points,
|
user_points,
|
||||||
@@ -28,26 +28,62 @@ export async function awardPoints(
|
|||||||
recordingId?: string,
|
recordingId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const points = POINT_VALUES[action];
|
const points = POINT_VALUES[action];
|
||||||
await db.insert(user_points).values({
|
await db
|
||||||
user_id: userId,
|
.insert(user_points)
|
||||||
action,
|
.values({
|
||||||
points,
|
user_id: userId,
|
||||||
recording_id: recordingId || null,
|
action,
|
||||||
date_created: new Date(),
|
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);
|
await updateUserStats(db, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
|
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
|
||||||
const now = new Date();
|
|
||||||
const result = await db.execute(sql`
|
const result = await db.execute(sql`
|
||||||
SELECT SUM(
|
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
|
) as weighted_score
|
||||||
FROM user_points
|
FROM user_points
|
||||||
WHERE user_id = ${userId}
|
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> {
|
export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||||
@@ -84,7 +120,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
|||||||
sql`, `,
|
sql`, `,
|
||||||
)})
|
)})
|
||||||
`);
|
`);
|
||||||
playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0");
|
playbacksCount = parseInt((playbacksResult.rows[0] as { count?: string })?.count || "0");
|
||||||
} else {
|
} else {
|
||||||
const playbacksResult = await db
|
const playbacksResult = await db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
@@ -96,7 +132,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
|||||||
const commentsResult = await db
|
const commentsResult = await db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(comments)
|
.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 commentsCount = commentsResult[0]?.count || 0;
|
||||||
|
|
||||||
const achievementsResult = await db
|
const achievementsResult = await db
|
||||||
@@ -175,7 +211,9 @@ export async function checkAchievements(db: DB, userId: string, category?: strin
|
|||||||
.update(user_achievements)
|
.update(user_achievements)
|
||||||
.set({
|
.set({
|
||||||
progress,
|
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(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -242,7 +280,7 @@ async function getAchievementProgress(
|
|||||||
WHERE rp.user_id = ${userId}
|
WHERE rp.user_id = ${userId}
|
||||||
AND r.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)) {
|
if (["completionist_10", "completionist_100"].includes(code)) {
|
||||||
@@ -257,7 +295,7 @@ async function getAchievementProgress(
|
|||||||
const result = await db
|
const result = await db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(comments)
|
.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;
|
return result[0]?.count || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +331,7 @@ async function getAchievementProgress(
|
|||||||
WHERE rp.user_id = ${userId} AND r.user_id != ${userId}
|
WHERE rp.user_id = ${userId} AND r.user_id != ${userId}
|
||||||
`);
|
`);
|
||||||
const rc = recordingsResult[0]?.count || 0;
|
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;
|
return rc >= 50 && pc >= 100 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
"when": 1772645674514,
|
"when": 1772645674514,
|
||||||
"tag": "0001_is_admin",
|
"tag": "0001_is_admin",
|
||||||
"breakpoints": true
|
"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;
|
||||||
|
}
|
||||||
5
packages/buttplug/.gitignore
vendored
Normal file
5
packages/buttplug/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
wasm/
|
||||||
|
target/
|
||||||
|
pkg/
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "@sexy.pivoine.art/buttplug",
|
"name": "@sexy.pivoine.art/buttplug",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -10,7 +11,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
|
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target web --release",
|
||||||
|
"serve": "node serve.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eventemitter3": "^5.0.4",
|
"eventemitter3": "^5.0.4",
|
||||||
|
|||||||
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}`);
|
||||||
|
});
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import { IButtplugClientConnector } from "./IButtplugClientConnector";
|
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||||
import { ButtplugMessage } from "../core/Messages";
|
import { type ButtplugMessage } from "../core/Messages";
|
||||||
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
|
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
|
||||||
|
|
||||||
export class ButtplugBrowserWebsocketClientConnector
|
export class ButtplugBrowserWebsocketClientConnector
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { ButtplugLogger } from "../core/Logging";
|
import { ButtplugLogger } from "../core/Logging";
|
||||||
import { EventEmitter } from "eventemitter3";
|
import { EventEmitter } from "eventemitter3";
|
||||||
import { ButtplugClientDevice } from "./ButtplugClientDevice";
|
import { ButtplugClientDevice } from "./ButtplugClientDevice";
|
||||||
import { IButtplugClientConnector } from "./IButtplugClientConnector";
|
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||||
import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
|
import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
|
||||||
import * as Messages from "../core/Messages";
|
import * as Messages from "../core/Messages";
|
||||||
import { ButtplugError, ButtplugInitError, ButtplugMessageError } from "../core/Exceptions";
|
import { ButtplugError, ButtplugInitError, ButtplugMessageError } from "../core/Exceptions";
|
||||||
@@ -158,7 +158,7 @@ export class ButtplugClient extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private parseDeviceList = (list: Messages.DeviceList) => {
|
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)) {
|
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._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
|
||||||
@@ -168,8 +168,8 @@ export class ButtplugClient extends EventEmitter {
|
|||||||
this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
|
this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let [index, device] of this._devices.entries()) {
|
for (const [index, device] of this._devices.entries()) {
|
||||||
if (!list.Devices.hasOwnProperty(index.toString())) {
|
if (!Object.prototype.hasOwnProperty.call(list.Devices, index.toString())) {
|
||||||
this._devices.delete(index);
|
this._devices.delete(index);
|
||||||
this.emit("deviceremoved", device);
|
this.emit("deviceremoved", device);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import * as Messages from "../core/Messages";
|
|||||||
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
|
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
|
||||||
import { EventEmitter } from "eventemitter3";
|
import { EventEmitter } from "eventemitter3";
|
||||||
import { ButtplugClientDeviceFeature } from "./ButtplugClientDeviceFeature";
|
import { ButtplugClientDeviceFeature } from "./ButtplugClientDeviceFeature";
|
||||||
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an abstract device, capable of taking certain kinds of messages.
|
* Represents an abstract device, capable of taking certain kinds of messages.
|
||||||
@@ -105,14 +105,22 @@ export class ButtplugClientDevice extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
|
protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
|
||||||
if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) {
|
if (
|
||||||
|
!Object.prototype.hasOwnProperty.call(
|
||||||
|
this._deviceInfo.DeviceFeatures,
|
||||||
|
featureIndex.toString(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw new ButtplugDeviceError(
|
throw new ButtplugDeviceError(
|
||||||
`Feature index ${featureIndex} does not exist for device ${this.name}`,
|
`Feature index ${featureIndex} does not exist for device ${this.name}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined &&
|
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined &&
|
||||||
!this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs.hasOwnProperty(type)
|
!Object.prototype.hasOwnProperty.call(
|
||||||
|
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs,
|
||||||
|
type,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
throw new ButtplugDeviceError(
|
throw new ButtplugDeviceError(
|
||||||
`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`,
|
`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`,
|
||||||
@@ -139,8 +147,8 @@ export class ButtplugClientDevice extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
|
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
|
||||||
let p: Promise<void>[] = [];
|
const p: Promise<void>[] = [];
|
||||||
for (let f of this._features.values()) {
|
for (const f of this._features.values()) {
|
||||||
if (f.hasOutput(cmd.outputType)) {
|
if (f.hasOutput(cmd.outputType)) {
|
||||||
p.push(f.runOutput(cmd));
|
p.push(f.runOutput(cmd));
|
||||||
}
|
}
|
||||||
@@ -164,11 +172,14 @@ export class ButtplugClientDevice extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async battery(): Promise<number> {
|
public async battery(): Promise<number> {
|
||||||
let p: Promise<void>[] = [];
|
const _p: Promise<void>[] = [];
|
||||||
for (let f of this._features.values()) {
|
for (const f of this._features.values()) {
|
||||||
if (f.hasInput(Messages.InputType.Battery)) {
|
if (f.hasInput(Messages.InputType.Battery)) {
|
||||||
// Right now, we only have one battery per device, so assume the first one we find is it.
|
// 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) {
|
if (response === undefined) {
|
||||||
throw new ButtplugMessageError("Got incorrect message back.");
|
throw new ButtplugMessageError("Got incorrect message back.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class PercentOrSteps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static createSteps(s: number): PercentOrSteps {
|
public static createSteps(s: number): PercentOrSteps {
|
||||||
let v = new PercentOrSteps();
|
const v = new PercentOrSteps();
|
||||||
v._steps = s;
|
v._steps = s;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ class PercentOrSteps {
|
|||||||
throw new ButtplugDeviceError(`Percent value ${p} is not in the range 0.0 <= x <= 1.0`);
|
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;
|
v._percent = p;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
|
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
|
||||||
import * as Messages from "../core/Messages";
|
import * as Messages from "../core/Messages";
|
||||||
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||||
|
|
||||||
export class ButtplugClientDeviceFeature {
|
export class ButtplugClientDeviceFeature {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,7 +26,10 @@ export class ButtplugClientDeviceFeature {
|
|||||||
};
|
};
|
||||||
|
|
||||||
protected isOutputValid(type: Messages.OutputType) {
|
protected isOutputValid(type: Messages.OutputType) {
|
||||||
if (this._feature.Output !== undefined && !this._feature.Output.hasOwnProperty(type)) {
|
if (
|
||||||
|
this._feature.Output !== undefined &&
|
||||||
|
!Object.prototype.hasOwnProperty.call(this._feature.Output, type)
|
||||||
|
) {
|
||||||
throw new ButtplugDeviceError(
|
throw new ButtplugDeviceError(
|
||||||
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
|
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
|
||||||
);
|
);
|
||||||
@@ -34,7 +37,10 @@ export class ButtplugClientDeviceFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected isInputValid(type: Messages.InputType) {
|
protected isInputValid(type: Messages.InputType) {
|
||||||
if (this._feature.Input !== undefined && !this._feature.Input.hasOwnProperty(type)) {
|
if (
|
||||||
|
this._feature.Input !== undefined &&
|
||||||
|
!Object.prototype.hasOwnProperty.call(this._feature.Input, type)
|
||||||
|
) {
|
||||||
throw new ButtplugDeviceError(
|
throw new ButtplugDeviceError(
|
||||||
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
|
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
|
||||||
);
|
);
|
||||||
@@ -48,7 +54,7 @@ export class ButtplugClientDeviceFeature {
|
|||||||
throw new ButtplugDeviceError(`${command.outputType} requires value defined`);
|
throw new ButtplugDeviceError(`${command.outputType} requires value defined`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let type = command.outputType;
|
const type = command.outputType;
|
||||||
let duration: undefined | number = undefined;
|
let duration: undefined | number = undefined;
|
||||||
if (type == Messages.OutputType.HwPositionWithDuration) {
|
if (type == Messages.OutputType.HwPositionWithDuration) {
|
||||||
if (command.duration === undefined) {
|
if (command.duration === undefined) {
|
||||||
@@ -57,18 +63,18 @@ export class ButtplugClientDeviceFeature {
|
|||||||
duration = command.duration;
|
duration = command.duration;
|
||||||
}
|
}
|
||||||
let value: number;
|
let value: number;
|
||||||
let p = command.value;
|
const p = command.value;
|
||||||
if (p.percent === undefined) {
|
if (p.percent === undefined) {
|
||||||
// TODO Check step limits here
|
// TODO Check step limits here
|
||||||
value = command.value.steps!;
|
value = command.value.steps!;
|
||||||
} else {
|
} else {
|
||||||
value = Math.ceil(this._feature.Output[type]!.Value![1] * p.percent);
|
value = Math.ceil(this._feature.Output[type]!.Value![1] * p.percent);
|
||||||
}
|
}
|
||||||
let newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
|
const newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
|
||||||
let outCommand = {};
|
const outCommand = {};
|
||||||
outCommand[type.toString()] = newCommand;
|
outCommand[type.toString()] = newCommand;
|
||||||
|
|
||||||
let cmd: Messages.ButtplugMessage = {
|
const cmd: Messages.ButtplugMessage = {
|
||||||
OutputCmd: {
|
OutputCmd: {
|
||||||
Id: 1,
|
Id: 1,
|
||||||
DeviceIndex: this._deviceIndex,
|
DeviceIndex: this._deviceIndex,
|
||||||
@@ -111,14 +117,14 @@ export class ButtplugClientDeviceFeature {
|
|||||||
|
|
||||||
public hasOutput(type: Messages.OutputType): boolean {
|
public hasOutput(type: Messages.OutputType): boolean {
|
||||||
if (this._feature.Output !== undefined) {
|
if (this._feature.Output !== undefined) {
|
||||||
return this._feature.Output.hasOwnProperty(type.toString());
|
return Object.prototype.hasOwnProperty.call(this._feature.Output, type.toString());
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasInput(type: Messages.InputType): boolean {
|
public hasInput(type: Messages.InputType): boolean {
|
||||||
if (this._feature.Input !== undefined) {
|
if (this._feature.Input !== undefined) {
|
||||||
return this._feature.Input.hasOwnProperty(type.toString());
|
return Object.prototype.hasOwnProperty.call(this._feature.Input, type.toString());
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -126,7 +132,7 @@ export class ButtplugClientDeviceFeature {
|
|||||||
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
|
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
|
||||||
if (
|
if (
|
||||||
this._feature.Output !== undefined &&
|
this._feature.Output !== undefined &&
|
||||||
this._feature.Output.hasOwnProperty(cmd.outputType.toString())
|
Object.prototype.hasOwnProperty.call(this._feature.Output, cmd.outputType.toString())
|
||||||
) {
|
) {
|
||||||
return this.sendOutputCmd(cmd);
|
return this.sendOutputCmd(cmd);
|
||||||
}
|
}
|
||||||
@@ -139,7 +145,7 @@ export class ButtplugClientDeviceFeature {
|
|||||||
): Promise<Messages.InputReading | undefined> {
|
): Promise<Messages.InputReading | undefined> {
|
||||||
// Make sure the requested feature is valid
|
// Make sure the requested feature is valid
|
||||||
this.isInputValid(inputType);
|
this.isInputValid(inputType);
|
||||||
let inputAttributes = this._feature.Input[inputType];
|
const inputAttributes = this._feature.Input[inputType];
|
||||||
console.log(this._feature.Input);
|
console.log(this._feature.Input);
|
||||||
if (
|
if (
|
||||||
inputCommand === Messages.InputCommandType.Unsubscribe &&
|
inputCommand === Messages.InputCommandType.Unsubscribe &&
|
||||||
@@ -149,7 +155,7 @@ export class ButtplugClientDeviceFeature {
|
|||||||
throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`);
|
throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cmd: Messages.ButtplugMessage = {
|
const cmd: Messages.ButtplugMessage = {
|
||||||
InputCmd: {
|
InputCmd: {
|
||||||
Id: 1,
|
Id: 1,
|
||||||
DeviceIndex: this._deviceIndex,
|
DeviceIndex: this._deviceIndex,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ButtplugMessage } from "../core/Messages";
|
import { type ButtplugMessage } from "../core/Messages";
|
||||||
import { EventEmitter } from "eventemitter3";
|
import { type EventEmitter } from "eventemitter3";
|
||||||
|
|
||||||
export interface IButtplugClientConnector extends EventEmitter {
|
export interface IButtplugClientConnector extends EventEmitter {
|
||||||
connect: () => Promise<void>;
|
connect: () => Promise<void>;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Messages from "./Messages";
|
import * as Messages from "./Messages";
|
||||||
import { ButtplugLogger } from "./Logging";
|
import { type ButtplugLogger } from "./Logging";
|
||||||
|
|
||||||
export class ButtplugError extends Error {
|
export class ButtplugError extends Error {
|
||||||
public get ErrorClass(): Messages.ErrorClass {
|
public get ErrorClass(): Messages.ErrorClass {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export interface ButtplugMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function msgId(msg: ButtplugMessage): number {
|
export function msgId(msg: ButtplugMessage): number {
|
||||||
for (let [_, entry] of Object.entries(msg)) {
|
for (const [_, entry] of Object.entries(msg)) {
|
||||||
if (entry != undefined) {
|
if (entry != undefined) {
|
||||||
return entry.Id;
|
return entry.Id;
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ export function msgId(msg: ButtplugMessage): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setMsgId(msg: ButtplugMessage, id: 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) {
|
if (entry != undefined) {
|
||||||
entry.Id = id;
|
entry.Id = id;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ButtplugMessage } from "./core/Messages";
|
import { type ButtplugMessage } from "./core/Messages";
|
||||||
import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
|
import { type IButtplugClientConnector } from "./client/IButtplugClientConnector";
|
||||||
import { EventEmitter } from "eventemitter3";
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
|
||||||
export * from "./client/ButtplugClient";
|
export * from "./client/ButtplugClient";
|
||||||
@@ -40,7 +40,9 @@ export class ButtplugWasmClientConnector extends EventEmitter implements IButtpl
|
|||||||
|
|
||||||
private static maybeLoadWasm = async () => {
|
private static maybeLoadWasm = async () => {
|
||||||
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type FFICallback = js_sys::Function;
|
|||||||
type FFICallbackContext = u32;
|
type FFICallbackContext = u32;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct FFICallbackContextWrapper(FFICallbackContext);
|
pub struct FFICallbackContextWrapper(FFICallbackContext);
|
||||||
|
|
||||||
unsafe impl Send for FFICallbackContextWrapper {
|
unsafe impl Send for FFICallbackContextWrapper {
|
||||||
@@ -50,7 +51,7 @@ pub fn send_server_message(
|
|||||||
let buf = json.as_bytes();
|
let buf = json.as_bytes();
|
||||||
let this = JsValue::null();
|
let this = JsValue::null();
|
||||||
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
|
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 buf = json.as_bytes();
|
||||||
let this = JsValue::null();
|
let this = JsValue::null();
|
||||||
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
|
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
|
||||||
callback.call1(&this, &JsValue::from(uint8buf));
|
let _ = callback.call1(&this, &JsValue::from(uint8buf));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import { EventEmitter } from "eventemitter3";
|
import { EventEmitter } from "eventemitter3";
|
||||||
import { ButtplugMessage } from "../core/Messages";
|
import { type ButtplugMessage } from "../core/Messages";
|
||||||
|
|
||||||
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||||
protected _ws: WebSocket | undefined;
|
protected _ws: WebSocket | undefined;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class ButtplugMessageSorter {
|
|||||||
public ParseIncomingMessages(msgs: Messages.ButtplugMessage[]): Messages.ButtplugMessage[] {
|
public ParseIncomingMessages(msgs: Messages.ButtplugMessage[]): Messages.ButtplugMessage[] {
|
||||||
const noMatch: Messages.ButtplugMessage[] = [];
|
const noMatch: Messages.ButtplugMessage[] = [];
|
||||||
for (const x of msgs) {
|
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)) {
|
if (id !== Messages.SYSTEM_MESSAGE_ID && this._waitingMsgs.has(id)) {
|
||||||
const [res, rej] = this._waitingMsgs.get(id)!;
|
const [res, rej] = this._waitingMsgs.get(id)!;
|
||||||
this._waitingMsgs.delete(id);
|
this._waitingMsgs.delete(id);
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ impl HardwareSpecializer for WebBluetoothHardwareSpecializer {
|
|||||||
pub enum WebBluetoothEvent {
|
pub enum WebBluetoothEvent {
|
||||||
// This is the only way we have to get our endpoints back to device creation
|
// This is the only way we have to get our endpoints back to device creation
|
||||||
// right now. My god this is a mess.
|
// right now. My god this is a mess.
|
||||||
|
#[allow(dead_code)]
|
||||||
Connected(Vec<Endpoint>),
|
Connected(Vec<Endpoint>),
|
||||||
Disconnected,
|
Disconnected,
|
||||||
}
|
}
|
||||||
@@ -201,6 +202,7 @@ pub enum WebBluetoothDeviceCommand {
|
|||||||
HardwareSubscribeCmd,
|
HardwareSubscribeCmd,
|
||||||
oneshot::Sender<Result<(), ButtplugDeviceError>>,
|
oneshot::Sender<Result<(), ButtplugDeviceError>>,
|
||||||
),
|
),
|
||||||
|
#[allow(dead_code)]
|
||||||
Unsubscribe(
|
Unsubscribe(
|
||||||
HardwareUnsubscribeCmd,
|
HardwareUnsubscribeCmd,
|
||||||
oneshot::Sender<Result<(), ButtplugDeviceError>>,
|
oneshot::Sender<Result<(), ButtplugDeviceError>>,
|
||||||
@@ -271,7 +273,7 @@ async fn run_webbluetooth_loop(
|
|||||||
//let web_btle_device = WebBluetoothDeviceImpl::new(device, char_map);
|
//let web_btle_device = WebBluetoothDeviceImpl::new(device, char_map);
|
||||||
info!("device created!");
|
info!("device created!");
|
||||||
let endpoints = char_map.keys().into_iter().cloned().collect();
|
let endpoints = char_map.keys().into_iter().cloned().collect();
|
||||||
device_local_event_sender
|
let _ = device_local_event_sender
|
||||||
.send(WebBluetoothEvent::Connected(endpoints))
|
.send(WebBluetoothEvent::Connected(endpoints))
|
||||||
.await;
|
.await;
|
||||||
while let Some(msg) = device_command_receiver.recv().await {
|
while let Some(msg) = device_command_receiver.recv().await {
|
||||||
@@ -337,6 +339,7 @@ async fn run_webbluetooth_loop(
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct WebBluetoothHardware {
|
pub struct WebBluetoothHardware {
|
||||||
device_command_sender: mpsc::Sender<WebBluetoothDeviceCommand>,
|
device_command_sender: mpsc::Sender<WebBluetoothDeviceCommand>,
|
||||||
|
#[allow(dead_code)]
|
||||||
device_event_receiver: mpsc::Receiver<WebBluetoothEvent>,
|
device_event_receiver: mpsc::Receiver<WebBluetoothEvent>,
|
||||||
event_sender: broadcast::Sender<HardwareEvent>,
|
event_sender: broadcast::Sender<HardwareEvent>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@sexy.pivoine.art/frontend",
|
"name": "@sexy.pivoine.art/frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "valknarogg",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,9 +11,10 @@
|
|||||||
"check": "svelte-check --tsconfig ./tsconfig.json --threshold warning"
|
"check": "svelte-check --tsconfig ./tsconfig.json --threshold warning"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||||
"@iconify-json/ri": "^1.2.10",
|
"@iconify-json/ri": "^1.2.10",
|
||||||
"@iconify/tailwind4": "^1.2.1",
|
"@iconify/tailwind4": "^1.2.1",
|
||||||
"@internationalized/date": "^3.11.0",
|
"@internationalized/date": "^3.12.0",
|
||||||
"@lucide/svelte": "^0.561.0",
|
"@lucide/svelte": "^0.561.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"super-sitemap": "^1.0.7",
|
|
||||||
"svelte": "^5.53.7",
|
"svelte": "^5.53.7",
|
||||||
"svelte-check": "^4.4.4",
|
"svelte-check": "^4.4.4",
|
||||||
"svelte-sonner": "^1.0.8",
|
"svelte-sonner": "^1.0.8",
|
||||||
@@ -38,11 +37,9 @@
|
|||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1"
|
||||||
"vite-plugin-wasm": "3.5.0"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
|
||||||
"@sexy.pivoine.art/types": "workspace:*",
|
"@sexy.pivoine.art/types": "workspace:*",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"graphql-request": "^7.1.2",
|
"graphql-request": "^7.1.2",
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
|
|
||||||
@plugin "@iconify/tailwind4";
|
@plugin "@iconify/tailwind4";
|
||||||
|
|
||||||
|
@utility scrollbar-none {
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@custom-variant hover (&:hover);
|
@custom-variant hover (&:hover);
|
||||||
@@ -194,7 +201,7 @@
|
|||||||
--card-foreground: oklch(0.95 0.01 280);
|
--card-foreground: oklch(0.95 0.01 280);
|
||||||
--border: oklch(0.2 0.05 280);
|
--border: oklch(0.2 0.05 280);
|
||||||
--input: oklch(1 0 0 / 0.15);
|
--input: oklch(1 0 0 / 0.15);
|
||||||
--primary: oklch(0.65 0.25 320);
|
--primary: oklch(65.054% 0.25033 319.934);
|
||||||
--primary-foreground: oklch(0.98 0.01 320);
|
--primary-foreground: oklch(0.98 0.01 320);
|
||||||
--secondary: oklch(0.15 0.05 260);
|
--secondary: oklch(0.15 0.05 260);
|
||||||
--secondary-foreground: oklch(0.9 0.02 260);
|
--secondary-foreground: oklch(0.9 0.02 260);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<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"
|
href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -10,23 +10,23 @@
|
|||||||
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
||||||
>
|
>
|
||||||
<div
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
|
|
||||||
<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
|
<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
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
import { logout } from "$lib/services";
|
import { logout } from "$lib/services";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||||
|
import { getUserInitials } from "$lib/utils";
|
||||||
import Separator from "../ui/separator/separator.svelte";
|
import Separator from "../ui/separator/separator.svelte";
|
||||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||||
import Logo from "../logo/logo.svelte";
|
import Logo from "../logo/logo.svelte";
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
isMobileMenuOpen = false;
|
isMobileMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveLink(link: any) {
|
function isActiveLink(link: { name?: string; href: string }) {
|
||||||
return (
|
return (
|
||||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<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="container mx-auto px-4">
|
||||||
<div class="flex items-center justify-evenly h-16">
|
<div class="flex items-center justify-evenly h-16">
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
href="/"
|
href="/"
|
||||||
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<Logo hideName={true} />
|
<Logo />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
<!-- Desktop Navigation -->
|
||||||
@@ -75,28 +76,14 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Desktop Auth Actions -->
|
<!-- Auth Actions -->
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<div class="w-full hidden lg:flex items-center justify-end">
|
<div class="w-full flex items-center justify-end">
|
||||||
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "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="/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={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
|
||||||
href="/play"
|
href="/play"
|
||||||
title={$_("header.play")}
|
title={$_("header.play")}
|
||||||
>
|
>
|
||||||
@@ -111,7 +98,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
|
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"
|
href="/admin/users"
|
||||||
title="Admin"
|
title="Admin"
|
||||||
>
|
>
|
||||||
@@ -123,44 +110,70 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
|
<Separator orientation="vertical" class="hidden lg:block mx-1 h-6 bg-border/50" />
|
||||||
|
|
||||||
<LogoutButton
|
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
|
||||||
user={{
|
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
||||||
name:
|
<AvatarImage
|
||||||
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
||||||
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
|
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
||||||
email: authStatus.user!.email,
|
/>
|
||||||
}}
|
<AvatarFallback
|
||||||
onLogout={handleLogout}
|
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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="hidden lg:flex w-full items-center justify-end gap-4">
|
<div class="w-full flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
|
<div class="flex gap-4">
|
||||||
<Button
|
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button
|
||||||
href="/signup"
|
>
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
<Button
|
||||||
>{$_("header.signup")}</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Burger button — mobile/tablet only -->
|
|
||||||
<div class="lg:hidden ml-auto">
|
|
||||||
<BurgerMenuButton
|
|
||||||
label={$_("header.navigation")}
|
|
||||||
bind:isMobileMenuOpen
|
|
||||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
role="presentation"
|
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"}`}
|
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"}`}
|
||||||
@@ -174,21 +187,40 @@
|
|||||||
>
|
>
|
||||||
<!-- Panel header -->
|
<!-- Panel header -->
|
||||||
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
|
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
|
||||||
<Logo hideName={true} />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 py-6 px-5 space-y-6">
|
<div class="flex-1 py-6 px-5 space-y-6">
|
||||||
<!-- User logout slider -->
|
<!-- User card -->
|
||||||
{#if authStatus.authenticated}
|
{#if authStatus.authenticated}
|
||||||
<LogoutButton
|
<div class="flex items-center gap-3 rounded-xl border border-border/40 bg-card/50 px-4 py-3">
|
||||||
user={{
|
<Avatar class="h-10 w-10 ring-2 ring-primary/20 shrink-0">
|
||||||
name: authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
<AvatarImage
|
||||||
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
|
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
||||||
email: authStatus.user!.email,
|
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
||||||
}}
|
/>
|
||||||
onLogout={handleLogout}
|
<AvatarFallback
|
||||||
class="w-full"
|
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}
|
{/if}
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
@@ -320,6 +352,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -145,7 +145,12 @@
|
|||||||
{#if isViewerOpen}
|
{#if isViewerOpen}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-0 bg-black/95 backdrop-blur-xl cursor-default"
|
||||||
|
onclick={closeViewer}
|
||||||
|
aria-label="Close viewer"
|
||||||
|
></button>
|
||||||
|
|
||||||
<!-- Viewer Content -->
|
<!-- Viewer Content -->
|
||||||
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from "svelte-i18n";
|
|
||||||
import SexyIcon from "../icon/icon.svelte";
|
import SexyIcon from "../icon/icon.svelte";
|
||||||
|
|
||||||
const { hideName = false } = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative">
|
<SexyIcon class="w-12 h-12" />
|
||||||
<SexyIcon class="w-8 h-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
|
|
||||||
>
|
|
||||||
{$_("brand.name")}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.logo {
|
|
||||||
font-family: "Dancing Script", cursive;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, description, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="relative py-12 md:py-20 overflow-hidden">
|
||||||
|
<div class="relative container mx-auto px-4 text-center">
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<h1
|
||||||
|
class="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{#if description}
|
||||||
|
<p
|
||||||
|
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { currentPage, totalPages, onPageChange }: Props = $props();
|
||||||
|
|
||||||
|
const pageNumbers = $derived(() => {
|
||||||
|
const pages: (number | -1)[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (currentPage > 3) pages.push(-1);
|
||||||
|
for (
|
||||||
|
let i = Math.max(2, currentPage - 1);
|
||||||
|
i <= Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
i++
|
||||||
|
)
|
||||||
|
pages.push(i);
|
||||||
|
if (currentPage < totalPages - 2) pages.push(-1);
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onclick={() => onPageChange(currentPage - 1)}
|
||||||
|
>
|
||||||
|
{$_("common.previous")}
|
||||||
|
</Button>
|
||||||
|
{#each pageNumbers() as p, i (i)}
|
||||||
|
{#if p === -1}
|
||||||
|
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant={p === currentPage ? "default" : "outline"}
|
||||||
|
class="min-w-9"
|
||||||
|
onclick={() => onPageChange(p)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onclick={() => onPageChange(currentPage + 1)}
|
||||||
|
>
|
||||||
|
{$_("common.next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -2,16 +2,18 @@
|
|||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import type { Recording, DeviceInfo } from "$lib/types";
|
import type { Recording, DeviceInfo } from "$lib/types";
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
recording: Recording;
|
recording: Recording;
|
||||||
onPlay?: (id: string) => void;
|
onPlay?: (id: string) => void;
|
||||||
|
onPublish?: (id: string) => void;
|
||||||
|
onUnpublish?: (id: string) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { recording, onPlay, onDelete }: Props = $props();
|
let { recording, onPlay, onPublish, onUnpublish, onDelete }: Props = $props();
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
@@ -19,19 +21,6 @@
|
|||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % 60;
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "published":
|
|
||||||
return "text-green-400 bg-green-400/20";
|
|
||||||
case "draft":
|
|
||||||
return "text-yellow-400 bg-yellow-400/20";
|
|
||||||
case "archived":
|
|
||||||
return "text-red-400 bg-red-400/20";
|
|
||||||
default:
|
|
||||||
return "text-gray-400 bg-gray-400/20";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@@ -44,9 +33,14 @@
|
|||||||
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||||
{recording.title}
|
{recording.title}
|
||||||
</h3>
|
</h3>
|
||||||
<span class={cn("text-xs px-2 py-0.5 rounded-full", getStatusColor(recording.status))}>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class={recording.status === "published"
|
||||||
|
? "text-green-600 border-green-500/40 bg-green-500/10"
|
||||||
|
: "text-yellow-600 border-yellow-500/40 bg-yellow-500/10"}
|
||||||
|
>
|
||||||
{$_(`recording_card.status_${recording.status}`)}
|
{$_(`recording_card.status_${recording.status}`)}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{#if recording.description}
|
{#if recording.description}
|
||||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||||
@@ -151,12 +145,35 @@
|
|||||||
{$_("recording_card.play")}
|
{$_("recording_card.play")}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if onPublish && recording.status === "draft"}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => onPublish?.(recording.id)}
|
||||||
|
class="cursor-pointer border-primary/20 hover:bg-primary/10 hover:text-primary"
|
||||||
|
title={$_("recording_card.publish")}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--send-plane-line] w-4 h-4"></span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if onUnpublish && recording.status === "published"}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => onUnpublish?.(recording.id)}
|
||||||
|
class="cursor-pointer border-muted-foreground/20 hover:bg-muted/50 hover:text-muted-foreground"
|
||||||
|
title={$_("recording_card.unpublish")}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--arrow-go-back-line] w-4 h-4"></span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
{#if onDelete}
|
{#if onDelete}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => onDelete?.(recording.id)}
|
onclick={() => onDelete?.(recording.id)}
|
||||||
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
title={$_("common.delete")}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,50 +1,49 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
export const badgeVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
secondary:
|
||||||
secondary:
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
destructive:
|
||||||
destructive:
|
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
},
|
||||||
},
|
},
|
||||||
},
|
defaultVariants: {
|
||||||
defaultVariants: {
|
variant: "default",
|
||||||
variant: "default",
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href,
|
href,
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
<svelte:element
|
||||||
this={href ? "a" : "span"}
|
this={href ? "a" : "span"}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
{href}
|
{href}
|
||||||
class={cn(badgeVariants({ variant }), className)}
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</svelte:element>
|
</svelte:element>
|
||||||
|
|||||||
@@ -1,76 +1,76 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from "svelte";
|
||||||
import type Calendar from "./calendar.svelte";
|
import type Calendar from "./calendar.svelte";
|
||||||
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||||
import CalendarYearSelect from "./calendar-year-select.svelte";
|
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||||
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
captionLayout,
|
captionLayout,
|
||||||
months,
|
months,
|
||||||
monthFormat,
|
monthFormat,
|
||||||
years,
|
years,
|
||||||
yearFormat,
|
yearFormat,
|
||||||
month,
|
month,
|
||||||
locale,
|
locale,
|
||||||
placeholder = $bindable(),
|
placeholder = $bindable(),
|
||||||
monthIndex = 0,
|
monthIndex = 0,
|
||||||
}: {
|
}: {
|
||||||
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||||
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||||
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||||
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||||
month: DateValue;
|
month: DateValue;
|
||||||
placeholder: DateValue | undefined;
|
placeholder: DateValue | undefined;
|
||||||
locale: string;
|
locale: string;
|
||||||
monthIndex: number;
|
monthIndex: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function formatYear(date: DateValue) {
|
function formatYear(date: DateValue) {
|
||||||
const dateObj = date.toDate(getLocalTimeZone());
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMonth(date: DateValue) {
|
function formatMonth(date: DateValue) {
|
||||||
const dateObj = date.toDate(getLocalTimeZone());
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet MonthSelect()}
|
{#snippet MonthSelect()}
|
||||||
<CalendarMonthSelect
|
<CalendarMonthSelect
|
||||||
{months}
|
{months}
|
||||||
{monthFormat}
|
{monthFormat}
|
||||||
value={month.month}
|
value={month.month}
|
||||||
onchange={(e) => {
|
onchange={(e) => {
|
||||||
if (!placeholder) return;
|
if (!placeholder) return;
|
||||||
const v = Number.parseInt(e.currentTarget.value);
|
const v = Number.parseInt(e.currentTarget.value);
|
||||||
const newPlaceholder = placeholder.set({ month: v });
|
const newPlaceholder = placeholder.set({ month: v });
|
||||||
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet YearSelect()}
|
{#snippet YearSelect()}
|
||||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if captionLayout === "dropdown"}
|
{#if captionLayout === "dropdown"}
|
||||||
{@render MonthSelect()}
|
{@render MonthSelect()}
|
||||||
{@render YearSelect()}
|
{@render YearSelect()}
|
||||||
{:else if captionLayout === "dropdown-months"}
|
{:else if captionLayout === "dropdown-months"}
|
||||||
{@render MonthSelect()}
|
{@render MonthSelect()}
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
{formatYear(placeholder)}
|
{formatYear(placeholder)}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if captionLayout === "dropdown-years"}
|
{:else if captionLayout === "dropdown-years"}
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
{formatMonth(placeholder)}
|
{formatMonth(placeholder)}
|
||||||
{/if}
|
{/if}
|
||||||
{@render YearSelect()}
|
{@render YearSelect()}
|
||||||
{:else}
|
{:else}
|
||||||
{formatMonth(month)} {formatYear(month)}
|
{formatMonth(month)} {formatYear(month)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.CellProps = $props();
|
}: CalendarPrimitive.CellProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Cell
|
<CalendarPrimitive.Cell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.DayProps = $props();
|
}: CalendarPrimitive.DayProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Day
|
<CalendarPrimitive.Day
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
||||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||||
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||||
// Outside months
|
// Outside months
|
||||||
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||||
// Disabled
|
// Disabled
|
||||||
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
// Unavailable
|
// Unavailable
|
||||||
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||||
// hover
|
// hover
|
||||||
"dark:hover:text-accent-foreground",
|
"dark:hover:text-accent-foreground",
|
||||||
// focus
|
// focus
|
||||||
"focus:border-ring focus:ring-ring/50 focus:relative",
|
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||||
// inner spans
|
// inner spans
|
||||||
"[&>span]:text-xs [&>span]:opacity-70",
|
"[&>span]:text-xs [&>span]:opacity-70",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.GridBodyProps = $props();
|
}: CalendarPrimitive.GridBodyProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.GridHeadProps = $props();
|
}: CalendarPrimitive.GridHeadProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.GridRowProps = $props();
|
}: CalendarPrimitive.GridRowProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.GridProps = $props();
|
}: CalendarPrimitive.GridProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Grid
|
<CalendarPrimitive.Grid
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.HeadCellProps = $props();
|
}: CalendarPrimitive.HeadCellProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.HeadCell
|
<CalendarPrimitive.HeadCell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.HeaderProps = $props();
|
}: CalendarPrimitive.HeaderProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Header
|
<CalendarPrimitive.Header
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.HeadingProps = $props();
|
}: CalendarPrimitive.HeadingProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.Heading
|
<CalendarPrimitive.Heading
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
value,
|
value,
|
||||||
onchange,
|
onchange,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarPrimitive.MonthSelect
|
<CalendarPrimitive.MonthSelect
|
||||||
bind:ref
|
bind:ref
|
||||||
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||||
<select {...props} {value} {onchange}>
|
<select {...props} {value} {onchange}>
|
||||||
{#each monthItems as monthItem (monthItem.value)}
|
{#each monthItems as monthItem (monthItem.value)}
|
||||||
<option
|
<option
|
||||||
value={monthItem.value}
|
value={monthItem.value}
|
||||||
selected={value !== undefined
|
selected={value !== undefined
|
||||||
? monthItem.value === value
|
? monthItem.value === value
|
||||||
: monthItem.value === selectedMonthItem.value}
|
: monthItem.value === selectedMonthItem.value}
|
||||||
>
|
>
|
||||||
{monthItem.label}
|
{monthItem.label}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||||
<ChevronDownIcon class="size-4" />
|
<ChevronDownIcon class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarPrimitive.MonthSelect>
|
</CalendarPrimitive.MonthSelect>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type WithElementRef, cn } from "$lib/utils.js";
|
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
{...restProps}
|
{...restProps}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.NextButtonProps & {
|
}: CalendarPrimitive.NextButtonProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Fallback()}
|
{#snippet Fallback()}
|
||||||
<ChevronRightIcon class="size-4" />
|
<ChevronRightIcon class="size-4" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<CalendarPrimitive.NextButton
|
<CalendarPrimitive.NextButton
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant }),
|
buttonVariants({ variant }),
|
||||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
children={children || Fallback}
|
children={children || Fallback}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.PrevButtonProps & {
|
}: CalendarPrimitive.PrevButtonProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Fallback()}
|
{#snippet Fallback()}
|
||||||
<ChevronLeftIcon class="size-4" />
|
<ChevronLeftIcon class="size-4" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<CalendarPrimitive.PrevButton
|
<CalendarPrimitive.PrevButton
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant }),
|
buttonVariants({ variant }),
|
||||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
children={children || Fallback}
|
children={children || Fallback}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
value,
|
value,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarPrimitive.YearSelect
|
<CalendarPrimitive.YearSelect
|
||||||
bind:ref
|
bind:ref
|
||||||
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
class="dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet child({ props, yearItems, selectedYearItem })}
|
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||||
<select {...props} {value}>
|
<select {...props} {value}>
|
||||||
{#each yearItems as yearItem (yearItem.value)}
|
{#each yearItems as yearItem (yearItem.value)}
|
||||||
<option
|
<option
|
||||||
value={yearItem.value}
|
value={yearItem.value}
|
||||||
selected={value !== undefined
|
selected={value !== undefined
|
||||||
? yearItem.value === value
|
? yearItem.value === value
|
||||||
: yearItem.value === selectedYearItem.value}
|
: yearItem.value === selectedYearItem.value}
|
||||||
>
|
>
|
||||||
{yearItem.label}
|
{yearItem.label}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||||
<ChevronDownIcon class="size-4" />
|
<ChevronDownIcon class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarPrimitive.YearSelect>
|
</CalendarPrimitive.YearSelect>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import * as Calendar from "./index.js";
|
import * as Calendar from "./index.js";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import type { ButtonVariant } from "../button/button.svelte";
|
import type { ButtonVariant } from "../button/button.svelte";
|
||||||
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
placeholder = $bindable(),
|
placeholder = $bindable(),
|
||||||
class: className,
|
class: className,
|
||||||
weekdayFormat = "short",
|
weekdayFormat = "short",
|
||||||
buttonVariant = "ghost",
|
buttonVariant = "ghost",
|
||||||
captionLayout = "label",
|
captionLayout = "label",
|
||||||
locale = "en-US",
|
locale = "en-US",
|
||||||
months: monthsProp,
|
months: monthsProp,
|
||||||
years,
|
years,
|
||||||
monthFormat: monthFormatProp,
|
monthFormat: monthFormatProp,
|
||||||
yearFormat = "numeric",
|
yearFormat = "numeric",
|
||||||
day,
|
day,
|
||||||
disableDaysOutsideMonth = false,
|
disableDaysOutsideMonth = false,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||||
buttonVariant?: ButtonVariant;
|
buttonVariant?: ButtonVariant;
|
||||||
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||||
months?: CalendarPrimitive.MonthSelectProps["months"];
|
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||||
years?: CalendarPrimitive.YearSelectProps["years"];
|
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||||
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||||
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const monthFormat = $derived.by(() => {
|
const monthFormat = $derived.by(() => {
|
||||||
if (monthFormatProp) return monthFormatProp;
|
if (monthFormatProp) return monthFormatProp;
|
||||||
if (captionLayout.startsWith("dropdown")) return "short";
|
if (captionLayout.startsWith("dropdown")) return "short";
|
||||||
return "long";
|
return "long";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -44,72 +44,72 @@ Discriminated Unions + Destructing (required for bindable) do not
|
|||||||
get along, so we shut typescript up by casting `value` to `never`.
|
get along, so we shut typescript up by casting `value` to `never`.
|
||||||
-->
|
-->
|
||||||
<CalendarPrimitive.Root
|
<CalendarPrimitive.Root
|
||||||
bind:value={value as never}
|
bind:value={value as never}
|
||||||
bind:ref
|
bind:ref
|
||||||
bind:placeholder
|
bind:placeholder
|
||||||
{weekdayFormat}
|
{weekdayFormat}
|
||||||
{disableDaysOutsideMonth}
|
{disableDaysOutsideMonth}
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{locale}
|
{locale}
|
||||||
{monthFormat}
|
{monthFormat}
|
||||||
{yearFormat}
|
{yearFormat}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet children({ months, weekdays })}
|
{#snippet children({ months, weekdays })}
|
||||||
<Calendar.Months>
|
<Calendar.Months>
|
||||||
<Calendar.Nav>
|
<Calendar.Nav>
|
||||||
<Calendar.PrevButton variant={buttonVariant} />
|
<Calendar.PrevButton variant={buttonVariant} />
|
||||||
<Calendar.NextButton variant={buttonVariant} />
|
<Calendar.NextButton variant={buttonVariant} />
|
||||||
</Calendar.Nav>
|
</Calendar.Nav>
|
||||||
{#each months as month, monthIndex (month)}
|
{#each months as month, monthIndex (month)}
|
||||||
<Calendar.Month>
|
<Calendar.Month>
|
||||||
<Calendar.Header>
|
<Calendar.Header>
|
||||||
<Calendar.Caption
|
<Calendar.Caption
|
||||||
{captionLayout}
|
{captionLayout}
|
||||||
months={monthsProp}
|
months={monthsProp}
|
||||||
{monthFormat}
|
{monthFormat}
|
||||||
{years}
|
{years}
|
||||||
{yearFormat}
|
{yearFormat}
|
||||||
month={month.value}
|
month={month.value}
|
||||||
bind:placeholder
|
bind:placeholder
|
||||||
{locale}
|
{locale}
|
||||||
{monthIndex}
|
{monthIndex}
|
||||||
/>
|
/>
|
||||||
</Calendar.Header>
|
</Calendar.Header>
|
||||||
<Calendar.Grid>
|
<Calendar.Grid>
|
||||||
<Calendar.GridHead>
|
<Calendar.GridHead>
|
||||||
<Calendar.GridRow class="select-none">
|
<Calendar.GridRow class="select-none">
|
||||||
{#each weekdays as weekday (weekday)}
|
{#each weekdays as weekday (weekday)}
|
||||||
<Calendar.HeadCell>
|
<Calendar.HeadCell>
|
||||||
{weekday.slice(0, 2)}
|
{weekday.slice(0, 2)}
|
||||||
</Calendar.HeadCell>
|
</Calendar.HeadCell>
|
||||||
{/each}
|
{/each}
|
||||||
</Calendar.GridRow>
|
</Calendar.GridRow>
|
||||||
</Calendar.GridHead>
|
</Calendar.GridHead>
|
||||||
<Calendar.GridBody>
|
<Calendar.GridBody>
|
||||||
{#each month.weeks as weekDates (weekDates)}
|
{#each month.weeks as weekDates (weekDates)}
|
||||||
<Calendar.GridRow class="mt-2 w-full">
|
<Calendar.GridRow class="mt-2 w-full">
|
||||||
{#each weekDates as date (date)}
|
{#each weekDates as date (date)}
|
||||||
<Calendar.Cell {date} month={month.value}>
|
<Calendar.Cell {date} month={month.value}>
|
||||||
{#if day}
|
{#if day}
|
||||||
{@render day({
|
{@render day({
|
||||||
day: date,
|
day: date,
|
||||||
outsideMonth: !isEqualMonth(date, month.value),
|
outsideMonth: !isEqualMonth(date, month.value),
|
||||||
})}
|
})}
|
||||||
{:else}
|
{:else}
|
||||||
<Calendar.Day />
|
<Calendar.Day />
|
||||||
{/if}
|
{/if}
|
||||||
</Calendar.Cell>
|
</Calendar.Cell>
|
||||||
{/each}
|
{/each}
|
||||||
</Calendar.GridRow>
|
</Calendar.GridRow>
|
||||||
{/each}
|
{/each}
|
||||||
</Calendar.GridBody>
|
</Calendar.GridBody>
|
||||||
</Calendar.Grid>
|
</Calendar.Grid>
|
||||||
</Calendar.Month>
|
</Calendar.Month>
|
||||||
{/each}
|
{/each}
|
||||||
</Calendar.Months>
|
</Calendar.Months>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarPrimitive.Root>
|
</CalendarPrimitive.Root>
|
||||||
|
|||||||
@@ -18,23 +18,23 @@ import Nav from "./calendar-nav.svelte";
|
|||||||
import Caption from "./calendar-caption.svelte";
|
import Caption from "./calendar-caption.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Day,
|
Day,
|
||||||
Cell,
|
Cell,
|
||||||
Grid,
|
Grid,
|
||||||
Header,
|
Header,
|
||||||
Months,
|
Months,
|
||||||
GridRow,
|
GridRow,
|
||||||
Heading,
|
Heading,
|
||||||
GridBody,
|
GridBody,
|
||||||
GridHead,
|
GridHead,
|
||||||
HeadCell,
|
HeadCell,
|
||||||
NextButton,
|
NextButton,
|
||||||
PrevButton,
|
PrevButton,
|
||||||
Nav,
|
Nav,
|
||||||
Month,
|
Month,
|
||||||
YearSelect,
|
YearSelect,
|
||||||
MonthSelect,
|
MonthSelect,
|
||||||
Caption,
|
Caption,
|
||||||
//
|
//
|
||||||
Root as Calendar,
|
Root as Calendar,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="empty-content"
|
||||||
|
class={cn(
|
||||||
|
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="empty-description"
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="empty-header"
|
||||||
|
class={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const emptyMediaVariants = tv({
|
||||||
|
base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
class={cn(emptyMediaVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="empty-title"
|
||||||
|
class={cn("text-lg font-medium tracking-tight", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
23
packages/frontend/src/lib/components/ui/empty/empty.svelte
Normal file
23
packages/frontend/src/lib/components/ui/empty/empty.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="empty"
|
||||||
|
class={cn(
|
||||||
|
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
22
packages/frontend/src/lib/components/ui/empty/index.ts
Normal file
22
packages/frontend/src/lib/components/ui/empty/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Root from "./empty.svelte";
|
||||||
|
import Header from "./empty-header.svelte";
|
||||||
|
import Media from "./empty-media.svelte";
|
||||||
|
import Title from "./empty-title.svelte";
|
||||||
|
import Description from "./empty-description.svelte";
|
||||||
|
import Content from "./empty-content.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Header,
|
||||||
|
Media,
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
Content,
|
||||||
|
//
|
||||||
|
Root as Empty,
|
||||||
|
Header as EmptyHeader,
|
||||||
|
Media as EmptyMedia,
|
||||||
|
Title as EmptyTitle,
|
||||||
|
Description as EmptyDescription,
|
||||||
|
Content as EmptyContent,
|
||||||
|
};
|
||||||
@@ -23,11 +23,13 @@
|
|||||||
...rest
|
...rest
|
||||||
}: FileDropZoneProps = $props();
|
}: FileDropZoneProps = $props();
|
||||||
|
|
||||||
if (maxFiles !== undefined && fileCount === undefined) {
|
$effect(() => {
|
||||||
console.warn(
|
if (maxFiles !== undefined && fileCount === undefined) {
|
||||||
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
console.warn(
|
||||||
);
|
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import Trigger from "./popover-trigger.svelte";
|
|||||||
import Portal from "./popover-portal.svelte";
|
import Portal from "./popover-portal.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Content,
|
Content,
|
||||||
Trigger,
|
Trigger,
|
||||||
Close,
|
Close,
|
||||||
Portal,
|
Portal,
|
||||||
//
|
//
|
||||||
Root as Popover,
|
Root as Popover,
|
||||||
Content as PopoverContent,
|
Content as PopoverContent,
|
||||||
Trigger as PopoverTrigger,
|
Trigger as PopoverTrigger,
|
||||||
Close as PopoverClose,
|
Close as PopoverClose,
|
||||||
Portal as PopoverPortal,
|
Portal as PopoverPortal,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
import PopoverPortal from "./popover-portal.svelte";
|
import PopoverPortal from "./popover-portal.svelte";
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = "center",
|
align = "center",
|
||||||
portalProps,
|
portalProps,
|
||||||
...restProps
|
...restProps
|
||||||
}: PopoverPrimitive.ContentProps & {
|
}: PopoverPrimitive.ContentProps & {
|
||||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPortal {...portalProps}>
|
<PopoverPortal {...portalProps}>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
{align}
|
{align}
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
</PopoverPortal>
|
</PopoverPortal>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPrimitive.Portal {...restProps} />
|
<PopoverPrimitive.Portal {...restProps} />
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: PopoverPrimitive.TriggerProps = $props();
|
}: PopoverPrimitive.TriggerProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPrimitive.Trigger
|
<PopoverPrimitive.Trigger
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="popover-trigger"
|
data-slot="popover-trigger"
|
||||||
class={cn("", className)}
|
class={cn("", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverPrimitive.Root bind:open {...restProps} />
|
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default {
|
|||||||
my_profile: "My Profile",
|
my_profile: "My Profile",
|
||||||
anonymous: "Anonymous",
|
anonymous: "Anonymous",
|
||||||
load_more: "Load More",
|
load_more: "Load More",
|
||||||
|
page_of: "Page {page} of {total}",
|
||||||
|
total_results: "{total} results",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
home: "Home",
|
home: "Home",
|
||||||
@@ -48,7 +50,7 @@ export default {
|
|||||||
account: "Account",
|
account: "Account",
|
||||||
},
|
},
|
||||||
brand: {
|
brand: {
|
||||||
name: "SexyArt",
|
name: "Sexy",
|
||||||
tagline: "Where Love Meets Artistry",
|
tagline: "Where Love Meets Artistry",
|
||||||
description:
|
description:
|
||||||
"The premier destination for artistic adult content, intimate storytelling, and creative expression through video and magazine content.",
|
"The premier destination for artistic adult content, intimate storytelling, and creative expression through video and magazine content.",
|
||||||
@@ -89,6 +91,23 @@ export default {
|
|||||||
me: {
|
me: {
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
welcome: "Welcome back, {name}",
|
welcome: "Welcome back, {name}",
|
||||||
|
nav: {
|
||||||
|
profile: "Profile",
|
||||||
|
security: "Security",
|
||||||
|
recordings: "Recordings",
|
||||||
|
analytics: "Analytics",
|
||||||
|
back_to_site: "Back to site",
|
||||||
|
back_mobile: "Back",
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
title: "Analytics",
|
||||||
|
description: "Track your content performance and audience engagement",
|
||||||
|
total_videos: "Total Videos",
|
||||||
|
total_likes: "Total Likes",
|
||||||
|
total_plays: "Total Plays",
|
||||||
|
video_performance: "Video Performance",
|
||||||
|
video_performance_description: "Detailed metrics for each video",
|
||||||
|
},
|
||||||
view_profile: "View Public Profile",
|
view_profile: "View Public Profile",
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
@@ -132,6 +151,10 @@ export default {
|
|||||||
delete_confirm: "Are you sure you want to delete this recording?",
|
delete_confirm: "Are you sure you want to delete this recording?",
|
||||||
delete_success: "Recording deleted successfully",
|
delete_success: "Recording deleted successfully",
|
||||||
delete_error: "Failed to delete recording",
|
delete_error: "Failed to delete recording",
|
||||||
|
publish_success: "Recording published successfully",
|
||||||
|
publish_error: "Failed to publish recording",
|
||||||
|
unpublish_success: "Recording unpublished",
|
||||||
|
unpublish_error: "Failed to unpublish recording",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording_card: {
|
recording_card: {
|
||||||
@@ -141,8 +164,9 @@ export default {
|
|||||||
created: "Created",
|
created: "Created",
|
||||||
status_draft: "Draft",
|
status_draft: "Draft",
|
||||||
status_published: "Published",
|
status_published: "Published",
|
||||||
status_archived: "Archived",
|
|
||||||
play: "Play",
|
play: "Play",
|
||||||
|
publish: "Publish",
|
||||||
|
unpublish: "Unpublish",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
public: "Public",
|
public: "Public",
|
||||||
@@ -251,6 +275,7 @@ export default {
|
|||||||
rating: "Highest Rated",
|
rating: "Highest Rated",
|
||||||
videos: "Most Videos",
|
videos: "Most Videos",
|
||||||
name: "A-Z",
|
name: "A-Z",
|
||||||
|
recent: "Newest",
|
||||||
},
|
},
|
||||||
online: "Online",
|
online: "Online",
|
||||||
followers: "followers",
|
followers: "followers",
|
||||||
@@ -300,13 +325,15 @@ export default {
|
|||||||
show: "Show",
|
show: "Show",
|
||||||
add_comment_placeholder: "Add a comment...",
|
add_comment_placeholder: "Add a comment...",
|
||||||
toast_comment: "Your comment has been sent",
|
toast_comment: "Your comment has been sent",
|
||||||
|
comment_deleted: "Comment deleted",
|
||||||
|
comment_delete_error: "Failed to delete comment",
|
||||||
comment: "Comment",
|
comment: "Comment",
|
||||||
commenting: "Commenting...",
|
commenting: "Commenting...",
|
||||||
error: "Heads Up!",
|
error: "Heads Up!",
|
||||||
back: "Back to Videos",
|
back: "Back to Videos",
|
||||||
},
|
},
|
||||||
magazine: {
|
magazine: {
|
||||||
title: "SexyArt Magazine",
|
title: "Sexy Magazine",
|
||||||
description:
|
description:
|
||||||
"Insights, stories, and inspiration from the world of love, art, and intimate expression",
|
"Insights, stories, and inspiration from the world of love, art, and intimate expression",
|
||||||
search_placeholder: "Search articles...",
|
search_placeholder: "Search articles...",
|
||||||
@@ -383,7 +410,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: "About SexyArt",
|
title: "About Sexy",
|
||||||
subtitle:
|
subtitle:
|
||||||
"Where passion meets artistry, and intimate storytelling becomes a celebration of human connection.",
|
"Where passion meets artistry, and intimate storytelling becomes a celebration of human connection.",
|
||||||
join_community: "Join Our Community",
|
join_community: "Join Our Community",
|
||||||
@@ -399,11 +426,11 @@ export default {
|
|||||||
subtitle:
|
subtitle:
|
||||||
"Born from a vision to transform how intimate content is created, shared, and appreciated",
|
"Born from a vision to transform how intimate content is created, shared, and appreciated",
|
||||||
description_part1:
|
description_part1:
|
||||||
"SexyArt was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
|
"Sexy was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
|
||||||
description_part2:
|
description_part2:
|
||||||
"We recognized that the adult content industry needed a platform that prioritized artistic expression, creator empowerment, and community building. Our founders, coming from backgrounds in photography, digital media, and community management, set out to build something different.",
|
"We recognized that the adult content industry needed a platform that prioritized artistic expression, creator empowerment, and community building. Our founders, coming from backgrounds in photography, digital media, and community management, set out to build something different.",
|
||||||
description_part3:
|
description_part3:
|
||||||
"Today, SexyArt is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
|
"Today, Sexy is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
title: "Our Values",
|
title: "Our Values",
|
||||||
@@ -443,7 +470,7 @@ export default {
|
|||||||
image: "/img/valknar.gif",
|
image: "/img/valknar.gif",
|
||||||
bio: "DJ and visual storyteller specializing in diffusion AI art.",
|
bio: "DJ and visual storyteller specializing in diffusion AI art.",
|
||||||
},
|
},
|
||||||
subtitle: "The passionate individuals behind SexyArt's success",
|
subtitle: "The passionate individuals behind Sexy's success",
|
||||||
},
|
},
|
||||||
mission: {
|
mission: {
|
||||||
title: "Our Mission",
|
title: "Our Mission",
|
||||||
@@ -470,7 +497,7 @@ export default {
|
|||||||
},
|
},
|
||||||
faq: {
|
faq: {
|
||||||
title: "Frequently Asked Questions",
|
title: "Frequently Asked Questions",
|
||||||
description: "Find answers to common questions about SexyArt, our platform, and services",
|
description: "Find answers to common questions about Sexy, our platform, and services",
|
||||||
search_placeholder: "Search frequently asked questions...",
|
search_placeholder: "Search frequently asked questions...",
|
||||||
search_results: "Search Results ({count})",
|
search_results: "Search Results ({count})",
|
||||||
no_results: "No questions found matching your search.",
|
no_results: "No questions found matching your search.",
|
||||||
@@ -479,24 +506,24 @@ export default {
|
|||||||
title: "Getting Started",
|
title: "Getting Started",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
question: "How do I create an account on SexyArt?",
|
question: "How do I create an account on Sexy?",
|
||||||
answer:
|
answer:
|
||||||
"Creating an account is simple! Click the 'Join Now' button in the top navigation, fill out the registration form with your email and basic information, verify you're 18+, and agree to our terms. You'll receive a confirmation email to activate your account.",
|
"Creating an account is simple! Click the 'Join Now' button in the top navigation, fill out the registration form with your email and basic information, verify you're 18+, and agree to our terms. You'll receive a confirmation email to activate your account.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "What types of content can I find on SexyArt?",
|
question: "What types of content can I find on Sexy?",
|
||||||
answer:
|
answer:
|
||||||
"SexyArt features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
|
"Sexy features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Is SexyArt safe and secure?",
|
question: "Is Sexy safe and secure?",
|
||||||
answer:
|
answer:
|
||||||
"Yes! We use industry-standard encryption, secure payment processing, and strict privacy measures. All creators are verified, and we have comprehensive content moderation. Your personal information and viewing habits are kept completely private.",
|
"Yes! We use industry-standard encryption, secure payment processing, and strict privacy measures. All creators are verified, and we have comprehensive content moderation. Your personal information and viewing habits are kept completely private.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can I access SexyArt on mobile devices?",
|
question: "Can I access Sexy on mobile devices?",
|
||||||
answer:
|
answer:
|
||||||
"Absolutely! SexyArt is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
|
"Absolutely! Sexy is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -504,7 +531,7 @@ export default {
|
|||||||
title: "For Creators & Models",
|
title: "For Creators & Models",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
question: "How do I become a creator on SexyArt?",
|
question: "How do I become a creator on Sexy?",
|
||||||
answer:
|
answer:
|
||||||
"To become a creator, sign up for a Creator account during registration or upgrade your existing account. You'll need to verify your identity, provide tax information, and agree to our creator terms. Once approved, you can start uploading content and building your audience.",
|
"To become a creator, sign up for a Creator account during registration or upgrade your existing account. You'll need to verify your identity, provide tax information, and agree to our creator terms. Once approved, you can start uploading content and building your audience.",
|
||||||
},
|
},
|
||||||
@@ -593,7 +620,7 @@ export default {
|
|||||||
company_information: "Company Information",
|
company_information: "Company Information",
|
||||||
company_name: {
|
company_name: {
|
||||||
title: "Company Name",
|
title: "Company Name",
|
||||||
value: "SexyArt",
|
value: "Sexy",
|
||||||
},
|
},
|
||||||
legal_form: {
|
legal_form: {
|
||||||
title: "Legal Form",
|
title: "Legal Form",
|
||||||
@@ -610,7 +637,7 @@ export default {
|
|||||||
contact_information: "Contact Information",
|
contact_information: "Contact Information",
|
||||||
registered_address: "Registered Address",
|
registered_address: "Registered Address",
|
||||||
address: {
|
address: {
|
||||||
company: "SexyArt",
|
company: "Sexy",
|
||||||
name: "Sebastian Krüger",
|
name: "Sebastian Krüger",
|
||||||
street: "Berlingerstraße 48",
|
street: "Berlingerstraße 48",
|
||||||
city: "78333 Stockach",
|
city: "78333 Stockach",
|
||||||
@@ -684,7 +711,7 @@ export default {
|
|||||||
acceptance: {
|
acceptance: {
|
||||||
title: "1. Acceptance of Terms",
|
title: "1. Acceptance of Terms",
|
||||||
text: [
|
text: [
|
||||||
"By accessing and using SexyArt, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
|
"By accessing and using Sexy, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
age: {
|
age: {
|
||||||
@@ -728,7 +755,7 @@ export default {
|
|||||||
values: {
|
values: {
|
||||||
title: "Our Community Values",
|
title: "Our Community Values",
|
||||||
text: [
|
text: [
|
||||||
"SexyArt is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
|
"Sexy is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
respect: {
|
respect: {
|
||||||
@@ -795,11 +822,19 @@ export default {
|
|||||||
questions_email: "support@pivoine.art",
|
questions_email: "support@pivoine.art",
|
||||||
},
|
},
|
||||||
play: {
|
play: {
|
||||||
title: "SexyPlay",
|
title: "Play",
|
||||||
description: "Bring your toys.",
|
description: "Connect and control your Bluetooth toys.",
|
||||||
scan: "Start Scan",
|
scan: "Start Scan",
|
||||||
scanning: "Scanning...",
|
scanning: "Scanning...",
|
||||||
no_results: "No Devices founds",
|
no_results: "No devices found",
|
||||||
|
no_results_description: "Start a scan to discover nearby Bluetooth devices",
|
||||||
|
nav: {
|
||||||
|
play: "Play",
|
||||||
|
recordings: "Recordings",
|
||||||
|
leaderboard: "Leaderboard",
|
||||||
|
back_to_site: "Back to site",
|
||||||
|
back_mobile: "Site",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
not_found: "Oops! Page Not Found",
|
not_found: "Oops! Page Not Found",
|
||||||
@@ -897,22 +932,26 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
head: {
|
head: {
|
||||||
title: "SexyArt | {title}",
|
title: "Sexy | {title}",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
nav: {
|
nav: {
|
||||||
back_to_site: "← Back to site",
|
back_to_site: "Back to site",
|
||||||
back_mobile: "← Back",
|
back_mobile: "Back",
|
||||||
title: "Admin",
|
title: "Admin",
|
||||||
users: "Users",
|
users: "Users",
|
||||||
videos: "Videos",
|
videos: "Videos",
|
||||||
articles: "Articles",
|
articles: "Articles",
|
||||||
|
comments: "Comments",
|
||||||
|
recordings: "Recordings",
|
||||||
|
queues: "Queues",
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
save_changes: "Save changes",
|
save_changes: "Save changes",
|
||||||
saving: "Saving…",
|
saving: "Saving…",
|
||||||
creating: "Creating…",
|
creating: "Creating…",
|
||||||
deleting: "Deleting…",
|
deleting: "Deleting…",
|
||||||
|
all: "All",
|
||||||
featured: "Featured",
|
featured: "Featured",
|
||||||
premium: "Premium",
|
premium: "Premium",
|
||||||
write: "Write",
|
write: "Write",
|
||||||
@@ -920,8 +959,8 @@ export default {
|
|||||||
cover_image: "Cover image",
|
cover_image: "Cover image",
|
||||||
tags: "Tags",
|
tags: "Tags",
|
||||||
publish_date: "Publish date",
|
publish_date: "Publish date",
|
||||||
title_field: "Title *",
|
title_field: "Title",
|
||||||
slug_field: "Slug *",
|
slug_field: "Slug",
|
||||||
title_slug_required: "Title and slug are required",
|
title_slug_required: "Title and slug are required",
|
||||||
image_uploaded: "Image uploaded",
|
image_uploaded: "Image uploaded",
|
||||||
image_upload_failed: "Image upload failed",
|
image_upload_failed: "Image upload failed",
|
||||||
@@ -944,7 +983,8 @@ export default {
|
|||||||
role_updated: "Role updated to {role}",
|
role_updated: "Role updated to {role}",
|
||||||
role_update_failed: "Failed to update role",
|
role_update_failed: "Failed to update role",
|
||||||
delete_title: "Delete user",
|
delete_title: "Delete user",
|
||||||
delete_description: "Are you sure you want to permanently delete {name}? This cannot be undone.",
|
delete_description:
|
||||||
|
"Are you sure you want to permanently delete {name}? This cannot be undone.",
|
||||||
delete_success: "User deleted",
|
delete_success: "User deleted",
|
||||||
delete_error: "Failed to delete user",
|
delete_error: "Failed to delete user",
|
||||||
},
|
},
|
||||||
@@ -954,6 +994,11 @@ export default {
|
|||||||
artist_name: "Artist name",
|
artist_name: "Artist name",
|
||||||
avatar: "Avatar",
|
avatar: "Avatar",
|
||||||
banner: "Banner",
|
banner: "Banner",
|
||||||
|
model_photo: "Model photo",
|
||||||
|
model_photo_hint:
|
||||||
|
"Used in model cards and on the model profile page. Avatar is used for comments and article authors.",
|
||||||
|
model_photo_uploaded: "Model photo uploaded",
|
||||||
|
model_photo_failed: "Model photo upload failed",
|
||||||
is_admin: "Administrator",
|
is_admin: "Administrator",
|
||||||
is_admin_hint: "Grants full admin access to the dashboard",
|
is_admin_hint: "Grants full admin access to the dashboard",
|
||||||
photos: "Photo gallery",
|
photos: "Photo gallery",
|
||||||
@@ -971,6 +1016,7 @@ export default {
|
|||||||
videos: {
|
videos: {
|
||||||
title: "Videos",
|
title: "Videos",
|
||||||
new_video: "New video",
|
new_video: "New video",
|
||||||
|
search_placeholder: "Search videos...",
|
||||||
col_video: "Video",
|
col_video: "Video",
|
||||||
col_badges: "Badges",
|
col_badges: "Badges",
|
||||||
col_plays: "Plays",
|
col_plays: "Plays",
|
||||||
@@ -1005,6 +1051,8 @@ export default {
|
|||||||
articles: {
|
articles: {
|
||||||
title: "Articles",
|
title: "Articles",
|
||||||
new_article: "New article",
|
new_article: "New article",
|
||||||
|
search_placeholder: "Search articles...",
|
||||||
|
filter_all_categories: "All categories",
|
||||||
col_article: "Article",
|
col_article: "Article",
|
||||||
col_category: "Category",
|
col_category: "Category",
|
||||||
col_published: "Published",
|
col_published: "Published",
|
||||||
@@ -1014,6 +1062,64 @@ export default {
|
|||||||
delete_success: "Article deleted",
|
delete_success: "Article deleted",
|
||||||
delete_error: "Failed to delete article",
|
delete_error: "Failed to delete article",
|
||||||
},
|
},
|
||||||
|
comments: {
|
||||||
|
title: "Comments",
|
||||||
|
search_placeholder: "Search comments…",
|
||||||
|
col_user: "User",
|
||||||
|
col_comment: "Comment",
|
||||||
|
col_on: "On",
|
||||||
|
col_date: "Date",
|
||||||
|
no_results: "No comments found",
|
||||||
|
delete_title: "Delete comment",
|
||||||
|
delete_success: "Comment deleted",
|
||||||
|
delete_error: "Failed to delete comment",
|
||||||
|
},
|
||||||
|
recordings: {
|
||||||
|
title: "Recordings",
|
||||||
|
search_placeholder: "Search recordings…",
|
||||||
|
col_title: "Title",
|
||||||
|
col_status: "Status",
|
||||||
|
col_duration: "Duration",
|
||||||
|
col_date: "Date",
|
||||||
|
no_results: "No recordings found",
|
||||||
|
published: "Published",
|
||||||
|
draft: "Draft",
|
||||||
|
public: "Public",
|
||||||
|
delete_title: "Delete recording",
|
||||||
|
delete_description: 'Permanently delete "{title}"? This cannot be undone.',
|
||||||
|
delete_success: "Recording deleted",
|
||||||
|
delete_error: "Failed to delete recording",
|
||||||
|
},
|
||||||
|
queues: {
|
||||||
|
title: "Job Queues",
|
||||||
|
pause: "Pause",
|
||||||
|
resume: "Resume",
|
||||||
|
paused_badge: "Paused",
|
||||||
|
retry: "Retry",
|
||||||
|
remove: "Remove",
|
||||||
|
retry_success: "Job retried",
|
||||||
|
retry_error: "Failed to retry job",
|
||||||
|
remove_success: "Job removed",
|
||||||
|
remove_error: "Failed to remove job",
|
||||||
|
pause_success: "Queue paused",
|
||||||
|
pause_error: "Failed to pause queue",
|
||||||
|
resume_success: "Queue resumed",
|
||||||
|
resume_error: "Failed to resume queue",
|
||||||
|
col_id: "ID",
|
||||||
|
col_name: "Name",
|
||||||
|
col_status: "Status",
|
||||||
|
col_attempts: "Attempts",
|
||||||
|
col_created: "Created",
|
||||||
|
col_actions: "Actions",
|
||||||
|
no_jobs: "No jobs found",
|
||||||
|
status_all: "All",
|
||||||
|
status_waiting: "Waiting",
|
||||||
|
status_active: "Active",
|
||||||
|
status_completed: "Completed",
|
||||||
|
status_failed: "Failed",
|
||||||
|
status_delayed: "Delayed",
|
||||||
|
failed_reason: "Reason: {reason}",
|
||||||
|
},
|
||||||
article_form: {
|
article_form: {
|
||||||
new_title: "New article",
|
new_title: "New article",
|
||||||
edit_title: "Edit article",
|
edit_title: "Edit article",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user