Compare commits
50 Commits
239128bf5e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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 \
|
||||||
|
|||||||
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:
|
||||||
|
|||||||
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`,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface ReplyLike {
|
|||||||
}
|
}
|
||||||
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";
|
||||||
|
|
||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { builder } from "../builder";
|
|||||||
import { CommentType, AdminCommentListType } 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, ilike, count } 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, requireAdmin } from "../../lib/acl";
|
||||||
|
import { gamificationQueue } from "../../queues/index";
|
||||||
|
|
||||||
builder.queryField("commentsForVideo", (t) =>
|
builder.queryField("commentsForVideo", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
@@ -59,10 +59,16 @@ builder.mutationField("createCommentForVideo", (t) =>
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
await gamificationQueue.add("awardPoints", {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE")
|
job: "awardPoints",
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "social"))
|
userId: ctx.currentUser.id,
|
||||||
.catch((e) => console.error("Gamification error on comment:", e));
|
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({
|
||||||
|
|||||||
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;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -4,8 +4,8 @@ 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, ilike, count, type SQL } 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 { requireAdmin } from "../../lib/acl";
|
||||||
|
import { gamificationQueue } from "../../queues/index";
|
||||||
|
|
||||||
builder.queryField("recordings", (t) =>
|
builder.queryField("recordings", (t) =>
|
||||||
t.field({
|
t.field({
|
||||||
@@ -122,11 +122,18 @@ builder.mutationField("createRecording", (t) =>
|
|||||||
|
|
||||||
const recording = newRecording[0];
|
const recording = newRecording[0];
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
|
||||||
if (recording.status === "published") {
|
if (recording.status === "published") {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
|
await gamificationQueue.add("awardPoints", {
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
job: "awardPoints",
|
||||||
.catch((e) => console.error("Gamification error on recording create:", e));
|
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;
|
||||||
@@ -180,15 +187,45 @@ builder.mutationField("updateRecording", (t) =>
|
|||||||
|
|
||||||
const recording = updated[0];
|
const recording = updated[0];
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
|
||||||
if (args.status === "published" && existing[0].status !== "published") {
|
if (args.status === "published" && existing[0].status !== "published") {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
|
// draft → published: award creation points
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
await gamificationQueue.add("awardPoints", {
|
||||||
.catch((e) => console.error("Gamification error on recording publish:", e));
|
job: "awardPoints",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
action: "RECORDING_CREATE",
|
||||||
|
recordingId: recording.id,
|
||||||
|
});
|
||||||
|
await gamificationQueue.add("checkAchievements", {
|
||||||
|
job: "checkAchievements",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
category: "recordings",
|
||||||
|
});
|
||||||
|
} else if (args.status === "draft" && existing[0].status === "published") {
|
||||||
|
// published → draft: revoke creation points
|
||||||
|
await gamificationQueue.add("revokePoints", {
|
||||||
|
job: "revokePoints",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
action: "RECORDING_CREATE",
|
||||||
|
recordingId: recording.id,
|
||||||
|
});
|
||||||
|
await gamificationQueue.add("checkAchievements", {
|
||||||
|
job: "checkAchievements",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
category: "recordings",
|
||||||
|
});
|
||||||
} else if (args.status === "published" && recording.featured && !existing[0].featured) {
|
} else if (args.status === "published" && recording.featured && !existing[0].featured) {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id)
|
// newly featured while published: award featured bonus
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
await gamificationQueue.add("awardPoints", {
|
||||||
.catch((e) => console.error("Gamification error on recording feature:", e));
|
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;
|
||||||
@@ -290,11 +327,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
|
|||||||
})
|
})
|
||||||
.returning({ id: recording_plays.id });
|
.returning({ id: recording_plays.id });
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
|
||||||
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId)
|
await gamificationQueue.add("awardPoints", {
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
|
job: "awardPoints",
|
||||||
.catch((e) => console.error("Gamification error on recording play:", e));
|
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 };
|
||||||
@@ -329,11 +373,18 @@ builder.mutationField("updateRecordingPlay", (t) =>
|
|||||||
})
|
})
|
||||||
.where(eq(recording_plays.id, args.playId));
|
.where(eq(recording_plays.id, args.playId));
|
||||||
|
|
||||||
// Gamification (non-blocking)
|
|
||||||
if (args.completed && !wasCompleted && ctx.currentUser) {
|
if (args.completed && !wasCompleted && ctx.currentUser) {
|
||||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id)
|
await gamificationQueue.add("awardPoints", {
|
||||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
|
job: "awardPoints",
|
||||||
.catch((e) => console.error("Gamification error on recording complete:", e));
|
userId: ctx.currentUser.id,
|
||||||
|
action: "RECORDING_COMPLETE",
|
||||||
|
recordingId: existing[0].recording_id,
|
||||||
|
});
|
||||||
|
await gamificationQueue.add("checkAchievements", {
|
||||||
|
job: "checkAchievements",
|
||||||
|
userId: ctx.currentUser.id,
|
||||||
|
category: "playback",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -333,6 +333,74 @@ 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
|
export const VideoListType = builder
|
||||||
.objectRef<{ items: Video[]; total: number }>("VideoList")
|
.objectRef<{ items: Video[]; total: number }>("VideoList")
|
||||||
.implement({
|
.implement({
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ 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 { 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";
|
||||||
@@ -28,6 +30,11 @@ async function main() {
|
|||||||
await migrate(db, { migrationsFolder });
|
await migrate(db, { migrationsFolder });
|
||||||
logger.info("Migrations complete");
|
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, {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,21 +28,41 @@ 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> {
|
||||||
|
await db
|
||||||
|
.delete(user_points)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(user_points.user_id, userId),
|
||||||
|
eq(user_points.action, action),
|
||||||
|
eq(user_points.recording_id, recordingId),
|
||||||
|
),
|
||||||
|
);
|
||||||
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}
|
||||||
|
|||||||
@@ -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;
|
||||||
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}`);
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
isMobileMenuOpen = false;
|
isMobileMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveLink(link: { name: string; href: string }) {
|
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])
|
||||||
@@ -47,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">
|
||||||
@@ -76,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")}
|
||||||
>
|
>
|
||||||
@@ -112,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"
|
||||||
>
|
>
|
||||||
@@ -124,7 +110,7 @@
|
|||||||
</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" />
|
||||||
|
|
||||||
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
|
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
|
||||||
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
||||||
@@ -138,41 +124,51 @@
|
|||||||
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span class="text-sm font-medium text-foreground/90 max-w-24 truncate">
|
<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]}
|
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="link"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 rounded-full text-foreground hover:text-destructive hover:bg-destructive/10"
|
class="hidden lg:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group hover:text-destructive"
|
||||||
onclick={handleLogout}
|
onclick={handleLogout}
|
||||||
title={$_("header.logout")}
|
title={$_("header.logout")}
|
||||||
>
|
>
|
||||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lg:hidden ml-2">
|
||||||
|
<BurgerMenuButton
|
||||||
|
label={$_("header.navigation")}
|
||||||
|
bind:isMobileMenuOpen
|
||||||
|
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class={className}
|
class={`rounded-full ring-2 ring-primary/20 ${className}`}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 10240 10240"
|
viewBox="0 0 10240 10240"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="relative py-12 md:py-20 overflow-hidden">
|
<section class="relative py-12 md:py-20 overflow-hidden">
|
||||||
<div class="absolute inset-0 bg-gradient-to-b from-primary/12 via-accent/6 to-transparent"></div>
|
|
||||||
<div class="relative container mx-auto px-4 text-center">
|
<div class="relative container mx-auto px-4 text-center">
|
||||||
<div class="max-w-5xl mx-auto">
|
<div class="max-w-5xl mx-auto">
|
||||||
<h1
|
<h1
|
||||||
|
|||||||
@@ -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,17 +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";
|
|
||||||
default:
|
|
||||||
return "text-gray-400 bg-gray-400/20";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@@ -42,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">
|
||||||
@@ -149,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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -91,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",
|
||||||
@@ -134,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: {
|
||||||
@@ -144,6 +165,8 @@ export default {
|
|||||||
status_draft: "Draft",
|
status_draft: "Draft",
|
||||||
status_published: "Published",
|
status_published: "Published",
|
||||||
play: "Play",
|
play: "Play",
|
||||||
|
publish: "Publish",
|
||||||
|
unpublish: "Unpublish",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
public: "Public",
|
public: "Public",
|
||||||
@@ -799,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: "Bring your 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",
|
||||||
@@ -905,14 +936,15 @@ export default {
|
|||||||
},
|
},
|
||||||
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",
|
comments: "Comments",
|
||||||
recordings: "Recordings",
|
recordings: "Recordings",
|
||||||
|
queues: "Queues",
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
save_changes: "Save changes",
|
save_changes: "Save changes",
|
||||||
@@ -927,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",
|
||||||
@@ -1058,6 +1090,36 @@ export default {
|
|||||||
delete_success: "Recording deleted",
|
delete_success: "Recording deleted",
|
||||||
delete_error: "Failed to delete recording",
|
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",
|
||||||
|
|||||||
@@ -902,6 +902,26 @@ export async function createRecording(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UPDATE_RECORDING_MUTATION = gql`
|
||||||
|
mutation UpdateRecording($id: String!, $status: String, $public: Boolean) {
|
||||||
|
updateRecording(id: $id, status: $status, public: $public) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
public
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function updateRecording(id: string, fields: { status?: string; public?: boolean }) {
|
||||||
|
return loggedApiCall("updateRecording", async () => {
|
||||||
|
const data = await getGraphQLClient().request<{ updateRecording: Recording }>(
|
||||||
|
UPDATE_RECORDING_MUTATION,
|
||||||
|
{ id, ...fields },
|
||||||
|
);
|
||||||
|
return data.updateRecording;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const DELETE_RECORDING_MUTATION = gql`
|
const DELETE_RECORDING_MUTATION = gql`
|
||||||
mutation DeleteRecording($id: String!) {
|
mutation DeleteRecording($id: String!) {
|
||||||
deleteRecording(id: $id)
|
deleteRecording(id: $id)
|
||||||
@@ -1876,3 +1896,145 @@ export async function adminDeleteRecording(id: string): Promise<void> {
|
|||||||
await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id });
|
await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Queues ---
|
||||||
|
|
||||||
|
export type JobCounts = {
|
||||||
|
waiting: number;
|
||||||
|
active: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
delayed: number;
|
||||||
|
paused: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueueInfo = {
|
||||||
|
name: string;
|
||||||
|
counts: JobCounts;
|
||||||
|
isPaused: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Job = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
queue: string;
|
||||||
|
status: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
result: unknown;
|
||||||
|
failedReason: string | null;
|
||||||
|
attemptsMade: number;
|
||||||
|
createdAt: string;
|
||||||
|
processedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
progress: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_QUEUES_QUERY = gql`
|
||||||
|
query AdminQueues {
|
||||||
|
adminQueues {
|
||||||
|
name
|
||||||
|
isPaused
|
||||||
|
counts {
|
||||||
|
waiting
|
||||||
|
active
|
||||||
|
completed
|
||||||
|
failed
|
||||||
|
delayed
|
||||||
|
paused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function getAdminQueues(
|
||||||
|
fetchFn?: typeof globalThis.fetch,
|
||||||
|
token?: string,
|
||||||
|
): Promise<QueueInfo[]> {
|
||||||
|
return loggedApiCall("getAdminQueues", async () => {
|
||||||
|
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
|
||||||
|
const data = await client.request<{ adminQueues: QueueInfo[] }>(ADMIN_QUEUES_QUERY);
|
||||||
|
return data.adminQueues;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_QUEUE_JOBS_QUERY = gql`
|
||||||
|
query AdminQueueJobs($queue: String!, $status: String, $limit: Int, $offset: Int) {
|
||||||
|
adminQueueJobs(queue: $queue, status: $status, limit: $limit, offset: $offset) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
queue
|
||||||
|
status
|
||||||
|
data
|
||||||
|
result
|
||||||
|
failedReason
|
||||||
|
attemptsMade
|
||||||
|
createdAt
|
||||||
|
processedAt
|
||||||
|
finishedAt
|
||||||
|
progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function getAdminQueueJobs(
|
||||||
|
queue: string,
|
||||||
|
status?: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
): Promise<Job[]> {
|
||||||
|
return loggedApiCall("getAdminQueueJobs", async () => {
|
||||||
|
const data = await getGraphQLClient().request<{ adminQueueJobs: Job[] }>(
|
||||||
|
ADMIN_QUEUE_JOBS_QUERY,
|
||||||
|
{ queue, status, limit, offset },
|
||||||
|
);
|
||||||
|
return data.adminQueueJobs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_RETRY_JOB_MUTATION = gql`
|
||||||
|
mutation AdminRetryJob($queue: String!, $jobId: String!) {
|
||||||
|
adminRetryJob(queue: $queue, jobId: $jobId)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminRetryJob(queue: string, jobId: string): Promise<void> {
|
||||||
|
return loggedApiCall("adminRetryJob", async () => {
|
||||||
|
await getGraphQLClient().request(ADMIN_RETRY_JOB_MUTATION, { queue, jobId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_REMOVE_JOB_MUTATION = gql`
|
||||||
|
mutation AdminRemoveJob($queue: String!, $jobId: String!) {
|
||||||
|
adminRemoveJob(queue: $queue, jobId: $jobId)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminRemoveJob(queue: string, jobId: string): Promise<void> {
|
||||||
|
return loggedApiCall("adminRemoveJob", async () => {
|
||||||
|
await getGraphQLClient().request(ADMIN_REMOVE_JOB_MUTATION, { queue, jobId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_PAUSE_QUEUE_MUTATION = gql`
|
||||||
|
mutation AdminPauseQueue($queue: String!) {
|
||||||
|
adminPauseQueue(queue: $queue)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ADMIN_RESUME_QUEUE_MUTATION = gql`
|
||||||
|
mutation AdminResumeQueue($queue: String!) {
|
||||||
|
adminResumeQueue(queue: $queue)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function adminPauseQueue(queue: string): Promise<void> {
|
||||||
|
return loggedApiCall("adminPauseQueue", async () => {
|
||||||
|
await getGraphQLClient().request(ADMIN_PAUSE_QUEUE_MUTATION, { queue });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminResumeQueue(queue: string): Promise<void> {
|
||||||
|
return loggedApiCall("adminResumeQueue", async () => {
|
||||||
|
await getGraphQLClient().request(ADMIN_RESUME_QUEUE_MUTATION, { queue });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
|
|
||||||
<div class="bg-background text-foreground min-h-screen">
|
<div class="bg-background text-foreground min-h-screen">
|
||||||
<!-- Advanced Global Plasma Background -->
|
<!-- Advanced Global Plasma Background -->
|
||||||
<div class="fixed inset-0 pointer-events-none overflow-hidden">
|
<div
|
||||||
|
class="fixed inset-0 pointer-events-none overflow-hidden bg-gradient-to-b from-primary/12 via-accent/6 to-transparent"
|
||||||
|
>
|
||||||
<!-- Large primary blobs -->
|
<!-- Large primary blobs -->
|
||||||
<div
|
<div
|
||||||
class="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-ultra-slow"
|
class="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-ultra-slow"
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
|
||||||
|
import { getUserInitials } from "$lib/utils";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
|
||||||
const { children } = $props();
|
const { children, data } = $props();
|
||||||
|
|
||||||
|
const user = $derived(data.authStatus.user!);
|
||||||
|
const avatarUrl = $derived(
|
||||||
|
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
|
||||||
|
);
|
||||||
|
const displayName = $derived(user.artist_name ?? user.email);
|
||||||
|
|
||||||
const navLinks = $derived([
|
const navLinks = $derived([
|
||||||
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
|
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
|
||||||
@@ -14,6 +23,11 @@
|
|||||||
href: "/admin/recordings",
|
href: "/admin/recordings",
|
||||||
icon: "icon-[ri--record-circle-line]",
|
icon: "icon-[ri--record-circle-line]",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: $_("admin.nav.queues"),
|
||||||
|
href: "/admin/queues",
|
||||||
|
icon: "icon-[ri--stack-line]",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
@@ -28,9 +42,10 @@
|
|||||||
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
|
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
|
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
|
||||||
>
|
>
|
||||||
{$_("admin.nav.back_mobile")}
|
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
|
||||||
|
<span class="hidden sm:inline">{$_("admin.nav.back_mobile")}</span>
|
||||||
</a>
|
</a>
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
<a
|
<a
|
||||||
@@ -53,10 +68,33 @@
|
|||||||
<!-- Sidebar (desktop only) -->
|
<!-- Sidebar (desktop only) -->
|
||||||
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
|
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
|
||||||
<div class="px-4 py-5 border-b border-border/40">
|
<div class="px-4 py-5 border-b border-border/40">
|
||||||
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
<a
|
||||||
|
href="/"
|
||||||
|
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
|
||||||
{$_("admin.nav.back_to_site")}
|
{$_("admin.nav.back_to_site")}
|
||||||
</a>
|
</a>
|
||||||
<h1 class="mt-2 text-base font-bold text-foreground">{$_("admin.nav.title")}</h1>
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<Avatar class="h-9 w-9">
|
||||||
|
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||||
|
<AvatarFallback class="text-xs">
|
||||||
|
{getUserInitials(displayName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span
|
||||||
|
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary ring-2 ring-background"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--shield-keyhole-fill] h-2.5 w-2.5 text-primary-foreground"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
|
||||||
|
<p class="text-xs text-primary font-medium">{$_("admin.nav.title")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 p-3 space-y-1">
|
<nav class="flex-1 p-3 space-y-1">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { Article } from "$lib/types";
|
import type { Article } from "$lib/types";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -64,8 +65,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.articles.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.articles.title")}</h1>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.articles.search_placeholder")}
|
placeholder={$_("admin.articles.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -201,7 +204,7 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if data.total > data.limit}
|
{#if data.total > data.limit}
|
||||||
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
<div class="flex items-center justify-between mt-4">
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
import { Textarea } from "$lib/components/ui/textarea";
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -93,145 +95,170 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6">
|
<Meta title={$_("admin.article_form.edit_title")} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/articles" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
<h1 class="text-2xl font-bold">{data.article.title}</h1>
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.article_form.edit_title")}</h1>
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{data.article.slug}{data.article.category ? " · " + data.article.category : ""}{data.article
|
||||||
|
.author
|
||||||
|
? " · " + data.article.author.artist_name
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5 max-w-4xl">
|
<Card class="bg-card/50 border-primary/20 max-w-4xl">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<CardContent class="space-y-5 pt-6">
|
||||||
<div class="space-y-1.5">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<div class="space-y-1.5">
|
||||||
<Input id="title" bind:value={title} />
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
</div>
|
<Input
|
||||||
<div class="space-y-1.5">
|
id="title"
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
bind:value={title}
|
||||||
<Input id="slug" bind:value={slug} />
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
<div class="space-y-1.5">
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
<Input
|
||||||
<Textarea id="excerpt" bind:value={excerpt} rows={2} />
|
id="slug"
|
||||||
</div>
|
bind:value={slug}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
<!-- Markdown editor with live preview -->
|
/>
|
||||||
<div class="space-y-1.5">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<Label>{$_("admin.article_form.content")}</Label>
|
|
||||||
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
|
||||||
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
|
||||||
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
bind:value={content}
|
id="excerpt"
|
||||||
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
bind:value={excerpt}
|
||||||
|
rows={2}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
<div
|
</div>
|
||||||
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
|
|
||||||
>
|
<!-- Markdown editor with live preview -->
|
||||||
{#if preview}
|
<div class="space-y-1.5">
|
||||||
{@html preview}
|
<div class="flex items-center justify-between">
|
||||||
{:else}
|
<Label>{$_("admin.article_form.content")}</Label>
|
||||||
<p class="text-muted-foreground italic text-sm">
|
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
||||||
{$_("admin.article_form.preview_placeholder")}
|
<Button
|
||||||
</p>
|
variant="ghost"
|
||||||
{/if}
|
size="sm"
|
||||||
|
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||||
|
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||||
|
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||||
|
<Textarea
|
||||||
|
bind:value={content}
|
||||||
|
class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
|
||||||
|
>
|
||||||
|
{#if preview}
|
||||||
|
{@html preview}
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground italic text-sm">
|
||||||
|
{$_("admin.article_form.preview_placeholder")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.cover_image")}</Label>
|
<Label>{$_("admin.common.cover_image")}</Label>
|
||||||
{#if imageId}
|
{#if imageId}
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(imageId, "thumbnail")}
|
src={getAssetUrl(imageId, "thumbnail")}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-24 rounded object-cover mb-2"
|
class="h-24 rounded object-cover mb-2"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Author -->
|
<div class="space-y-1.5">
|
||||||
<div class="space-y-1.5">
|
<Label>{$_("admin.article_form.author")}</Label>
|
||||||
<Label>{$_("admin.article_form.author")}</Label>
|
<Select type="single" bind:value={authorId}>
|
||||||
<Select type="single" bind:value={authorId}>
|
<SelectTrigger class="w-full bg-background/50 border-primary/20">
|
||||||
<SelectTrigger class="w-full">
|
{#if selectedAuthor}
|
||||||
{#if selectedAuthor}
|
{#if selectedAuthor.avatar}
|
||||||
{#if selectedAuthor.avatar}
|
|
||||||
<img
|
|
||||||
src={getAssetUrl(selectedAuthor.avatar, "mini")}
|
|
||||||
alt=""
|
|
||||||
class="h-5 w-5 rounded-full object-cover shrink-0"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{selectedAuthor.artist_name}
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
|
|
||||||
{/if}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
|
|
||||||
{#each data.authors as author (author.id)}
|
|
||||||
<SelectItem value={author.id}>
|
|
||||||
{#if author.avatar}
|
|
||||||
<img
|
<img
|
||||||
src={getAssetUrl(author.avatar, "mini")}
|
src={getAssetUrl(selectedAuthor.avatar, "mini")}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-5 w-5 rounded-full object-cover shrink-0"
|
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{author.artist_name}
|
{selectedAuthor.artist_name}
|
||||||
</SelectItem>
|
{:else}
|
||||||
{/each}
|
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
|
||||||
</SelectContent>
|
{/if}
|
||||||
</Select>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
|
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
{#each data.authors as author (author.id)}
|
||||||
<div class="space-y-1.5">
|
<SelectItem value={author.id}>
|
||||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
{#if author.avatar}
|
||||||
<Input id="category" bind:value={category} />
|
<img
|
||||||
|
src={getAssetUrl(author.avatar, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{author.artist_name}
|
||||||
|
</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.common.publish_date")}</Label>
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
<div class="space-y-1.5">
|
||||||
|
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||||
|
<Input
|
||||||
|
id="category"
|
||||||
|
bind:value={category}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.common.publish_date")}</Label>
|
||||||
|
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.tags")}</Label>
|
<Label>{$_("admin.common.tags")}</Label>
|
||||||
<TagsInput bind:value={tags} />
|
<TagsInput
|
||||||
</div>
|
bind:value={tags}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
>
|
>
|
||||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
let title = $state("");
|
let title = $state("");
|
||||||
let slug = $state("");
|
let slug = $state("");
|
||||||
@@ -75,127 +77,134 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6">
|
<Meta title={$_("admin.article_form.new_title")} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/articles" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5 max-w-4xl">
|
<Card class="bg-card/50 border-primary/20 max-w-4xl">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<CardContent class="space-y-5 pt-6">
|
||||||
<div class="space-y-1.5">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<div class="space-y-1.5">
|
||||||
<Input
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
id="title"
|
<Input
|
||||||
bind:value={title}
|
id="title"
|
||||||
oninput={() => {
|
bind:value={title}
|
||||||
if (!slug) slug = generateSlug(title);
|
oninput={() => {
|
||||||
}}
|
if (!slug) slug = generateSlug(title);
|
||||||
placeholder={$_("admin.article_form.title_placeholder")}
|
}}
|
||||||
/>
|
placeholder={$_("admin.article_form.title_placeholder")}
|
||||||
</div>
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
<div class="space-y-1.5">
|
/>
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
</div>
|
||||||
<Input
|
<div class="space-y-1.5">
|
||||||
id="slug"
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
bind:value={slug}
|
<Input
|
||||||
placeholder={$_("admin.article_form.slug_placeholder")}
|
id="slug"
|
||||||
/>
|
bind:value={slug}
|
||||||
</div>
|
placeholder={$_("admin.article_form.slug_placeholder")}
|
||||||
</div>
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
|
||||||
<Textarea
|
|
||||||
id="excerpt"
|
|
||||||
bind:value={excerpt}
|
|
||||||
placeholder={$_("admin.article_form.excerpt_placeholder")}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Markdown editor with live preview -->
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<Label>{$_("admin.article_form.content")}</Label>
|
|
||||||
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
|
||||||
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
|
||||||
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile: single pane toggled; Desktop: side by side -->
|
|
||||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
<div class="space-y-1.5">
|
||||||
|
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
bind:value={content}
|
id="excerpt"
|
||||||
placeholder={$_("admin.article_form.content_placeholder")}
|
bind:value={excerpt}
|
||||||
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
placeholder={$_("admin.article_form.excerpt_placeholder")}
|
||||||
|
rows={2}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
<div
|
</div>
|
||||||
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
|
|
||||||
>
|
<!-- Markdown editor with live preview -->
|
||||||
{#if preview}
|
<div class="space-y-1.5">
|
||||||
{@html preview}
|
<div class="flex items-center justify-between">
|
||||||
{:else}
|
<Label>{$_("admin.article_form.content")}</Label>
|
||||||
<p class="text-muted-foreground italic text-sm">
|
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
||||||
{$_("admin.article_form.preview_placeholder")}
|
<Button
|
||||||
</p>
|
variant="ghost"
|
||||||
{/if}
|
size="sm"
|
||||||
|
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||||
|
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||||
|
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile: single pane toggled; Desktop: side by side -->
|
||||||
|
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||||
|
<Textarea
|
||||||
|
bind:value={content}
|
||||||
|
placeholder={$_("admin.article_form.content_placeholder")}
|
||||||
|
class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
|
||||||
|
>
|
||||||
|
{#if preview}
|
||||||
|
{@html preview}
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground italic text-sm">
|
||||||
|
{$_("admin.article_form.preview_placeholder")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.common.cover_image")}</Label>
|
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
|
||||||
{#if imageId}<p class="text-xs text-green-600 mt-1">
|
|
||||||
{$_("admin.common.image_uploaded")} ✓
|
|
||||||
</p>{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
<Label>{$_("admin.common.cover_image")}</Label>
|
||||||
<Input
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
id="category"
|
{#if imageId}
|
||||||
bind:value={category}
|
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>
|
||||||
placeholder={$_("admin.article_form.category_placeholder")}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||||
|
<Input
|
||||||
|
id="category"
|
||||||
|
bind:value={category}
|
||||||
|
placeholder={$_("admin.article_form.category_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.common.publish_date")}</Label>
|
||||||
|
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.common.tags")}</Label>
|
||||||
|
<TagsInput
|
||||||
|
bind:value={tags}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.common.publish_date")}</Label>
|
|
||||||
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<Label>{$_("admin.common.tags")}</Label>
|
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||||
<TagsInput bind:value={tags} />
|
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
|
||||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
>
|
>
|
||||||
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
|
{saving ? $_("admin.common.creating") : $_("admin.article_form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const timeAgo = new TimeAgo("en");
|
const timeAgo = new TimeAgo("en");
|
||||||
@@ -53,15 +54,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.comments.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.comments.title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.comments.title")}</h1>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.comments.search_placeholder")}
|
placeholder={$_("admin.comments.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -150,7 +153,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.total > data.limit}
|
{#if data.total > data.limit}
|
||||||
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
<div class="flex items-center justify-between mt-4">
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { getAdminQueues } from "$lib/services";
|
||||||
|
|
||||||
|
export async function load({ fetch, cookies }) {
|
||||||
|
const token = cookies.get("session_token") || "";
|
||||||
|
const queues = await getAdminQueues(fetch, token).catch(() => []);
|
||||||
|
return { queues };
|
||||||
|
}
|
||||||
302
packages/frontend/src/routes/admin/queues/+page.svelte
Normal file
302
packages/frontend/src/routes/admin/queues/+page.svelte
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import {
|
||||||
|
getAdminQueueJobs,
|
||||||
|
adminRetryJob,
|
||||||
|
adminRemoveJob,
|
||||||
|
adminPauseQueue,
|
||||||
|
adminResumeQueue,
|
||||||
|
} from "$lib/services";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
|
import type { Job } from "$lib/services";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
const queues = $derived(data.queues);
|
||||||
|
|
||||||
|
// null means "user hasn't picked yet" — fall back to first queue
|
||||||
|
let selectedQueueOverride = $state<string | null>(null);
|
||||||
|
const selectedQueue = $derived(selectedQueueOverride ?? queues[0]?.name ?? null);
|
||||||
|
let selectedStatus = $state<string | null>(null);
|
||||||
|
let jobs = $state<Job[]>([]);
|
||||||
|
let loadingJobs = $state(false);
|
||||||
|
let togglingQueue = $state<string | null>(null);
|
||||||
|
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ value: null, label: $_("admin.queues.status_all") },
|
||||||
|
{ value: "waiting", label: $_("admin.queues.status_waiting") },
|
||||||
|
{ value: "active", label: $_("admin.queues.status_active") },
|
||||||
|
{ value: "completed", label: $_("admin.queues.status_completed") },
|
||||||
|
{ value: "failed", label: $_("admin.queues.status_failed") },
|
||||||
|
{ value: "delayed", label: $_("admin.queues.status_delayed") },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadJobs() {
|
||||||
|
if (!selectedQueue) return;
|
||||||
|
loadingJobs = true;
|
||||||
|
try {
|
||||||
|
jobs = await getAdminQueueJobs(selectedQueue, selectedStatus ?? undefined, 50, 0);
|
||||||
|
} finally {
|
||||||
|
loadingJobs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectQueue(name: string) {
|
||||||
|
selectedQueueOverride = name;
|
||||||
|
selectedStatus = null;
|
||||||
|
await loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectStatus(status: string | null) {
|
||||||
|
selectedStatus = status;
|
||||||
|
await loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryJob(job: Job) {
|
||||||
|
try {
|
||||||
|
await adminRetryJob(job.queue, job.id);
|
||||||
|
toast.success($_("admin.queues.retry_success"));
|
||||||
|
await loadJobs();
|
||||||
|
await refreshCounts();
|
||||||
|
} catch {
|
||||||
|
toast.error($_("admin.queues.retry_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeJob(job: Job) {
|
||||||
|
try {
|
||||||
|
await adminRemoveJob(job.queue, job.id);
|
||||||
|
toast.success($_("admin.queues.remove_success"));
|
||||||
|
jobs = jobs.filter((j) => j.id !== job.id);
|
||||||
|
await refreshCounts();
|
||||||
|
} catch {
|
||||||
|
toast.error($_("admin.queues.remove_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleQueue(queueName: string, isPaused: boolean) {
|
||||||
|
togglingQueue = queueName;
|
||||||
|
try {
|
||||||
|
if (isPaused) {
|
||||||
|
await adminResumeQueue(queueName);
|
||||||
|
toast.success($_("admin.queues.resume_success"));
|
||||||
|
} else {
|
||||||
|
await adminPauseQueue(queueName);
|
||||||
|
toast.success($_("admin.queues.pause_success"));
|
||||||
|
}
|
||||||
|
await refreshCounts();
|
||||||
|
} catch {
|
||||||
|
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
|
||||||
|
} finally {
|
||||||
|
togglingQueue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCounts() {
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedQueue) loadJobs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "text-blue-500 border-blue-500/30 bg-blue-500/10";
|
||||||
|
case "completed":
|
||||||
|
return "text-green-500 border-green-500/30 bg-green-500/10";
|
||||||
|
case "failed":
|
||||||
|
return "text-destructive border-destructive/30 bg-destructive/10";
|
||||||
|
case "delayed":
|
||||||
|
return "text-yellow-500 border-yellow-500/30 bg-yellow-500/10";
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground border-border/40 bg-muted/20";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta title={$_("admin.queues.title")} description={null} />
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue cards -->
|
||||||
|
<div class="flex flex-wrap gap-3 mb-6">
|
||||||
|
{#each queues as queue (queue.name)}
|
||||||
|
{@const isSelected = selectedQueue === queue.name}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class={`flex-1 min-w-48 rounded-lg border p-4 text-left transition-colors cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? "border-primary/50 bg-primary/5"
|
||||||
|
: "border-border/40 bg-card hover:border-border/70"
|
||||||
|
}`}
|
||||||
|
onclick={() => selectQueue(queue.name)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && selectQueue(queue.name)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="font-semibold capitalize">{queue.name}</span>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
{#if queue.isPaused}
|
||||||
|
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10"
|
||||||
|
>{$_("admin.queues.paused_badge")}</Badge
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
disabled={togglingQueue === queue.name}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleQueue(queue.name, queue.isPaused);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{queue.isPaused ? $_("admin.queues.resume") : $_("admin.queues.pause")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
|
{#if queue.counts.waiting > 0}
|
||||||
|
<span class="text-muted-foreground">{queue.counts.waiting} waiting</span>
|
||||||
|
{/if}
|
||||||
|
{#if queue.counts.active > 0}
|
||||||
|
<span class="text-blue-500">{queue.counts.active} active</span>
|
||||||
|
{/if}
|
||||||
|
{#if queue.counts.completed > 0}
|
||||||
|
<span class="text-green-500">{queue.counts.completed} completed</span>
|
||||||
|
{/if}
|
||||||
|
{#if queue.counts.failed > 0}
|
||||||
|
<span class="text-destructive font-medium">{queue.counts.failed} failed</span>
|
||||||
|
{/if}
|
||||||
|
{#if queue.counts.delayed > 0}
|
||||||
|
<span class="text-yellow-500">{queue.counts.delayed} delayed</span>
|
||||||
|
{/if}
|
||||||
|
{#if Object.values(queue.counts).every((v) => v === 0)}
|
||||||
|
<span class="text-muted-foreground">empty</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedQueue}
|
||||||
|
<!-- Status filter tabs -->
|
||||||
|
<div class="flex gap-1 mb-4 flex-wrap">
|
||||||
|
{#each STATUS_FILTERS as f (f.value ?? "all")}
|
||||||
|
<Button
|
||||||
|
variant={selectedStatus === f.value ? "default" : "outline"}
|
||||||
|
onclick={() => selectStatus(f.value)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jobs table -->
|
||||||
|
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
||||||
|
>{$_("admin.queues.col_id")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
||||||
|
>{$_("admin.queues.col_name")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
||||||
|
>{$_("admin.queues.col_status")}</th
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">
|
||||||
|
{$_("admin.queues.col_attempts")}
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden lg:table-cell">
|
||||||
|
{$_("admin.queues.col_created")}
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
|
||||||
|
>{$_("admin.queues.col_actions")}</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border/30">
|
||||||
|
{#if loadingJobs}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>{$_("common.loading")}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each jobs as job (job.id)}
|
||||||
|
<tr class="hover:bg-muted/10 transition-colors">
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{job.name}</p>
|
||||||
|
{#if job.failedReason}
|
||||||
|
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
|
||||||
|
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
|
||||||
|
>{job.attemptsMade}</td
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"
|
||||||
|
>{formatDate(job.createdAt)}</td
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
{#if job.status === "failed"}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={$_("admin.queues.retry")}
|
||||||
|
onclick={() => retryJob(job)}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--restart-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={$_("admin.queues.remove")}
|
||||||
|
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onclick={() => removeJob(job)}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#if jobs.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>{$_("admin.queues.no_jobs")}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { Recording } from "$lib/types";
|
import type { Recording } from "$lib/types";
|
||||||
import TimeAgo from "javascript-time-ago";
|
import TimeAgo from "javascript-time-ago";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const timeAgo = new TimeAgo("en");
|
const timeAgo = new TimeAgo("en");
|
||||||
@@ -63,15 +64,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.recordings.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.recordings.title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.recordings.title")}</h1>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.recordings.search_placeholder")}
|
placeholder={$_("admin.recordings.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -128,10 +131,12 @@
|
|||||||
<td class="px-4 py-3 hidden sm:table-cell">
|
<td class="px-4 py-3 hidden sm:table-cell">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Badge
|
<Badge
|
||||||
variant={recording.status === "published" ? "default" : "outline"}
|
variant="outline"
|
||||||
class={recording.status === "draft" ? "text-muted-foreground" : ""}
|
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.status}
|
{$_(`recording_card.status_${recording.status}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{#if recording.public}
|
{#if recording.public}
|
||||||
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
|
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
|
||||||
@@ -174,7 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.total > data.limit}
|
{#if data.total > data.limit}
|
||||||
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
<div class="flex items-center justify-between mt-4">
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { User } from "$lib/types";
|
import type { User } from "$lib/types";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -84,8 +85,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.users.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.users.title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.users.title")}</h1>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||||
@@ -93,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.users.search_placeholder")}
|
placeholder={$_("admin.users.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -225,7 +228,7 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if data.total > data.limit}
|
{#if data.total > data.limit}
|
||||||
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
<div class="flex items-center justify-between mt-4">
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -125,11 +127,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6 max-w-2xl">
|
<Meta title={data.user.artist_name || data.user.email} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/users" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
|
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
@@ -140,117 +141,142 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6 max-w-2xl">
|
||||||
<!-- Basic info -->
|
<!-- Profile & files card -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<Card class="bg-card/50 border-primary/20">
|
||||||
<div class="space-y-1.5">
|
<CardContent class="space-y-5 pt-6">
|
||||||
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Input id="firstName" bind:value={firstName} />
|
<div class="space-y-1.5">
|
||||||
</div>
|
<Label for="firstName">{$_("admin.user_edit.first_name")}</Label>
|
||||||
<div class="space-y-1.5">
|
<Input
|
||||||
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
|
id="firstName"
|
||||||
<Input id="lastName" bind:value={lastName} />
|
bind:value={firstName}
|
||||||
</div>
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
|
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
|
||||||
<Input id="artistName" bind:value={artistName} />
|
<Input
|
||||||
</div>
|
id="lastName"
|
||||||
|
bind:value={lastName}
|
||||||
<!-- Avatar -->
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
<div class="space-y-1.5">
|
/>
|
||||||
<Label>{$_("admin.user_edit.avatar")}</Label>
|
</div>
|
||||||
{#if avatarId}
|
|
||||||
<img
|
|
||||||
src={getAssetUrl(avatarId, "thumbnail")}
|
|
||||||
alt=""
|
|
||||||
class="h-20 w-20 rounded-full object-cover mb-2"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Banner -->
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.user_edit.banner")}</Label>
|
|
||||||
{#if bannerId}
|
|
||||||
<img
|
|
||||||
src={getAssetUrl(bannerId, "preview")}
|
|
||||||
alt=""
|
|
||||||
class="w-full h-24 rounded object-cover mb-2"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Model photo (used in cards & model page, not for avatar/comments) -->
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.user_edit.model_photo")}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
|
|
||||||
{#if photoId}
|
|
||||||
<img
|
|
||||||
src={getAssetUrl(photoId, "preview")}
|
|
||||||
alt=""
|
|
||||||
class="w-full h-48 rounded object-cover mb-2"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload2} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Admin flag -->
|
|
||||||
<label
|
|
||||||
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={isAdmin}
|
|
||||||
class="h-4 w-4 rounded accent-primary shrink-0"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
|
|
||||||
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<Button
|
|
||||||
onclick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
|
||||||
>
|
|
||||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photo gallery -->
|
|
||||||
<div class="space-y-3 pt-4 border-t border-border/40">
|
|
||||||
<Label>{$_("admin.user_edit.photos")}</Label>
|
|
||||||
|
|
||||||
{#if data.user.photos && data.user.photos.length > 0}
|
|
||||||
<div class="grid grid-cols-3 gap-2">
|
|
||||||
{#each data.user.photos as photo (photo.id)}
|
|
||||||
<div class="relative group">
|
|
||||||
<img
|
|
||||||
src={getAssetUrl(photo.id, "thumbnail")}
|
|
||||||
alt=""
|
|
||||||
class="w-full aspect-square object-cover rounded"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded"
|
|
||||||
onclick={() => removePhoto(photo.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
<div class="space-y-1.5">
|
||||||
</div>
|
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
|
||||||
|
<Input
|
||||||
|
id="artistName"
|
||||||
|
bind:value={artistName}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.user_edit.avatar")}</Label>
|
||||||
|
{#if avatarId}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(avatarId, "thumbnail")}
|
||||||
|
alt=""
|
||||||
|
class="h-20 w-20 rounded-full object-cover mb-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone
|
||||||
|
accept="image/*"
|
||||||
|
maxFileSize={10 * MEGABYTE}
|
||||||
|
onUpload={handleAvatarUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.user_edit.banner")}</Label>
|
||||||
|
{#if bannerId}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(bannerId, "preview")}
|
||||||
|
alt=""
|
||||||
|
class="w-full h-24 rounded object-cover mb-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone
|
||||||
|
accept="image/*"
|
||||||
|
maxFileSize={10 * MEGABYTE}
|
||||||
|
onUpload={handleBannerUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.user_edit.model_photo")}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
|
||||||
|
{#if photoId}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(photoId, "preview")}
|
||||||
|
alt=""
|
||||||
|
class="w-full h-48 rounded object-cover mb-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone
|
||||||
|
accept="image/*"
|
||||||
|
maxFileSize={10 * MEGABYTE}
|
||||||
|
onUpload={handlePhotoUpload2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={isAdmin}
|
||||||
|
class="h-4 w-4 rounded accent-primary shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
|
||||||
|
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Photo gallery card -->
|
||||||
|
<Card class="bg-card/50 border-primary/20">
|
||||||
|
<CardContent class="space-y-4 pt-6">
|
||||||
|
<Label>{$_("admin.user_edit.photos")}</Label>
|
||||||
|
|
||||||
|
{#if data.user.photos && data.user.photos.length > 0}
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each data.user.photos as photo (photo.id)}
|
||||||
|
<div class="relative group">
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(photo.id, "thumbnail")}
|
||||||
|
alt=""
|
||||||
|
class="w-full aspect-square object-cover rounded"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded h-auto p-0"
|
||||||
|
onclick={() => removePhoto(photo.id)}
|
||||||
|
aria-label="Remove photo"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import type { Video } from "$lib/types";
|
import type { Video } from "$lib/types";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -61,8 +62,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-3 sm:py-6 sm:pl-6">
|
<Meta title={$_("admin.videos.title")} description={null} />
|
||||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.videos.title")}</h1>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
@@ -78,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4 px-3 sm:px-0">
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={$_("admin.videos.search_placeholder")}
|
placeholder={$_("admin.videos.search_placeholder")}
|
||||||
class="max-w-xs"
|
class="max-w-xs"
|
||||||
@@ -206,7 +209,7 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{#if data.total > data.limit}
|
{#if data.total > data.limit}
|
||||||
<div class="flex items-center justify-between mt-4 px-3 sm:px-0">
|
<div class="flex items-center justify-between mt-4">
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{$_("admin.users.showing", {
|
{$_("admin.users.showing", {
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
import { Textarea } from "$lib/components/ui/textarea";
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { getAssetUrl } from "$lib/api";
|
import { getAssetUrl } from "$lib/api";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -102,132 +104,145 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6 max-w-2xl">
|
<Meta title={$_("admin.video_form.edit_title")} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/videos" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
<h1 class="text-2xl font-bold">{data.video.title}</h1>
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.video_form.edit_title")}</h1>
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{data.video.slug}{data.video.premium ? " · premium" : ""}{data.video.featured
|
||||||
|
? " · featured"
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<CardContent class="space-y-5 pt-6">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
bind:value={title}
|
||||||
|
placeholder={$_("admin.video_form.title_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
bind:value={slug}
|
||||||
|
placeholder={$_("admin.video_form.slug_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||||
<Input
|
<Textarea
|
||||||
id="title"
|
id="description"
|
||||||
bind:value={title}
|
bind:value={description}
|
||||||
placeholder={$_("admin.video_form.title_placeholder")}
|
placeholder={$_("admin.video_form.description_placeholder")}
|
||||||
|
rows={3}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
<Label>{$_("admin.common.cover_image")}</Label>
|
||||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
|
{#if imageId}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(imageId, "thumbnail")}
|
||||||
|
alt=""
|
||||||
|
class="h-24 rounded object-cover mb-2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||||
<Textarea
|
{#if movieId}
|
||||||
id="description"
|
<video
|
||||||
bind:value={description}
|
src={getAssetUrl(movieId)}
|
||||||
placeholder={$_("admin.video_form.description_placeholder")}
|
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
|
||||||
rows={3}
|
controls
|
||||||
/>
|
class="w-full rounded-lg bg-black max-h-72 mb-2"
|
||||||
</div>
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
{/if}
|
||||||
|
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>{$_("admin.common.cover_image")}</Label>
|
<Label>{$_("admin.common.tags")}</Label>
|
||||||
{#if imageId}
|
<TagsInput
|
||||||
<img
|
bind:value={tags}
|
||||||
src={getAssetUrl(imageId, "thumbnail")}
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
alt=""
|
|
||||||
class="h-24 rounded object-cover mb-2"
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
|
||||||
{#if movieId}
|
|
||||||
<video
|
|
||||||
src={getAssetUrl(movieId)}
|
|
||||||
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
|
|
||||||
controls
|
|
||||||
class="w-full rounded-lg bg-black max-h-72 mb-2"
|
|
||||||
>
|
|
||||||
<track kind="captions" />
|
|
||||||
</video>
|
|
||||||
{/if}
|
|
||||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.common.tags")}</Label>
|
|
||||||
<TagsInput bind:value={tags} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.common.publish_date")}</Label>
|
|
||||||
<DatePicker
|
|
||||||
bind:value={uploadDate}
|
|
||||||
placeholder={$_("admin.common.publish_date")}
|
|
||||||
showTime={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-6">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" bind:checked={premium} class="rounded" />
|
|
||||||
<span class="text-sm">{$_("admin.common.premium")}</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
|
||||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if data.models.length > 0}
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.video_form.models")}</Label>
|
|
||||||
<Select type="multiple" bind:value={selectedModelIds}>
|
|
||||||
<SelectTrigger class="w-full">
|
|
||||||
{#if selectedModelIds.length}
|
|
||||||
{$_("admin.video_form.models_selected", {
|
|
||||||
values: { count: selectedModelIds.length },
|
|
||||||
})}
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
|
|
||||||
{/if}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{#each data.models as model (model.id)}
|
|
||||||
<SelectItem value={model.id}>
|
|
||||||
{#if model.avatar}
|
|
||||||
<img
|
|
||||||
src={getAssetUrl(model.avatar, "mini")}
|
|
||||||
alt=""
|
|
||||||
class="h-5 w-5 rounded-full object-cover shrink-0"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{model.artist_name}
|
|
||||||
</SelectItem>
|
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.common.publish_date")}</Label>
|
||||||
|
<DatePicker
|
||||||
|
bind:value={uploadDate}
|
||||||
|
placeholder={$_("admin.common.publish_date")}
|
||||||
|
showTime={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||||
|
<span class="text-sm">{$_("admin.common.premium")}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||||
|
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.models.length > 0}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.video_form.models")}</Label>
|
||||||
|
<Select type="multiple" bind:value={selectedModelIds}>
|
||||||
|
<SelectTrigger class="w-full bg-background/50 border-primary/20">
|
||||||
|
{#if selectedModelIds.length}
|
||||||
|
{$_("admin.video_form.models_selected", {
|
||||||
|
values: { count: selectedModelIds.length },
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
|
||||||
|
{/if}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each data.models as model (model.id)}
|
||||||
|
<SelectItem value={model.id}>
|
||||||
|
{#if model.avatar}
|
||||||
|
<img
|
||||||
|
src={getAssetUrl(model.avatar, "mini")}
|
||||||
|
alt=""
|
||||||
|
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{model.artist_name}
|
||||||
|
</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
>
|
>
|
||||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -97,114 +99,123 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6 max-w-2xl">
|
<Meta title={$_("admin.video_form.new_title")} description={null} />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" href="/admin/videos" size="sm">
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
<div class="mb-6">
|
||||||
</Button>
|
|
||||||
<h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
|
<h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<CardContent class="space-y-5 pt-6">
|
||||||
<div class="space-y-1.5">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
<div class="space-y-1.5">
|
||||||
<Input
|
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||||
id="title"
|
<Input
|
||||||
bind:value={title}
|
id="title"
|
||||||
oninput={() => {
|
bind:value={title}
|
||||||
if (!slug) slug = generateSlug(title);
|
oninput={() => {
|
||||||
}}
|
if (!slug) slug = generateSlug(title);
|
||||||
placeholder={$_("admin.video_form.title_placeholder")}
|
}}
|
||||||
/>
|
placeholder={$_("admin.video_form.title_placeholder")}
|
||||||
</div>
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
<div class="space-y-1.5">
|
/>
|
||||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
</div>
|
||||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
|
<div class="space-y-1.5">
|
||||||
</div>
|
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||||
</div>
|
<Input
|
||||||
|
id="slug"
|
||||||
<div class="space-y-1.5">
|
bind:value={slug}
|
||||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
placeholder={$_("admin.video_form.slug_placeholder")}
|
||||||
<Textarea
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
id="description"
|
/>
|
||||||
bind:value={description}
|
|
||||||
placeholder={$_("admin.video_form.description_placeholder")}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.common.cover_image")}</Label>
|
|
||||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
|
||||||
{#if imageId}<p class="text-xs text-green-600 mt-1">
|
|
||||||
{$_("admin.common.image_uploaded")} ✓
|
|
||||||
</p>{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
|
||||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
|
||||||
{#if movieId}<p class="text-xs text-green-600 mt-1">
|
|
||||||
{$_("admin.video_form.video_uploaded")} ✓
|
|
||||||
</p>{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.common.tags")}</Label>
|
|
||||||
<TagsInput bind:value={tags} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label>{$_("admin.common.publish_date")}</Label>
|
|
||||||
<DatePicker
|
|
||||||
bind:value={uploadDate}
|
|
||||||
placeholder={$_("admin.common.publish_date")}
|
|
||||||
showTime={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-6">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" bind:checked={premium} class="rounded" />
|
|
||||||
<span class="text-sm">{$_("admin.common.premium")}</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
|
||||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if data.models.length > 0}
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Models</Label>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each data.models as model (model.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
|
|
||||||
selectedModelIds.includes(model.id)
|
|
||||||
? "border-primary bg-primary/10 text-primary"
|
|
||||||
: "border-border/40 text-muted-foreground hover:border-primary/40"
|
|
||||||
}`}
|
|
||||||
onclick={() => toggleModel(model.id)}
|
|
||||||
>
|
|
||||||
{model.artist_name || model.id}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="space-y-1.5">
|
||||||
|
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder={$_("admin.video_form.description_placeholder")}
|
||||||
|
rows={3}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.common.cover_image")}</Label>
|
||||||
|
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||||
|
{#if imageId}
|
||||||
|
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||||
|
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||||
|
{#if movieId}
|
||||||
|
<p class="text-xs text-green-600 mt-1">{$_("admin.video_form.video_uploaded")} ✓</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.common.tags")}</Label>
|
||||||
|
<TagsInput
|
||||||
|
bind:value={tags}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>{$_("admin.common.publish_date")}</Label>
|
||||||
|
<DatePicker
|
||||||
|
bind:value={uploadDate}
|
||||||
|
placeholder={$_("admin.common.publish_date")}
|
||||||
|
showTime={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||||
|
<span class="text-sm">{$_("admin.common.premium")}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||||
|
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.models.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{$_("admin.video_form.models")}</Label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each data.models as model (model.id)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class={`px-3 py-1.5 h-auto rounded-full text-sm border transition-colors ${
|
||||||
|
selectedModelIds.includes(model.id)
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border/40 text-muted-foreground hover:border-primary/40"
|
||||||
|
}`}
|
||||||
|
onclick={() => toggleModel(model.id)}
|
||||||
|
>
|
||||||
|
{model.artist_name || model.id}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
>
|
>
|
||||||
{saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
|
{saving ? $_("admin.common.creating") : $_("admin.video_form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
let expandedItems = new SvelteSet<number>();
|
// eslint-disable-next-line svelte/no-unnecessary-state-wrap -- variable is reassigned, $state is required
|
||||||
|
let expandedItems = $state(new SvelteSet<number>());
|
||||||
|
|
||||||
const faqCategories = [
|
const faqCategories = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,66 +1,5 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import type { PageServerLoad } from "./$types";
|
|
||||||
import { gql } from "graphql-request";
|
|
||||||
import { getGraphQLClient } from "$lib/api";
|
|
||||||
|
|
||||||
const LEADERBOARD_QUERY = gql`
|
export function load() {
|
||||||
query Leaderboard($limit: Int, $offset: Int) {
|
throw redirect(301, "/play/leaderboard");
|
||||||
leaderboard(limit: $limit, offset: $offset) {
|
}
|
||||||
user_id
|
|
||||||
display_name
|
|
||||||
avatar
|
|
||||||
total_weighted_points
|
|
||||||
total_raw_points
|
|
||||||
recordings_count
|
|
||||||
playbacks_count
|
|
||||||
achievements_count
|
|
||||||
rank
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
|
||||||
// Guard: Redirect to login if not authenticated
|
|
||||||
if (!locals.authStatus.authenticated) {
|
|
||||||
throw redirect(302, "/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const limit = parseInt(url.searchParams.get("limit") || "100");
|
|
||||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
|
||||||
|
|
||||||
const client = getGraphQLClient(fetch);
|
|
||||||
const data = await client.request<{
|
|
||||||
leaderboard: {
|
|
||||||
user_id: string;
|
|
||||||
display_name: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
total_weighted_points: number | null;
|
|
||||||
total_raw_points: number | null;
|
|
||||||
recordings_count: number | null;
|
|
||||||
playbacks_count: number | null;
|
|
||||||
achievements_count: number | null;
|
|
||||||
rank: number;
|
|
||||||
}[];
|
|
||||||
}>(LEADERBOARD_QUERY, { limit, offset });
|
|
||||||
|
|
||||||
return {
|
|
||||||
leaderboard: data.leaderboard || [],
|
|
||||||
pagination: {
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore: data.leaderboard?.length === limit,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Leaderboard load error:", error);
|
|
||||||
return {
|
|
||||||
leaderboard: [],
|
|
||||||
pagination: {
|
|
||||||
limit: 100,
|
|
||||||
offset: 0,
|
|
||||||
hasMore: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
12
packages/frontend/src/routes/me/+layout.server.ts
Normal file
12
packages/frontend/src/routes/me/+layout.server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
import { isModel } from "$lib/api";
|
||||||
|
|
||||||
|
export async function load({ locals }) {
|
||||||
|
if (!locals.authStatus.authenticated) {
|
||||||
|
throw redirect(302, "/login");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
authStatus: locals.authStatus,
|
||||||
|
isModel: isModel(locals.authStatus.user!),
|
||||||
|
};
|
||||||
|
}
|
||||||
112
packages/frontend/src/routes/me/+layout.svelte
Normal file
112
packages/frontend/src/routes/me/+layout.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
|
||||||
|
import { getUserInitials } from "$lib/utils";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
|
||||||
|
const { children, data } = $props();
|
||||||
|
|
||||||
|
const navLinks = $derived([
|
||||||
|
{ name: $_("me.nav.profile"), href: "/me/profile", icon: "icon-[ri--user-line]" },
|
||||||
|
{ name: $_("me.nav.security"), href: "/me/security", icon: "icon-[ri--shield-keyhole-line]" },
|
||||||
|
...(data.isModel
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: $_("me.nav.analytics"),
|
||||||
|
href: "/me/analytics",
|
||||||
|
icon: "icon-[ri--line-chart-line]",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
return page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = $derived(data.authStatus.user!);
|
||||||
|
const avatarUrl = $derived(
|
||||||
|
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
|
||||||
|
);
|
||||||
|
const displayName = $derived(user.artist_name ?? user.email);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<!-- Mobile top nav -->
|
||||||
|
<div class="lg:hidden border-b border-border/40">
|
||||||
|
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
|
||||||
|
<span class="hidden sm:inline">{$_("me.nav.back_mobile")}</span>
|
||||||
|
</a>
|
||||||
|
{#each navLinks as link (link.href)}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class={`shrink-0 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
isActive(link.href)
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class={`${link.icon} h-4 w-4 shrink-0`}></span>
|
||||||
|
<span class="hidden sm:inline">{link.name}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop layout -->
|
||||||
|
<div class="flex min-h-screen">
|
||||||
|
<!-- Sidebar (desktop only) -->
|
||||||
|
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
|
||||||
|
<div class="px-4 py-5 border-b border-border/40">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
|
||||||
|
{$_("me.nav.back_to_site")}
|
||||||
|
</a>
|
||||||
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<Avatar class="h-9 w-9 shrink-0">
|
||||||
|
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||||
|
<AvatarFallback class="text-xs">
|
||||||
|
{getUserInitials(displayName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{$_("me.title")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex-1 p-3 space-y-1">
|
||||||
|
{#each navLinks as link (link.href)}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive(link.href)
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class={`${link.icon} h-4 w-4`}></span>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 min-w-0">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,25 +1,4 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { getAnalytics, getFolders, getRecordings } from "$lib/services";
|
export function load() {
|
||||||
import { isModel } from "$lib/api";
|
throw redirect(302, "/me/profile");
|
||||||
|
|
||||||
export async function load({ locals, fetch }) {
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
if (!locals.authStatus.authenticated) {
|
|
||||||
throw redirect(302, "/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordings = await getRecordings(fetch).catch(() => []);
|
|
||||||
|
|
||||||
const analytics = isModel(locals.authStatus.user!)
|
|
||||||
? await getAnalytics(fetch).catch(() => null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const folders = await getFolders(fetch).catch(() => []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
authStatus: locals.authStatus,
|
|
||||||
folders,
|
|
||||||
recordings,
|
|
||||||
analytics,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,690 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { _ } from "svelte-i18n";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "$lib/components/ui/card";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs";
|
|
||||||
import { Input } from "$lib/components/ui/input";
|
|
||||||
import { Label } from "$lib/components/ui/label";
|
|
||||||
import SexyBackground from "$lib/components/background/background.svelte";
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { goto, invalidateAll } from "$app/navigation";
|
|
||||||
import { getAssetUrl, isModel } from "$lib/api";
|
|
||||||
import * as Alert from "$lib/components/ui/alert";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
|
||||||
import { Textarea } from "$lib/components/ui/textarea";
|
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
|
||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
|
||||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
|
||||||
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
|
|
||||||
|
|
||||||
const { data } = $props();
|
|
||||||
|
|
||||||
let recordings = $state(untrack(() => data.recordings));
|
|
||||||
let deleteTarget = $state<string | null>(null);
|
|
||||||
let deleteOpen = $state(false);
|
|
||||||
let deleting = $state(false);
|
|
||||||
|
|
||||||
let activeTab = $state("settings");
|
|
||||||
|
|
||||||
let firstName = $state(untrack(() => data.authStatus.user!.first_name));
|
|
||||||
let lastName = $state(untrack(() => data.authStatus.user!.last_name));
|
|
||||||
let artistName = $state(untrack(() => data.authStatus.user!.artist_name));
|
|
||||||
let description = $state(untrack(() => data.authStatus.user!.description));
|
|
||||||
let tags = $state(untrack(() => data.authStatus.user!.tags ?? undefined));
|
|
||||||
$effect(() => {
|
|
||||||
recordings = data.recordings;
|
|
||||||
firstName = data.authStatus.user!.first_name;
|
|
||||||
lastName = data.authStatus.user!.last_name;
|
|
||||||
artistName = data.authStatus.user!.artist_name;
|
|
||||||
description = data.authStatus.user!.description;
|
|
||||||
tags = data.authStatus.user!.tags ?? undefined;
|
|
||||||
email = data.authStatus.user!.email;
|
|
||||||
});
|
|
||||||
|
|
||||||
let email = $state(untrack(() => data.authStatus.user!.email));
|
|
||||||
let password = $state("");
|
|
||||||
let confirmPassword = $state("");
|
|
||||||
|
|
||||||
let showPassword = $state(false);
|
|
||||||
let showConfirmPassword = $state(false);
|
|
||||||
|
|
||||||
let isProfileLoading = $state(false);
|
|
||||||
let isProfileError = $state(false);
|
|
||||||
let profileError = $state("");
|
|
||||||
|
|
||||||
let isSecurityLoading = $state(false);
|
|
||||||
let isSecurityError = $state(false);
|
|
||||||
let securityError = $state("");
|
|
||||||
|
|
||||||
async function handleProfileSubmit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
isProfileLoading = true;
|
|
||||||
isProfileError = false;
|
|
||||||
profileError = "";
|
|
||||||
|
|
||||||
let avatarId: string | null | undefined = undefined;
|
|
||||||
|
|
||||||
if (!avatar?.id && data.authStatus.user!.avatar) {
|
|
||||||
// User removed their avatar
|
|
||||||
await removeFile(data.authStatus.user!.avatar);
|
|
||||||
avatarId = null;
|
|
||||||
} else if (avatar?.id) {
|
|
||||||
// Keep existing avatar
|
|
||||||
avatarId = avatar.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (avatar?.file) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", avatar.file);
|
|
||||||
const result = await uploadFile(formData);
|
|
||||||
avatarId = result.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateProfile({
|
|
||||||
first_name: firstName,
|
|
||||||
last_name: lastName,
|
|
||||||
artist_name: artistName,
|
|
||||||
description,
|
|
||||||
tags,
|
|
||||||
avatar: avatarId ?? undefined,
|
|
||||||
});
|
|
||||||
toast.success($_("me.settings.toast_update"));
|
|
||||||
invalidateAll();
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
|
|
||||||
profileError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
|
|
||||||
isProfileError = true;
|
|
||||||
} finally {
|
|
||||||
isProfileLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSecuritySubmit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
throw new Error($_("me.settings.password_error"));
|
|
||||||
}
|
|
||||||
isSecurityLoading = true;
|
|
||||||
isSecurityError = false;
|
|
||||||
securityError = "";
|
|
||||||
await updateProfile({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
toast.success($_("me.settings.toast_update"));
|
|
||||||
invalidateAll();
|
|
||||||
password = confirmPassword = "";
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
|
|
||||||
securityError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
|
|
||||||
isSecurityError = true;
|
|
||||||
} finally {
|
|
||||||
isSecurityLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let avatar = $state<{
|
|
||||||
id?: string;
|
|
||||||
url: string;
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
file?: File;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
async function handleFilesUpload(files: File[]) {
|
|
||||||
const file = files[0];
|
|
||||||
avatar = {
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
url: URL.createObjectURL(file),
|
|
||||||
file,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAvatarRemove() {
|
|
||||||
if (avatar!.id) {
|
|
||||||
avatar = undefined;
|
|
||||||
} else {
|
|
||||||
setExistingAvatar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setExistingAvatar() {
|
|
||||||
if (data.authStatus.user!.avatar) {
|
|
||||||
avatar = {
|
|
||||||
id: data.authStatus.user!.avatar,
|
|
||||||
url: getAssetUrl(data.authStatus.user!.avatar, "thumbnail")!,
|
|
||||||
name: data.authStatus.user!.artist_name ?? "",
|
|
||||||
size: 0,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
avatar = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteRecording(id: string) {
|
|
||||||
deleteTarget = id;
|
|
||||||
deleteOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDeleteRecording() {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
deleting = true;
|
|
||||||
try {
|
|
||||||
await deleteRecording(deleteTarget);
|
|
||||||
recordings = recordings.filter((r) => r.id !== deleteTarget);
|
|
||||||
toast.success($_("me.recordings.delete_success"));
|
|
||||||
deleteOpen = false;
|
|
||||||
deleteTarget = null;
|
|
||||||
} catch {
|
|
||||||
toast.error($_("me.recordings.delete_error"));
|
|
||||||
} finally {
|
|
||||||
deleting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePlayRecording(id: string) {
|
|
||||||
// Navigate to play page with recording ID
|
|
||||||
goto(`/play?recording=${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (data.authStatus.authenticated) {
|
|
||||||
setExistingAvatar();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goto("/login");
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Meta
|
|
||||||
title={$_("me.title")}
|
|
||||||
description={$_("me.welcome", {
|
|
||||||
values: { name: data.authStatus.user!.artist_name },
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
|
||||||
>
|
|
||||||
<SexyBackground />
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">{$_("me.title")}</h1>
|
|
||||||
<p class="text-sm text-muted-foreground mt-0.5">
|
|
||||||
{$_("me.welcome", { values: { name: data.authStatus.user!.artist_name } })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{#if isModel(data.authStatus.user!)}
|
|
||||||
<Button href={`/models/${data.authStatus.user!.slug}`} variant="outline">
|
|
||||||
{$_("me.view_profile")}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dashboard Tabs -->
|
|
||||||
<Tabs bind:value={activeTab} class="w-full">
|
|
||||||
<TabsList class="grid w-full {data.analytics ? 'grid-cols-3' : 'grid-cols-2'} max-w-2xl mb-8">
|
|
||||||
<TabsTrigger value="settings" class="flex items-center gap-2">
|
|
||||||
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
|
|
||||||
{$_("me.settings.title")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="recordings" class="flex items-center gap-2">
|
|
||||||
<span class="icon-[ri--play-list-2-line] w-4 h-4"></span>
|
|
||||||
{$_("me.recordings.title")}
|
|
||||||
</TabsTrigger>
|
|
||||||
{#if data.analytics}
|
|
||||||
<TabsTrigger value="analytics" class="flex items-center gap-2">
|
|
||||||
<span class="icon-[ri--line-chart-line] w-4 h-4"></span>
|
|
||||||
Analytics
|
|
||||||
</TabsTrigger>
|
|
||||||
{/if}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
|
||||||
<TabsContent value="settings" class="space-y-6">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<!-- Profile Settings -->
|
|
||||||
<Card class="bg-card/50 border-primary/20">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{$_("me.settings.profile_title")}</CardTitle>
|
|
||||||
<CardDescription>{$_("me.settings.profile_subtitle")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<form onsubmit={handleProfileSubmit} class="space-y-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{$_("me.settings.avatar")}</Label>
|
|
||||||
<div class="flex items-center gap-5">
|
|
||||||
<FileDropZone
|
|
||||||
id="avatar"
|
|
||||||
fileCount={0}
|
|
||||||
maxFiles={1}
|
|
||||||
maxFileSize={2 * MEGABYTE}
|
|
||||||
onUpload={handleFilesUpload}
|
|
||||||
accept="image/*"
|
|
||||||
class="h-auto w-auto shrink-0 border-none p-0 rounded-full hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<div class="relative group cursor-pointer w-24 h-24">
|
|
||||||
{#if avatar}
|
|
||||||
<img
|
|
||||||
src={avatar.url}
|
|
||||||
alt={avatar.name}
|
|
||||||
class="w-24 h-24 rounded-full object-cover ring-4 ring-primary/20 group-hover:ring-primary/50 transition-all"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--camera-line] w-7 h-7 text-white"></span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="w-24 h-24 rounded-full border-2 border-dashed border-primary/30 group-hover:border-primary/60 bg-primary/5 group-hover:bg-primary/10 transition-all flex flex-col items-center justify-center gap-1"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="icon-[ri--camera-line] w-7 h-7 text-primary/50 group-hover:text-primary/80 transition-colors"
|
|
||||||
></span>
|
|
||||||
<span class="text-xs text-muted-foreground">Upload</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</FileDropZone>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<p class="text-sm text-muted-foreground">JPG, PNG · max 2 MB</p>
|
|
||||||
<p class="text-xs text-muted-foreground/70">
|
|
||||||
Click or drop to {avatar ? "change" : "upload"}
|
|
||||||
</p>
|
|
||||||
{#if avatar}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={handleAvatarRemove}
|
|
||||||
class="cursor-pointer w-fit mt-1 px-2 h-7 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--delete-bin-line] w-3.5 h-3.5 mr-1"></span>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Name Fields -->
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="firstName">{$_("me.settings.first_name")}</Label>
|
|
||||||
<Input
|
|
||||||
id="firstName"
|
|
||||||
placeholder={$_("me.settings.first_name_placeholder")}
|
|
||||||
bind:value={firstName}
|
|
||||||
required
|
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="lastName">{$_("me.settings.last_name")}</Label>
|
|
||||||
<Input
|
|
||||||
id="lastName"
|
|
||||||
placeholder={$_("me.settings.last_name_placeholder")}
|
|
||||||
bind:value={lastName}
|
|
||||||
required
|
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
|
|
||||||
<Input
|
|
||||||
id="artistName"
|
|
||||||
placeholder={$_("me.settings.artist_name_placeholder")}
|
|
||||||
bind:value={artistName}
|
|
||||||
required
|
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="description">{$_("me.settings.description")}</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
bind:value={description}
|
|
||||||
placeholder={$_("me.settings.description_placeholder")}
|
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="tags">{$_("me.settings.tags")}</Label>
|
|
||||||
<TagsInput
|
|
||||||
id="tags"
|
|
||||||
bind:value={tags}
|
|
||||||
placeholder={$_("me.settings.tags_placeholder")}
|
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if isProfileError}
|
|
||||||
<div class="grid w-full items-start gap-4">
|
|
||||||
<Alert.Root variant="destructive">
|
|
||||||
<Alert.Title class="items-center flex"
|
|
||||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
|
||||||
"me.settings.error",
|
|
||||||
)}</Alert.Title
|
|
||||||
>
|
|
||||||
<Alert.Description>{profileError}</Alert.Description>
|
|
||||||
</Alert.Root>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
|
||||||
disabled={isProfileLoading}
|
|
||||||
>
|
|
||||||
{#if isProfileLoading}
|
|
||||||
<div
|
|
||||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
|
||||||
></div>
|
|
||||||
{$_("me.settings.updating_profile")}
|
|
||||||
{:else}
|
|
||||||
{$_("me.settings.update_profile")}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Privacy Settings -->
|
|
||||||
<Card class="bg-card/50 border-primary/20">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{$_("me.settings.privacy_title")}</CardTitle>
|
|
||||||
<CardDescription>{$_("me.settings.privacy_subtitle")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<form onsubmit={handleSecuritySubmit} class="space-y-4">
|
|
||||||
<!-- Email -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="email">{$_("me.settings.email")}</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder={$_("me.settings.email_placeholder")}
|
|
||||||
bind:value={email}
|
|
||||||
required
|
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Password -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="password">{$_("me.settings.password")}</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder={$_("me.settings.password_placeholder")}
|
|
||||||
bind:value={password}
|
|
||||||
required
|
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (showPassword = !showPassword)}
|
|
||||||
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{#if showPassword}
|
|
||||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
|
||||||
{:else}
|
|
||||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirm Password -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="confirmPassword">{$_("me.settings.confirm_password")}</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="confirmPassword"
|
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
|
||||||
placeholder={$_("me.settings.confirm_password_placeholder")}
|
|
||||||
bind:value={confirmPassword}
|
|
||||||
required
|
|
||||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
|
||||||
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{#if showConfirmPassword}
|
|
||||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
|
||||||
{:else}
|
|
||||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if isSecurityError}
|
|
||||||
<div class="grid w-full items-start gap-4">
|
|
||||||
<Alert.Root variant="destructive">
|
|
||||||
<Alert.Title class="items-center flex"
|
|
||||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
|
||||||
"me.settings.error",
|
|
||||||
)}</Alert.Title
|
|
||||||
>
|
|
||||||
<Alert.Description>{securityError}</Alert.Description>
|
|
||||||
</Alert.Root>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="submit"
|
|
||||||
class="cursor-pointer w-full border-primary/20 hover:bg-primary/10"
|
|
||||||
disabled={isSecurityLoading}
|
|
||||||
>
|
|
||||||
{#if isSecurityLoading}
|
|
||||||
<div
|
|
||||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
|
||||||
></div>
|
|
||||||
{$_("me.settings.updating_security")}
|
|
||||||
{:else}
|
|
||||||
{$_("me.settings.update_security")}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<!-- Recordings Tab -->
|
|
||||||
<TabsContent value="recordings" class="space-y-6">
|
|
||||||
<div class="mb-6 flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-card-foreground">
|
|
||||||
{$_("me.recordings.title")}
|
|
||||||
</h2>
|
|
||||||
<p class="text-muted-foreground">
|
|
||||||
{$_("me.recordings.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
href="/play"
|
|
||||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
|
|
||||||
{$_("me.recordings.go_to_play")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if recordings.length === 0}
|
|
||||||
<Card class="bg-card/50 border-primary/20">
|
|
||||||
<CardContent class="py-12">
|
|
||||||
<div class="flex flex-col items-center justify-center text-center">
|
|
||||||
<div class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30">
|
|
||||||
<span class="icon-[ri--play-list-2-line] w-12 h-12 text-muted-foreground"></span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-semibold mb-2">
|
|
||||||
{$_("me.recordings.no_recordings")}
|
|
||||||
</h3>
|
|
||||||
<p class="text-muted-foreground mb-6 max-w-md">
|
|
||||||
{$_("me.recordings.no_recordings_description")}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
href="/play"
|
|
||||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
|
|
||||||
{$_("me.recordings.go_to_play")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{:else}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{#each recordings as recording (recording.id)}
|
|
||||||
<RecordingCard
|
|
||||||
{recording}
|
|
||||||
onPlay={handlePlayRecording}
|
|
||||||
onDelete={handleDeleteRecording}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<!-- Analytics Tab -->
|
|
||||||
{#if data.analytics}
|
|
||||||
<TabsContent value="analytics" class="space-y-6">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h2 class="text-2xl font-bold text-card-foreground">Analytics Dashboard</h2>
|
|
||||||
<p class="text-muted-foreground">
|
|
||||||
Track your content performance and audience engagement
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overview Stats -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<Card class="bg-card/50 border-primary/20">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="flex items-center gap-2">
|
|
||||||
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
|
|
||||||
Total Videos
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card class="bg-card/50 border-primary/20">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="flex items-center gap-2">
|
|
||||||
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
|
|
||||||
Total Likes
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card class="bg-card/50 border-primary/20">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="flex items-center gap-2">
|
|
||||||
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
|
|
||||||
Total Plays
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Video Performance Table -->
|
|
||||||
<Card class="bg-card/50 border-primary/20">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Video Performance</CardTitle>
|
|
||||||
<CardDescription>Detailed metrics for each video</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-border">
|
|
||||||
<th class="text-left p-3">Title</th>
|
|
||||||
<th class="text-right p-3">Likes</th>
|
|
||||||
<th class="text-right p-3">Plays</th>
|
|
||||||
<th class="text-right p-3">Completion Rate</th>
|
|
||||||
<th class="text-right p-3">Avg Watch Time</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each data.analytics.videos as video (video.slug)}
|
|
||||||
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
|
|
||||||
<td class="p-3">
|
|
||||||
<a
|
|
||||||
href="/videos/{video.slug}"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{video.title}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="text-right p-3 font-medium">
|
|
||||||
{video.likes}
|
|
||||||
</td>
|
|
||||||
<td class="text-right p-3 font-medium">
|
|
||||||
{video.plays}
|
|
||||||
</td>
|
|
||||||
<td class="text-right p-3">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >=
|
|
||||||
70
|
|
||||||
? 'bg-green-500/20 text-green-500'
|
|
||||||
: video.completion_rate >= 40
|
|
||||||
? 'bg-yellow-500/20 text-yellow-500'
|
|
||||||
: 'bg-red-500/20 text-red-500'}"
|
|
||||||
>
|
|
||||||
{video.completion_rate.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-right p-3 text-muted-foreground">
|
|
||||||
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
{/if}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog.Root bind:open={deleteOpen}>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
|
|
||||||
<Dialog.Description>This cannot be undone.</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
|
||||||
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
|
|
||||||
{deleting ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|||||||
12
packages/frontend/src/routes/me/analytics/+page.server.ts
Normal file
12
packages/frontend/src/routes/me/analytics/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
import { isModel } from "$lib/api";
|
||||||
|
import { getAnalytics } from "$lib/services";
|
||||||
|
|
||||||
|
export async function load({ locals, fetch }) {
|
||||||
|
if (!isModel(locals.authStatus.user!)) {
|
||||||
|
throw redirect(302, "/me/profile");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
analytics: await getAnalytics(fetch).catch(() => null),
|
||||||
|
};
|
||||||
|
}
|
||||||
138
packages/frontend/src/routes/me/analytics/+page.svelte
Normal file
138
packages/frontend/src/routes/me/analytics/+page.svelte
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta title={$_("me.analytics.title")} />
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{$_("me.analytics.title")}</h1>
|
||||||
|
<p class="text-sm text-muted-foreground mt-0.5">{$_("me.analytics.description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#if data.analytics}
|
||||||
|
<!-- Overview Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card class="bg-card/50 border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
|
||||||
|
{$_("me.analytics.total_videos")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="bg-card/50 border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
|
||||||
|
{$_("me.analytics.total_likes")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="bg-card/50 border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
|
||||||
|
{$_("me.analytics.total_plays")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Performance Table -->
|
||||||
|
<Card class="bg-card/50 border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{$_("me.analytics.video_performance")}</CardTitle>
|
||||||
|
<CardDescription>{$_("me.analytics.video_performance_description")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border">
|
||||||
|
<th class="text-left p-3">Title</th>
|
||||||
|
<th class="text-right p-3">Likes</th>
|
||||||
|
<th class="text-right p-3">Plays</th>
|
||||||
|
<th class="text-right p-3">Completion Rate</th>
|
||||||
|
<th class="text-right p-3">Avg Watch Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.analytics.videos as video (video.slug)}
|
||||||
|
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
|
||||||
|
<td class="p-3">
|
||||||
|
<a href="/videos/{video.slug}" class="hover:text-primary transition-colors">
|
||||||
|
{video.title}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="text-right p-3 font-medium">
|
||||||
|
{video.likes}
|
||||||
|
</td>
|
||||||
|
<td class="text-right p-3 font-medium">
|
||||||
|
{video.plays}
|
||||||
|
</td>
|
||||||
|
<td class="text-right p-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >=
|
||||||
|
70
|
||||||
|
? 'bg-green-500/20 text-green-500'
|
||||||
|
: video.completion_rate >= 40
|
||||||
|
? 'bg-yellow-500/20 text-yellow-500'
|
||||||
|
: 'bg-red-500/20 text-red-500'}"
|
||||||
|
>
|
||||||
|
{video.completion_rate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right p-3 text-muted-foreground">
|
||||||
|
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<Card class="bg-card/50 border-primary/20">
|
||||||
|
<CardContent class="py-12">
|
||||||
|
<div class="flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30">
|
||||||
|
<span class="icon-[ri--line-chart-line] w-12 h-12 text-muted-foreground"></span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">No analytics available</h3>
|
||||||
|
<p class="text-muted-foreground max-w-md">
|
||||||
|
Analytics data will appear here once your content starts getting views.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
284
packages/frontend/src/routes/me/profile/+page.svelte
Normal file
284
packages/frontend/src/routes/me/profile/+page.svelte
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { invalidateAll } from "$app/navigation";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { updateProfile, uploadFile, removeFile } from "$lib/services";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
|
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||||
|
import * as Alert from "$lib/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let firstName = $state(untrack(() => data.authStatus.user!.first_name));
|
||||||
|
let lastName = $state(untrack(() => data.authStatus.user!.last_name));
|
||||||
|
let artistName = $state(untrack(() => data.authStatus.user!.artist_name));
|
||||||
|
let description = $state(untrack(() => data.authStatus.user!.description));
|
||||||
|
let tags = $state(untrack(() => data.authStatus.user!.tags ?? undefined));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
firstName = data.authStatus.user!.first_name;
|
||||||
|
lastName = data.authStatus.user!.last_name;
|
||||||
|
artistName = data.authStatus.user!.artist_name;
|
||||||
|
description = data.authStatus.user!.description;
|
||||||
|
tags = data.authStatus.user!.tags ?? undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
let isProfileLoading = $state(false);
|
||||||
|
let isProfileError = $state(false);
|
||||||
|
let profileError = $state("");
|
||||||
|
|
||||||
|
let avatar = $state<{
|
||||||
|
id?: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
file?: File;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function setExistingAvatar() {
|
||||||
|
if (data.authStatus.user!.avatar) {
|
||||||
|
avatar = {
|
||||||
|
id: data.authStatus.user!.avatar,
|
||||||
|
url: getAssetUrl(data.authStatus.user!.avatar, "thumbnail")!,
|
||||||
|
name: data.authStatus.user!.artist_name ?? "",
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
avatar = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
setExistingAvatar();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleFilesUpload(files: File[]) {
|
||||||
|
const file = files[0];
|
||||||
|
avatar = {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvatarRemove() {
|
||||||
|
if (avatar!.id) {
|
||||||
|
avatar = undefined;
|
||||||
|
} else {
|
||||||
|
setExistingAvatar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProfileSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
isProfileLoading = true;
|
||||||
|
isProfileError = false;
|
||||||
|
profileError = "";
|
||||||
|
|
||||||
|
let avatarId: string | null | undefined = undefined;
|
||||||
|
|
||||||
|
if (!avatar?.id && data.authStatus.user!.avatar) {
|
||||||
|
await removeFile(data.authStatus.user!.avatar);
|
||||||
|
avatarId = null;
|
||||||
|
} else if (avatar?.id) {
|
||||||
|
avatarId = avatar.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avatar?.file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", avatar.file);
|
||||||
|
const result = await uploadFile(formData);
|
||||||
|
avatarId = result.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProfile({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
artist_name: artistName,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
avatar: avatarId ?? undefined,
|
||||||
|
});
|
||||||
|
toast.success($_("me.settings.toast_update"));
|
||||||
|
invalidateAll();
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
|
||||||
|
profileError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
|
||||||
|
isProfileError = true;
|
||||||
|
} finally {
|
||||||
|
isProfileLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta title={$_("me.settings.profile_title")} />
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">{$_("me.settings.profile_title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{$_("me.settings.profile_title")}</CardTitle>
|
||||||
|
<CardDescription>{$_("me.settings.profile_subtitle")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<form onsubmit={handleProfileSubmit} class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{$_("me.settings.avatar")}</Label>
|
||||||
|
<div class="flex items-center gap-5">
|
||||||
|
<FileDropZone
|
||||||
|
id="avatar"
|
||||||
|
fileCount={0}
|
||||||
|
maxFiles={1}
|
||||||
|
maxFileSize={2 * MEGABYTE}
|
||||||
|
onUpload={handleFilesUpload}
|
||||||
|
accept="image/*"
|
||||||
|
class="h-auto w-auto shrink-0 border-none p-0 rounded-full hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<div class="relative group cursor-pointer w-24 h-24">
|
||||||
|
{#if avatar}
|
||||||
|
<img
|
||||||
|
src={avatar.url}
|
||||||
|
alt={avatar.name}
|
||||||
|
class="w-24 h-24 rounded-full object-cover ring-4 ring-primary/20 group-hover:ring-primary/50 transition-all"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--camera-line] w-7 h-7 text-white"></span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 rounded-full border-2 border-dashed border-primary/30 group-hover:border-primary/60 bg-primary/5 group-hover:bg-primary/10 transition-all flex flex-col items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--camera-line] w-7 h-7 text-primary/50 group-hover:text-primary/80 transition-colors"
|
||||||
|
></span>
|
||||||
|
<span class="text-xs text-muted-foreground">Upload</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</FileDropZone>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<p class="text-sm text-muted-foreground">JPG, PNG · max 2 MB</p>
|
||||||
|
<p class="text-xs text-muted-foreground/70">
|
||||||
|
Click or drop to {avatar ? "change" : "upload"}
|
||||||
|
</p>
|
||||||
|
{#if avatar}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={handleAvatarRemove}
|
||||||
|
class="cursor-pointer w-fit mt-1 px-2 h-7 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] w-3.5 h-3.5 mr-1"></span>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="firstName">{$_("me.settings.first_name")}</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
placeholder={$_("me.settings.first_name_placeholder")}
|
||||||
|
bind:value={firstName}
|
||||||
|
required
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="lastName">{$_("me.settings.last_name")}</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
placeholder={$_("me.settings.last_name_placeholder")}
|
||||||
|
bind:value={lastName}
|
||||||
|
required
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
|
||||||
|
<Input
|
||||||
|
id="artistName"
|
||||||
|
placeholder={$_("me.settings.artist_name_placeholder")}
|
||||||
|
bind:value={artistName}
|
||||||
|
required
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="description">{$_("me.settings.description")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder={$_("me.settings.description_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tags">{$_("me.settings.tags")}</Label>
|
||||||
|
<TagsInput
|
||||||
|
id="tags"
|
||||||
|
bind:value={tags}
|
||||||
|
placeholder={$_("me.settings.tags_placeholder")}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isProfileError}
|
||||||
|
<div class="grid w-full items-start gap-4">
|
||||||
|
<Alert.Root variant="destructive">
|
||||||
|
<Alert.Title class="items-center flex">
|
||||||
|
<span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>
|
||||||
|
{$_("me.settings.error")}
|
||||||
|
</Alert.Title>
|
||||||
|
<Alert.Description>{profileError}</Alert.Description>
|
||||||
|
</Alert.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
disabled={isProfileLoading}
|
||||||
|
>
|
||||||
|
{#if isProfileLoading}
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||||
|
></div>
|
||||||
|
{$_("me.settings.updating_profile")}
|
||||||
|
{:else}
|
||||||
|
{$_("me.settings.update_profile")}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
throw redirect(301, "/play/recordings");
|
||||||
|
}
|
||||||
122
packages/frontend/src/routes/me/recordings/+page.svelte
Normal file
122
packages/frontend/src/routes/me/recordings/+page.svelte
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { deleteRecording, updateRecording } from "$lib/services";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Empty from "$lib/components/ui/empty";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let recordings = $state(untrack(() => data.recordings));
|
||||||
|
let deleteTarget = $state<string | null>(null);
|
||||||
|
let deleteOpen = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
function handleDeleteRecording(id: string) {
|
||||||
|
deleteTarget = id;
|
||||||
|
deleteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteRecording() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await deleteRecording(deleteTarget);
|
||||||
|
recordings = recordings.filter((r) => r.id !== deleteTarget);
|
||||||
|
toast.success($_("me.recordings.delete_success"));
|
||||||
|
deleteOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
} catch {
|
||||||
|
toast.error($_("me.recordings.delete_error"));
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublishRecording(id: string) {
|
||||||
|
try {
|
||||||
|
await updateRecording(id, { status: "published" });
|
||||||
|
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "published" } : r));
|
||||||
|
toast.success($_("me.recordings.publish_success"));
|
||||||
|
} catch {
|
||||||
|
toast.error($_("me.recordings.publish_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnpublishRecording(id: string) {
|
||||||
|
try {
|
||||||
|
await updateRecording(id, { status: "draft" });
|
||||||
|
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "draft" } : r));
|
||||||
|
toast.success($_("me.recordings.unpublish_success"));
|
||||||
|
} catch {
|
||||||
|
toast.error($_("me.recordings.unpublish_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayRecording(id: string) {
|
||||||
|
goto(`/play?recording=${id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta title={$_("me.recordings.title")} />
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recordings.length === 0}
|
||||||
|
<Empty.Root>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Media variant="icon">
|
||||||
|
<span class="icon-[ri--play-list-2-line] w-8 h-8"></span>
|
||||||
|
</Empty.Media>
|
||||||
|
<Empty.Title>{$_("me.recordings.no_recordings")}</Empty.Title>
|
||||||
|
<Empty.Description>{$_("me.recordings.no_recordings_description")}</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
<Empty.Content>
|
||||||
|
<Button
|
||||||
|
href="/play"
|
||||||
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
|
||||||
|
{$_("me.recordings.go_to_play")}
|
||||||
|
</Button>
|
||||||
|
</Empty.Content>
|
||||||
|
</Empty.Root>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{#each recordings as recording (recording.id)}
|
||||||
|
<RecordingCard
|
||||||
|
{recording}
|
||||||
|
onPlay={handlePlayRecording}
|
||||||
|
onPublish={handlePublishRecording}
|
||||||
|
onUnpublish={handleUnpublishRecording}
|
||||||
|
onDelete={handleDeleteRecording}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
|
||||||
|
<Dialog.Description>This cannot be undone.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (deleteOpen = false)}>
|
||||||
|
{$_("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
|
||||||
|
{deleting ? "Deleting…" : $_("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
163
packages/frontend/src/routes/me/security/+page.svelte
Normal file
163
packages/frontend/src/routes/me/security/+page.svelte
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { invalidateAll } from "$app/navigation";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { updateProfile } from "$lib/services";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import * as Alert from "$lib/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let email = $state(untrack(() => data.authStatus.user!.email));
|
||||||
|
let password = $state("");
|
||||||
|
let confirmPassword = $state("");
|
||||||
|
let showPassword = $state(false);
|
||||||
|
let showConfirmPassword = $state(false);
|
||||||
|
|
||||||
|
let isSecurityLoading = $state(false);
|
||||||
|
let isSecurityError = $state(false);
|
||||||
|
let securityError = $state("");
|
||||||
|
|
||||||
|
async function handleSecuritySubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
throw new Error($_("me.settings.password_error"));
|
||||||
|
}
|
||||||
|
isSecurityLoading = true;
|
||||||
|
isSecurityError = false;
|
||||||
|
securityError = "";
|
||||||
|
await updateProfile({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
toast.success($_("me.settings.toast_update"));
|
||||||
|
invalidateAll();
|
||||||
|
password = confirmPassword = "";
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
|
||||||
|
securityError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
|
||||||
|
isSecurityError = true;
|
||||||
|
} finally {
|
||||||
|
isSecurityLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta title={$_("me.settings.privacy_title")} />
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">{$_("me.settings.privacy_title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{$_("me.settings.privacy_title")}</CardTitle>
|
||||||
|
<CardDescription>{$_("me.settings.privacy_subtitle")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<form onsubmit={handleSecuritySubmit} class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="email">{$_("me.settings.email")}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder={$_("me.settings.email_placeholder")}
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">{$_("me.settings.password")}</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder={$_("me.settings.password_placeholder")}
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showPassword = !showPassword)}
|
||||||
|
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{#if showPassword}
|
||||||
|
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||||
|
{:else}
|
||||||
|
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="confirmPassword">{$_("me.settings.confirm_password")}</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
|
placeholder={$_("me.settings.confirm_password_placeholder")}
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||||
|
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{#if showConfirmPassword}
|
||||||
|
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||||
|
{:else}
|
||||||
|
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isSecurityError}
|
||||||
|
<div class="grid w-full items-start gap-4">
|
||||||
|
<Alert.Root variant="destructive">
|
||||||
|
<Alert.Title class="items-center flex">
|
||||||
|
<span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>
|
||||||
|
{$_("me.settings.error")}
|
||||||
|
</Alert.Title>
|
||||||
|
<Alert.Description>{securityError}</Alert.Description>
|
||||||
|
</Alert.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
disabled={isSecurityLoading}
|
||||||
|
>
|
||||||
|
{#if isSecurityLoading}
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||||
|
></div>
|
||||||
|
{$_("me.settings.updating_security")}
|
||||||
|
{:else}
|
||||||
|
{$_("me.settings.update_security")}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
8
packages/frontend/src/routes/play/+layout.server.ts
Normal file
8
packages/frontend/src/routes/play/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export async function load({ locals }) {
|
||||||
|
if (!locals.authStatus.authenticated) {
|
||||||
|
throw redirect(302, "/login");
|
||||||
|
}
|
||||||
|
return { authStatus: locals.authStatus };
|
||||||
|
}
|
||||||
123
packages/frontend/src/routes/play/+layout.svelte
Normal file
123
packages/frontend/src/routes/play/+layout.svelte
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
|
||||||
|
import { getUserInitials } from "$lib/utils";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
import SexyBackground from "$lib/components/background/background.svelte";
|
||||||
|
|
||||||
|
const { children, data } = $props();
|
||||||
|
|
||||||
|
const navLinks = $derived([
|
||||||
|
{
|
||||||
|
name: $_("play.nav.play"),
|
||||||
|
href: "/play/buttplug",
|
||||||
|
icon: "icon-[ri--rocket-line]",
|
||||||
|
exact: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: $_("play.nav.recordings"),
|
||||||
|
href: "/play/recordings",
|
||||||
|
icon: "icon-[ri--play-list-2-line]",
|
||||||
|
exact: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: $_("play.nav.leaderboard"),
|
||||||
|
href: "/play/leaderboard",
|
||||||
|
icon: "icon-[ri--trophy-line]",
|
||||||
|
exact: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isActive(link: { href: string; exact: boolean }) {
|
||||||
|
if (link.exact) return page.url.pathname === link.href;
|
||||||
|
return page.url.pathname.startsWith(link.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = $derived(data.authStatus.user!);
|
||||||
|
const avatarUrl = $derived(
|
||||||
|
user.avatar ? (getAssetUrl(user.avatar, "thumbnail") ?? undefined) : undefined,
|
||||||
|
);
|
||||||
|
const displayName = $derived(user.artist_name ?? user.email);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 relative">
|
||||||
|
<SexyBackground />
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 relative z-10">
|
||||||
|
<!-- Mobile top nav -->
|
||||||
|
<div class="lg:hidden border-b border-border/40">
|
||||||
|
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
|
||||||
|
<span class="hidden sm:inline">{$_("play.nav.back_mobile")}</span>
|
||||||
|
</a>
|
||||||
|
{#each navLinks as link (link.href)}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class={`shrink-0 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
isActive(link)
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class={`${link.icon} h-4 w-4 shrink-0`}></span>
|
||||||
|
<span class="hidden sm:inline">{link.name}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop layout -->
|
||||||
|
<div class="flex min-h-screen">
|
||||||
|
<!-- Sidebar (desktop only) -->
|
||||||
|
<aside class="hidden lg:flex w-56 shrink-0 flex-col border-r border-border/40">
|
||||||
|
<div class="px-4 py-5 border-b border-border/40">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--arrow-left-line] h-3.5 w-3.5"></span>
|
||||||
|
{$_("play.nav.back_to_site")}
|
||||||
|
</a>
|
||||||
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<Avatar class="h-9 w-9 shrink-0">
|
||||||
|
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||||
|
<AvatarFallback class="text-xs">
|
||||||
|
{getUserInitials(displayName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-foreground truncate">{displayName}</p>
|
||||||
|
<p class="text-xs text-primary">{$_("play.title")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex-1 p-3 space-y-1">
|
||||||
|
{#each navLinks as link (link.href)}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive(link)
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class={`${link.icon} h-4 w-4`}></span>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 min-w-0">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import { getRecording } from "$lib/services";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import type { Recording } from "$lib/types";
|
|
||||||
|
|
||||||
export async function load({ locals, url, fetch }) {
|
export function load() {
|
||||||
const recordingId = url.searchParams.get("recording");
|
throw redirect(302, "/play/buttplug");
|
||||||
|
|
||||||
let recording: Recording | null = null;
|
|
||||||
if (recordingId && locals.authStatus.authenticated) {
|
|
||||||
try {
|
|
||||||
recording = await getRecording(recordingId, fetch);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load recording:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
authStatus: locals.authStatus,
|
|
||||||
recording,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,615 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { _ } from "svelte-i18n";
|
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
|
||||||
import {
|
|
||||||
ButtplugClient,
|
|
||||||
ButtplugWasmClientConnector,
|
|
||||||
type ButtplugClientDevice,
|
|
||||||
type OutputType,
|
|
||||||
InputType,
|
|
||||||
DeviceOutputValueConstructor,
|
|
||||||
} from "@sexy.pivoine.art/buttplug";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import DeviceCard from "$lib/components/device-card/device-card.svelte";
|
|
||||||
import RecordingSaveDialog from "./components/recording-save-dialog.svelte";
|
|
||||||
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
|
|
||||||
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import SexyBackground from "$lib/components/background/background.svelte";
|
|
||||||
|
|
||||||
const client = new ButtplugClient("Sexy.Art");
|
|
||||||
let connected = $state(client.connected);
|
|
||||||
let scanning = $state(false);
|
|
||||||
let devices = $state<BluetoothDevice[]>([]);
|
|
||||||
|
|
||||||
// Recording state
|
|
||||||
let isRecording = $state(false);
|
|
||||||
let recordingStartTime = $state<number | null>(null);
|
|
||||||
let recordedEvents = $state<RecordedEvent[]>([]);
|
|
||||||
let showSaveDialog = $state(false);
|
|
||||||
let recordingDuration = $state(0);
|
|
||||||
|
|
||||||
// Playback state
|
|
||||||
let isPlaying = $state(false);
|
|
||||||
let playbackProgress = $state(0);
|
|
||||||
let playbackStartTime = $state<number | null>(null);
|
|
||||||
let playbackTimeoutId = $state<number | null>(null);
|
|
||||||
let currentEventIndex = $state(0);
|
|
||||||
let showMappingDialog = $state(false);
|
|
||||||
let deviceMappings = $state<Map<string, BluetoothDevice>>(new Map());
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const connector = new ButtplugWasmClientConnector();
|
|
||||||
// await ButtplugWasmClientConnector.activateLogging("info");
|
|
||||||
await client.connect(connector);
|
|
||||||
client.on("deviceadded", onDeviceAdded);
|
|
||||||
client.on("deviceremoved", (dev: ButtplugClientDevice) => {
|
|
||||||
const idx = devices.findIndex((d) => d.info.index === dev.index);
|
|
||||||
if (idx !== -1) devices.splice(idx, 1);
|
|
||||||
});
|
|
||||||
client.on("scanningfinished", () => (scanning = false));
|
|
||||||
client.on("inputreading", handleInputReading);
|
|
||||||
connected = client.connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startScanning() {
|
|
||||||
await client.startScanning();
|
|
||||||
scanning = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeviceAdded(dev: ButtplugClientDevice) {
|
|
||||||
const device = convertDevice(dev);
|
|
||||||
devices.push(device);
|
|
||||||
|
|
||||||
// Try to read battery level — access through the reactive array so Svelte detects the mutation
|
|
||||||
const idx = devices.length - 1;
|
|
||||||
if (device.hasBattery) {
|
|
||||||
try {
|
|
||||||
devices[idx].batteryLevel = await dev.battery();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to read battery for ${dev.name}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function handleInputReading(msg: any) {
|
|
||||||
if (msg.InputReading === undefined) return;
|
|
||||||
const reading = msg.InputReading;
|
|
||||||
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
|
|
||||||
if (!device) return;
|
|
||||||
|
|
||||||
if (reading.Reading[InputType.Battery] !== undefined) {
|
|
||||||
device.batteryLevel = reading.Reading[InputType.Battery].Value;
|
|
||||||
}
|
|
||||||
device.lastSeen = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleChange(device: BluetoothDevice, actuatorIdx: number, value: number) {
|
|
||||||
const actuator = device.actuators[actuatorIdx];
|
|
||||||
const feature = device.info.features.get(actuator.featureIndex);
|
|
||||||
if (!feature) return;
|
|
||||||
|
|
||||||
actuator.value = value;
|
|
||||||
const outputType = actuator.outputType as typeof OutputType;
|
|
||||||
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
|
|
||||||
|
|
||||||
// Capture event if recording
|
|
||||||
if (isRecording && recordingStartTime) {
|
|
||||||
captureEvent(device, actuatorIdx, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRecording() {
|
|
||||||
if (devices.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isRecording = true;
|
|
||||||
recordingStartTime = performance.now();
|
|
||||||
recordedEvents = [];
|
|
||||||
recordingDuration = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecording() {
|
|
||||||
isRecording = false;
|
|
||||||
if (recordedEvents.length > 0) {
|
|
||||||
recordingDuration = recordedEvents[recordedEvents.length - 1].timestamp;
|
|
||||||
showSaveDialog = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureEvent(device: BluetoothDevice, actuatorIdx: number, value: number) {
|
|
||||||
if (!recordingStartTime) return;
|
|
||||||
|
|
||||||
const timestamp = performance.now() - recordingStartTime;
|
|
||||||
const actuator = device.actuators[actuatorIdx];
|
|
||||||
|
|
||||||
recordedEvents.push({
|
|
||||||
timestamp,
|
|
||||||
device_index: device.info.index,
|
|
||||||
device_name: device.name,
|
|
||||||
actuator_index: actuatorIdx,
|
|
||||||
actuator_type: actuator.outputType,
|
|
||||||
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStop(device: BluetoothDevice) {
|
|
||||||
await device.info.stop();
|
|
||||||
device.actuators.forEach((a) => (a.value = 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
|
|
||||||
const actuators: import("$lib/types").DeviceActuator[] = []; // eslint-disable-line @typescript-eslint/consistent-type-imports
|
|
||||||
for (const [, feature] of device.features) {
|
|
||||||
for (const outputType of feature.outputTypes) {
|
|
||||||
actuators.push({
|
|
||||||
featureIndex: feature.featureIndex,
|
|
||||||
outputType,
|
|
||||||
maxSteps: feature.outputMaxValue(outputType),
|
|
||||||
descriptor: feature.featureDescriptor,
|
|
||||||
value: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(device.index),
|
|
||||||
name: device.name,
|
|
||||||
actuators,
|
|
||||||
batteryLevel: 0,
|
|
||||||
hasBattery: device.hasInput(InputType.Battery),
|
|
||||||
isConnected: true,
|
|
||||||
lastSeen: new Date(),
|
|
||||||
info: device,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) {
|
|
||||||
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
|
|
||||||
name: d.name,
|
|
||||||
index: d.info.index,
|
|
||||||
capabilities: d.actuators.map((a) => a.outputType),
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/sexy/recordings", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: data.title,
|
|
||||||
description: data.description,
|
|
||||||
duration: recordingDuration,
|
|
||||||
events: recordedEvents,
|
|
||||||
device_info: deviceInfo,
|
|
||||||
tags: data.tags,
|
|
||||||
status: "draft",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Recording saved successfully!");
|
|
||||||
showSaveDialog = false;
|
|
||||||
recordedEvents = [];
|
|
||||||
recordingDuration = 0;
|
|
||||||
|
|
||||||
// Optionally navigate to dashboard
|
|
||||||
// goto("/me?tab=recordings");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save recording:", error);
|
|
||||||
toast.error("Failed to save recording. Please try again.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancelSave() {
|
|
||||||
showSaveDialog = false;
|
|
||||||
recordedEvents = [];
|
|
||||||
recordingDuration = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Playback functions
|
|
||||||
function startPlayback() {
|
|
||||||
if (!data.recording) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (devices.length === 0) {
|
|
||||||
toast.error("Please connect devices before playing recording");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we need to map devices
|
|
||||||
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
|
|
||||||
showMappingDialog = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start playback with existing mappings
|
|
||||||
beginPlayback();
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginPlayback() {
|
|
||||||
isPlaying = true;
|
|
||||||
playbackStartTime = performance.now();
|
|
||||||
playbackProgress = 0;
|
|
||||||
currentEventIndex = 0;
|
|
||||||
scheduleNextEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMappingConfirm(mappings: Map<string, BluetoothDevice>) {
|
|
||||||
deviceMappings = mappings;
|
|
||||||
showMappingDialog = false;
|
|
||||||
beginPlayback();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMappingCancel() {
|
|
||||||
showMappingDialog = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPlayback() {
|
|
||||||
isPlaying = false;
|
|
||||||
if (playbackTimeoutId !== null) {
|
|
||||||
clearTimeout(playbackTimeoutId);
|
|
||||||
playbackTimeoutId = null;
|
|
||||||
}
|
|
||||||
playbackProgress = 0;
|
|
||||||
currentEventIndex = 0;
|
|
||||||
|
|
||||||
// Stop all devices
|
|
||||||
devices.forEach((device) => handleStop(device));
|
|
||||||
}
|
|
||||||
|
|
||||||
function pausePlayback() {
|
|
||||||
isPlaying = false;
|
|
||||||
if (playbackTimeoutId !== null) {
|
|
||||||
clearTimeout(playbackTimeoutId);
|
|
||||||
playbackTimeoutId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resumePlayback() {
|
|
||||||
if (!data.recording) return;
|
|
||||||
|
|
||||||
isPlaying = true;
|
|
||||||
playbackStartTime = performance.now() - playbackProgress;
|
|
||||||
scheduleNextEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleNextEvent() {
|
|
||||||
if (!data.recording || !isPlaying || !playbackStartTime) return;
|
|
||||||
|
|
||||||
const events = (data.recording.events ?? []) as RecordedEvent[];
|
|
||||||
if (currentEventIndex >= events.length) {
|
|
||||||
stopPlayback();
|
|
||||||
toast.success("Playback finished");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = events[currentEventIndex];
|
|
||||||
const currentTime = performance.now() - playbackStartTime;
|
|
||||||
const delay = event.timestamp - currentTime;
|
|
||||||
|
|
||||||
if (delay <= 0) {
|
|
||||||
// Execute event immediately
|
|
||||||
executeEvent(event);
|
|
||||||
currentEventIndex++;
|
|
||||||
scheduleNextEvent();
|
|
||||||
} else {
|
|
||||||
// Schedule event
|
|
||||||
playbackTimeoutId = setTimeout(() => {
|
|
||||||
executeEvent(event);
|
|
||||||
currentEventIndex++;
|
|
||||||
playbackProgress = event.timestamp;
|
|
||||||
scheduleNextEvent();
|
|
||||||
}, delay) as unknown as number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeEvent(event: RecordedEvent) {
|
|
||||||
// Get mapped device
|
|
||||||
const device = deviceMappings.get(event.device_name);
|
|
||||||
if (!device) {
|
|
||||||
console.warn(`No device mapping for: ${event.device_name}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find matching actuator by type
|
|
||||||
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
|
|
||||||
if (!actuator) {
|
|
||||||
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert normalized value (0-100) back to device scale
|
|
||||||
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
|
|
||||||
|
|
||||||
// Send command to device via feature
|
|
||||||
const feature = device.info.features.get(actuator.featureIndex);
|
|
||||||
if (feature) {
|
|
||||||
const outputType = actuator.outputType as typeof OutputType;
|
|
||||||
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
actuator.value = deviceValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seek(percentage: number) {
|
|
||||||
if (!data.recording) return;
|
|
||||||
|
|
||||||
const targetTime = (percentage / 100) * data.recording.duration;
|
|
||||||
playbackProgress = targetTime;
|
|
||||||
|
|
||||||
// Find the event index at this time
|
|
||||||
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
|
|
||||||
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
|
|
||||||
if (currentEventIndex === -1) {
|
|
||||||
currentEventIndex = seekEvents.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
if (playbackTimeoutId !== null) {
|
|
||||||
clearTimeout(playbackTimeoutId);
|
|
||||||
}
|
|
||||||
playbackStartTime = performance.now() - targetTime;
|
|
||||||
scheduleNextEvent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = $props();
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (data.authStatus.authenticated) {
|
|
||||||
init();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goto("/login");
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Meta title={$_("play.title")} description={$_("play.description")} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
|
||||||
>
|
|
||||||
<SexyBackground />
|
|
||||||
|
|
||||||
<div class="container mx-auto py-20 relative px-4">
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="text-center mb-12">
|
|
||||||
<h1
|
|
||||||
class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
|
||||||
>
|
|
||||||
{$_("play.title")}
|
|
||||||
</h1>
|
|
||||||
<p class="text-lg text-muted-foreground mb-6">
|
|
||||||
{$_("play.description")}
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-center gap-3 mb-10">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
href="/leaderboard"
|
|
||||||
class="border-primary/30 hover:bg-primary/10"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--trophy-line] w-4 h-4 mr-2"></span>
|
|
||||||
{$_("gamification.leaderboard")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
href="/me"
|
|
||||||
class="border-primary/30 hover:bg-primary/10"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--user-line] w-4 h-4 mr-2"></span>
|
|
||||||
{$_("common.my_profile")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center gap-4 items-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
disabled={!connected || scanning}
|
|
||||||
onclick={startScanning}
|
|
||||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
|
||||||
>
|
|
||||||
{#if scanning}
|
|
||||||
<div
|
|
||||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
|
||||||
></div>
|
|
||||||
{$_("play.scanning")}
|
|
||||||
{:else}
|
|
||||||
{$_("play.scan")}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{#if devices.length > 0 && !data.recording}
|
|
||||||
{#if !isRecording}
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
onclick={startRecording}
|
|
||||||
class="cursor-pointer border-primary/30 hover:bg-primary/10"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--record-circle-line] w-5 h-5 mr-2"></span>
|
|
||||||
Start Recording
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onclick={stopRecording}
|
|
||||||
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--stop-circle-fill] w-5 h-5 mr-2 animate-pulse"></span>
|
|
||||||
Stop Recording ({recordedEvents.length} events)
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Playback Controls (only shown when recording is loaded) -->
|
|
||||||
{#if data.recording}
|
|
||||||
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 backdrop-blur-sm">
|
|
||||||
<div class="mb-4">
|
|
||||||
<h2 class="text-xl font-semibold text-card-foreground mb-2">
|
|
||||||
{data.recording.title}
|
|
||||||
</h2>
|
|
||||||
{#if data.recording.description}
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{data.recording.description}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="flex items-center gap-3 mb-2">
|
|
||||||
<span class="text-sm text-muted-foreground min-w-[50px]">
|
|
||||||
{Math.floor(playbackProgress / 1000 / 60)}:{(
|
|
||||||
Math.floor(playbackProgress / 1000) % 60
|
|
||||||
)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
role="slider"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="Seek"
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={data.recording.duration}
|
|
||||||
aria-valuenow={playbackProgress}
|
|
||||||
class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
|
|
||||||
onclick={(e) => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
|
|
||||||
seek(percentage);
|
|
||||||
}}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "ArrowRight")
|
|
||||||
seek(((playbackProgress + 1) / data.recording.duration) * 100);
|
|
||||||
else if (e.key === "ArrowLeft")
|
|
||||||
seek(((playbackProgress - 1) / data.recording.duration) * 100);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150"
|
|
||||||
style="width: {(playbackProgress / data.recording.duration) * 100}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-muted-foreground min-w-[50px] text-right">
|
|
||||||
{Math.floor(data.recording.duration / 1000 / 60)}:{(
|
|
||||||
Math.floor(data.recording.duration / 1000) % 60
|
|
||||||
)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Playback Buttons -->
|
|
||||||
<div class="flex gap-2 justify-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
onclick={stopPlayback}
|
|
||||||
disabled={!isPlaying && playbackProgress === 0}
|
|
||||||
class="cursor-pointer border-primary/30 hover:bg-primary/10"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--stop-fill] w-5 h-5"></span>
|
|
||||||
</Button>
|
|
||||||
{#if !isPlaying}
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
|
|
||||||
disabled={devices.length === 0}
|
|
||||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--play-fill] w-5 h-5 mr-2"></span>
|
|
||||||
{playbackProgress > 0 ? "Resume" : "Play"}
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onclick={pausePlayback}
|
|
||||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
|
||||||
>
|
|
||||||
<span class="icon-[ri--pause-fill] w-5 h-5 mr-2"></span>
|
|
||||||
Pause
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recording Info -->
|
|
||||||
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-muted-foreground">Events</p>
|
|
||||||
<p class="text-sm font-medium">{data.recording.events?.length ?? 0}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-muted-foreground">Devices</p>
|
|
||||||
<p class="text-sm font-medium">{data.recording.device_info?.length ?? 0}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-muted-foreground">Status</p>
|
|
||||||
<p class="text-sm font-medium capitalize">{data.recording.status}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container mx-auto px-4 py-12">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{#if devices}
|
|
||||||
{#each devices as device (device.name)}
|
|
||||||
<DeviceCard
|
|
||||||
{device}
|
|
||||||
onChange={(scalarIndex, val) => handleChange(device, scalarIndex, val)}
|
|
||||||
onStop={() => handleStop(device)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if devices?.length === 0}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-muted-foreground text-lg mb-4">
|
|
||||||
{$_("play.no_results")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recording Save Dialog -->
|
|
||||||
<RecordingSaveDialog
|
|
||||||
open={showSaveDialog}
|
|
||||||
events={recordedEvents}
|
|
||||||
deviceInfo={devices.map((d) => ({
|
|
||||||
name: d.name,
|
|
||||||
index: d.info.index,
|
|
||||||
capabilities: d.actuators.map((a) => a.outputType),
|
|
||||||
}))}
|
|
||||||
duration={recordingDuration}
|
|
||||||
onSave={handleSaveRecording}
|
|
||||||
onCancel={handleCancelSave}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Device Mapping Dialog -->
|
|
||||||
{#if data.recording}
|
|
||||||
<DeviceMappingDialog
|
|
||||||
open={showMappingDialog}
|
|
||||||
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
|
|
||||||
connectedDevices={devices}
|
|
||||||
onConfirm={handleMappingConfirm}
|
|
||||||
onCancel={handleMappingCancel}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
19
packages/frontend/src/routes/play/buttplug/+page.server.ts
Normal file
19
packages/frontend/src/routes/play/buttplug/+page.server.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { getRecording } from "$lib/services";
|
||||||
|
import type { Recording } from "$lib/types";
|
||||||
|
|
||||||
|
export async function load({ url, fetch }) {
|
||||||
|
const recordingId = url.searchParams.get("recording");
|
||||||
|
|
||||||
|
let recording: Recording | null = null;
|
||||||
|
if (recordingId) {
|
||||||
|
try {
|
||||||
|
recording = await getRecording(recordingId, fetch);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recording:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recording,
|
||||||
|
};
|
||||||
|
}
|
||||||
549
packages/frontend/src/routes/play/buttplug/+page.svelte
Normal file
549
packages/frontend/src/routes/play/buttplug/+page.svelte
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug";
|
||||||
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import DeviceCard from "$lib/components/device-card/device-card.svelte";
|
||||||
|
import RecordingSaveDialog from "../components/recording-save-dialog.svelte";
|
||||||
|
import DeviceMappingDialog from "../components/device-mapping-dialog.svelte";
|
||||||
|
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { createRecording } from "$lib/services";
|
||||||
|
import * as Empty from "$lib/components/ui/empty";
|
||||||
|
|
||||||
|
// Runtime buttplug values — loaded dynamically from the buttplug nginx container
|
||||||
|
let client: ButtplugTypes.ButtplugClient;
|
||||||
|
let InputType: typeof ButtplugTypes.InputType;
|
||||||
|
let DeviceOutputValueConstructor: typeof ButtplugTypes.DeviceOutputValueConstructor;
|
||||||
|
let ButtplugWasmClientConnector: typeof ButtplugTypes.ButtplugWasmClientConnector;
|
||||||
|
|
||||||
|
let connected = $state(false);
|
||||||
|
let scanning = $state(false);
|
||||||
|
let devices = $state<BluetoothDevice[]>([]);
|
||||||
|
|
||||||
|
// Recording state
|
||||||
|
let isRecording = $state(false);
|
||||||
|
let recordingStartTime = $state<number | null>(null);
|
||||||
|
let recordedEvents = $state<RecordedEvent[]>([]);
|
||||||
|
let showSaveDialog = $state(false);
|
||||||
|
let recordingDuration = $state(0);
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
let isPlaying = $state(false);
|
||||||
|
let playbackProgress = $state(0);
|
||||||
|
let playbackStartTime = $state<number | null>(null);
|
||||||
|
let playbackTimeoutId = $state<number | null>(null);
|
||||||
|
let currentEventIndex = $state(0);
|
||||||
|
let showMappingDialog = $state(false);
|
||||||
|
let deviceMappings = $state<Map<string, BluetoothDevice>>(new Map());
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const connector = new ButtplugWasmClientConnector();
|
||||||
|
await client.connect(connector);
|
||||||
|
client.on("deviceadded", onDeviceAdded);
|
||||||
|
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
|
||||||
|
const idx = devices.findIndex((d) => d.info.index === dev.index);
|
||||||
|
if (idx !== -1) devices.splice(idx, 1);
|
||||||
|
});
|
||||||
|
client.on("scanningfinished", () => (scanning = false));
|
||||||
|
client.on("inputreading", handleInputReading);
|
||||||
|
connected = client.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScanning() {
|
||||||
|
await client.startScanning();
|
||||||
|
scanning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeviceAdded(dev: ButtplugTypes.ButtplugClientDevice) {
|
||||||
|
const device = convertDevice(dev);
|
||||||
|
devices.push(device);
|
||||||
|
|
||||||
|
const idx = devices.length - 1;
|
||||||
|
if (device.hasBattery) {
|
||||||
|
try {
|
||||||
|
devices[idx].batteryLevel = await dev.battery();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to read battery for ${dev.name}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function handleInputReading(msg: any) {
|
||||||
|
if (msg.InputReading === undefined) return;
|
||||||
|
const reading = msg.InputReading;
|
||||||
|
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
if (reading.Reading[InputType.Battery] !== undefined) {
|
||||||
|
device.batteryLevel = reading.Reading[InputType.Battery].Value;
|
||||||
|
}
|
||||||
|
device.lastSeen = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChange(device: BluetoothDevice, actuatorIdx: number, value: number) {
|
||||||
|
const actuator = device.actuators[actuatorIdx];
|
||||||
|
const feature = device.info.features.get(actuator.featureIndex);
|
||||||
|
if (!feature) return;
|
||||||
|
|
||||||
|
actuator.value = value;
|
||||||
|
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
|
||||||
|
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
|
||||||
|
|
||||||
|
if (isRecording && recordingStartTime) {
|
||||||
|
captureEvent(device, actuatorIdx, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
if (devices.length === 0) return;
|
||||||
|
isRecording = true;
|
||||||
|
recordingStartTime = performance.now();
|
||||||
|
recordedEvents = [];
|
||||||
|
recordingDuration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
isRecording = false;
|
||||||
|
if (recordedEvents.length > 0) {
|
||||||
|
recordingDuration = recordedEvents[recordedEvents.length - 1].timestamp;
|
||||||
|
showSaveDialog = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureEvent(device: BluetoothDevice, actuatorIdx: number, value: number) {
|
||||||
|
if (!recordingStartTime) return;
|
||||||
|
|
||||||
|
const timestamp = performance.now() - recordingStartTime;
|
||||||
|
const actuator = device.actuators[actuatorIdx];
|
||||||
|
|
||||||
|
recordedEvents.push({
|
||||||
|
timestamp,
|
||||||
|
device_index: device.info.index,
|
||||||
|
device_name: device.name,
|
||||||
|
actuator_index: actuatorIdx,
|
||||||
|
actuator_type: actuator.outputType,
|
||||||
|
value: (value / actuator.maxSteps) * 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop(device: BluetoothDevice) {
|
||||||
|
await device.info.stop();
|
||||||
|
device.actuators.forEach((a) => (a.value = 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDevice(device: ButtplugTypes.ButtplugClientDevice): BluetoothDevice {
|
||||||
|
const actuators: import("$lib/types").DeviceActuator[] = []; // eslint-disable-line @typescript-eslint/consistent-type-imports
|
||||||
|
for (const [, feature] of device.features) {
|
||||||
|
for (const outputType of feature.outputTypes) {
|
||||||
|
actuators.push({
|
||||||
|
featureIndex: feature.featureIndex,
|
||||||
|
outputType,
|
||||||
|
maxSteps: feature.outputMaxValue(outputType),
|
||||||
|
descriptor: feature.featureDescriptor,
|
||||||
|
value: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(device.index),
|
||||||
|
name: device.name,
|
||||||
|
actuators,
|
||||||
|
batteryLevel: 0,
|
||||||
|
hasBattery: device.hasInput(InputType.Battery),
|
||||||
|
isConnected: true,
|
||||||
|
lastSeen: new Date(),
|
||||||
|
info: device,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveRecording(saveData: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
}) {
|
||||||
|
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
index: d.info.index,
|
||||||
|
capabilities: d.actuators.map((a) => a.outputType),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createRecording({
|
||||||
|
title: saveData.title,
|
||||||
|
description: saveData.description,
|
||||||
|
duration: Math.round(recordingDuration),
|
||||||
|
events: recordedEvents,
|
||||||
|
device_info: deviceInfo,
|
||||||
|
tags: saveData.tags,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Recording saved successfully!");
|
||||||
|
showSaveDialog = false;
|
||||||
|
recordedEvents = [];
|
||||||
|
recordingDuration = 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save recording:", error);
|
||||||
|
toast.error("Failed to save recording. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelSave() {
|
||||||
|
showSaveDialog = false;
|
||||||
|
recordedEvents = [];
|
||||||
|
recordingDuration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPlayback() {
|
||||||
|
if (!data.recording) return;
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
toast.error("Please connect devices before playing recording");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
|
||||||
|
showMappingDialog = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
beginPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginPlayback() {
|
||||||
|
isPlaying = true;
|
||||||
|
playbackStartTime = performance.now();
|
||||||
|
playbackProgress = 0;
|
||||||
|
currentEventIndex = 0;
|
||||||
|
scheduleNextEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMappingConfirm(mappings: Map<string, BluetoothDevice>) {
|
||||||
|
deviceMappings = mappings;
|
||||||
|
showMappingDialog = false;
|
||||||
|
beginPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMappingCancel() {
|
||||||
|
showMappingDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPlayback() {
|
||||||
|
isPlaying = false;
|
||||||
|
if (playbackTimeoutId !== null) {
|
||||||
|
clearTimeout(playbackTimeoutId);
|
||||||
|
playbackTimeoutId = null;
|
||||||
|
}
|
||||||
|
playbackProgress = 0;
|
||||||
|
currentEventIndex = 0;
|
||||||
|
devices.forEach((device) => handleStop(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pausePlayback() {
|
||||||
|
isPlaying = false;
|
||||||
|
if (playbackTimeoutId !== null) {
|
||||||
|
clearTimeout(playbackTimeoutId);
|
||||||
|
playbackTimeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumePlayback() {
|
||||||
|
if (!data.recording) return;
|
||||||
|
isPlaying = true;
|
||||||
|
playbackStartTime = performance.now() - playbackProgress;
|
||||||
|
scheduleNextEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextEvent() {
|
||||||
|
if (!data.recording || !isPlaying || !playbackStartTime) return;
|
||||||
|
|
||||||
|
const events = (data.recording.events ?? []) as RecordedEvent[];
|
||||||
|
if (currentEventIndex >= events.length) {
|
||||||
|
stopPlayback();
|
||||||
|
toast.success("Playback finished");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = events[currentEventIndex];
|
||||||
|
const currentTime = performance.now() - playbackStartTime;
|
||||||
|
const delay = event.timestamp - currentTime;
|
||||||
|
|
||||||
|
if (delay <= 0) {
|
||||||
|
executeEvent(event);
|
||||||
|
currentEventIndex++;
|
||||||
|
scheduleNextEvent();
|
||||||
|
} else {
|
||||||
|
playbackTimeoutId = setTimeout(() => {
|
||||||
|
executeEvent(event);
|
||||||
|
currentEventIndex++;
|
||||||
|
playbackProgress = event.timestamp;
|
||||||
|
scheduleNextEvent();
|
||||||
|
}, delay) as unknown as number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeEvent(event: RecordedEvent) {
|
||||||
|
const device = deviceMappings.get(event.device_name);
|
||||||
|
if (!device) {
|
||||||
|
console.warn(`No device mapping for: ${event.device_name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actuator = device.actuators.find((a) => a.outputType === event.actuator_type);
|
||||||
|
if (!actuator) {
|
||||||
|
console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
|
||||||
|
const feature = device.info.features.get(actuator.featureIndex);
|
||||||
|
if (feature) {
|
||||||
|
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
|
||||||
|
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
actuator.value = deviceValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(percentage: number) {
|
||||||
|
if (!data.recording) return;
|
||||||
|
|
||||||
|
const targetTime = (percentage / 100) * data.recording.duration;
|
||||||
|
playbackProgress = targetTime;
|
||||||
|
|
||||||
|
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
|
||||||
|
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
|
||||||
|
if (currentEventIndex === -1) {
|
||||||
|
currentEventIndex = seekEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
if (playbackTimeoutId !== null) {
|
||||||
|
clearTimeout(playbackTimeoutId);
|
||||||
|
}
|
||||||
|
playbackStartTime = performance.now() - targetTime;
|
||||||
|
scheduleNextEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Concatenation prevents Rollup from statically resolving this URL at build time
|
||||||
|
const buttplugUrl = "/buttplug/" + "dist/index.js";
|
||||||
|
const bp = await import(/* @vite-ignore */ buttplugUrl);
|
||||||
|
InputType = bp.InputType;
|
||||||
|
DeviceOutputValueConstructor = bp.DeviceOutputValueConstructor;
|
||||||
|
ButtplugWasmClientConnector = bp.ButtplugWasmClientConnector;
|
||||||
|
client = new bp.ButtplugClient("Sexy.Art");
|
||||||
|
connected = client.connected;
|
||||||
|
await init();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta title={$_("play.title")} description={$_("play.description")} />
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">{$_("play.title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording controls (only when devices are connected) -->
|
||||||
|
{#if devices.length > 0 && !data.recording}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||||
|
{#if !isRecording}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={startRecording}
|
||||||
|
class="cursor-pointer border-primary/30 hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--record-circle-line] w-4 h-4 mr-2"></span>
|
||||||
|
Start Recording
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
onclick={stopRecording}
|
||||||
|
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--stop-circle-fill] w-4 h-4 mr-2 animate-pulse"></span>
|
||||||
|
Stop Recording ({recordedEvents.length} events)
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Playback Controls (only shown when recording is loaded) -->
|
||||||
|
{#if data.recording}
|
||||||
|
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 mb-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-card-foreground mb-1">
|
||||||
|
{data.recording.title}
|
||||||
|
</h2>
|
||||||
|
{#if data.recording.description}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{data.recording.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span class="text-sm text-muted-foreground min-w-[50px]">
|
||||||
|
{Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Seek"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={data.recording.duration}
|
||||||
|
aria-valuenow={playbackProgress}
|
||||||
|
class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
|
||||||
|
onclick={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
seek(percentage);
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "ArrowRight")
|
||||||
|
seek(((playbackProgress + 1) / data.recording.duration) * 100);
|
||||||
|
else if (e.key === "ArrowLeft")
|
||||||
|
seek(((playbackProgress - 1) / data.recording.duration) * 100);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150"
|
||||||
|
style="width: {(playbackProgress / data.recording.duration) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground min-w-[50px] text-right">
|
||||||
|
{Math.floor(data.recording.duration / 1000 / 60)}:{(
|
||||||
|
Math.floor(data.recording.duration / 1000) % 60
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playback Buttons -->
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={stopPlayback}
|
||||||
|
disabled={!isPlaying && playbackProgress === 0}
|
||||||
|
class="cursor-pointer border-primary/30 hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--stop-fill] w-5 h-5"></span>
|
||||||
|
</Button>
|
||||||
|
{#if !isPlaying}
|
||||||
|
<Button
|
||||||
|
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
|
||||||
|
disabled={devices.length === 0}
|
||||||
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--play-fill] w-5 h-5 mr-2"></span>
|
||||||
|
{playbackProgress > 0 ? "Resume" : "Play"}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
onclick={pausePlayback}
|
||||||
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--pause-fill] w-5 h-5 mr-2"></span>
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording Info -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Events</p>
|
||||||
|
<p class="text-sm font-medium">{data.recording.events?.length ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Devices</p>
|
||||||
|
<p class="text-sm font-medium">{data.recording.device_info?.length ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Status</p>
|
||||||
|
<p class="text-sm font-medium capitalize">{data.recording.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Devices grid or empty state -->
|
||||||
|
{#if devices.length > 0}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{#each devices as device (device.name)}
|
||||||
|
<DeviceCard
|
||||||
|
{device}
|
||||||
|
onChange={(scalarIndex, val) => handleChange(device, scalarIndex, val)}
|
||||||
|
onStop={() => handleStop(device)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Empty.Root>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Media>
|
||||||
|
<span class="icon-[ri--rocket-line] w-8 h-8"></span>
|
||||||
|
</Empty.Media>
|
||||||
|
<Empty.Title>{$_("play.no_results")}</Empty.Title>
|
||||||
|
<Empty.Description>{$_("play.no_results_description")}</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
<Empty.Content>
|
||||||
|
<Button
|
||||||
|
disabled={!connected || scanning}
|
||||||
|
onclick={startScanning}
|
||||||
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
|
{#if scanning}
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||||
|
></div>
|
||||||
|
{$_("play.scanning")}
|
||||||
|
{:else}
|
||||||
|
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
|
||||||
|
{$_("play.scan")}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Empty.Content>
|
||||||
|
</Empty.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording Save Dialog -->
|
||||||
|
<RecordingSaveDialog
|
||||||
|
open={showSaveDialog}
|
||||||
|
events={recordedEvents}
|
||||||
|
deviceInfo={devices.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
index: d.info.index,
|
||||||
|
capabilities: d.actuators.map((a) => a.outputType),
|
||||||
|
}))}
|
||||||
|
duration={recordingDuration}
|
||||||
|
onSave={handleSaveRecording}
|
||||||
|
onCancel={handleCancelSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Device Mapping Dialog -->
|
||||||
|
{#if data.recording}
|
||||||
|
<DeviceMappingDialog
|
||||||
|
open={showMappingDialog}
|
||||||
|
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
|
||||||
|
connectedDevices={devices}
|
||||||
|
onConfirm={handleMappingConfirm}
|
||||||
|
onCancel={handleMappingCancel}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
import { gql } from "graphql-request";
|
||||||
|
import { getGraphQLClient } from "$lib/api";
|
||||||
|
|
||||||
|
const LEADERBOARD_QUERY = gql`
|
||||||
|
query Leaderboard($limit: Int, $offset: Int) {
|
||||||
|
leaderboard(limit: $limit, offset: $offset) {
|
||||||
|
user_id
|
||||||
|
display_name
|
||||||
|
avatar
|
||||||
|
total_weighted_points
|
||||||
|
total_raw_points
|
||||||
|
recordings_count
|
||||||
|
playbacks_count
|
||||||
|
achievements_count
|
||||||
|
rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(url.searchParams.get("limit") || "100");
|
||||||
|
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||||
|
|
||||||
|
const client = getGraphQLClient(fetch);
|
||||||
|
const data = await client.request<{
|
||||||
|
leaderboard: {
|
||||||
|
user_id: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
total_weighted_points: number | null;
|
||||||
|
total_raw_points: number | null;
|
||||||
|
recordings_count: number | null;
|
||||||
|
playbacks_count: number | null;
|
||||||
|
achievements_count: number | null;
|
||||||
|
rank: number;
|
||||||
|
}[];
|
||||||
|
}>(LEADERBOARD_QUERY, { limit, offset });
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaderboard: data.leaderboard || [],
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: data.leaderboard?.length === limit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Leaderboard load error:", error);
|
||||||
|
return {
|
||||||
|
leaderboard: [],
|
||||||
|
pagination: {
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
188
packages/frontend/src/routes/play/leaderboard/+page.svelte
Normal file
188
packages/frontend/src/routes/play/leaderboard/+page.svelte
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _, locale } from "svelte-i18n";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "$lib/components/ui/avatar";
|
||||||
|
import { getAssetUrl } from "$lib/api";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
function formatPoints(points: number | null | undefined): string {
|
||||||
|
return Math.round(points ?? 0).toLocaleString($locale || "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMedalEmoji(rank: number): string {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return "🥇";
|
||||||
|
case 2:
|
||||||
|
return "🥈";
|
||||||
|
case 3:
|
||||||
|
return "🥉";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserInitials(name: string | null | undefined): string {
|
||||||
|
if (!name) return "?";
|
||||||
|
const parts = name.split(" ");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta
|
||||||
|
title={$_("gamification.leaderboard")}
|
||||||
|
description={$_("gamification.leaderboard_description")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{$_("gamification.leaderboard")}</h1>
|
||||||
|
<p class="text-sm text-muted-foreground mt-0.5">{$_("gamification.leaderboard_subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card class="bg-card/50 border-border/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<span class="icon-[ri--trophy-line] w-5 h-5 text-primary"></span>
|
||||||
|
{$_("gamification.top_players")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if data.leaderboard.length === 0}
|
||||||
|
<div class="text-center py-12 text-muted-foreground">
|
||||||
|
<span class="icon-[ri--trophy-line] w-12 h-12 mx-auto mb-4 opacity-50 block"></span>
|
||||||
|
<p>{$_("gamification.no_rankings_yet")}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each data.leaderboard as entry (entry.user_id)}
|
||||||
|
<a
|
||||||
|
href="/users/{entry.user_id}"
|
||||||
|
class="flex items-center gap-4 p-4 rounded-lg hover:bg-accent/10 transition-colors group"
|
||||||
|
>
|
||||||
|
<!-- Rank Badge -->
|
||||||
|
<div class="flex-shrink-0 w-14 text-center">
|
||||||
|
{#if entry.rank <= 3}
|
||||||
|
<span class="text-3xl">{getMedalEmoji(entry.rank)}</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
#{entry.rank}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Avatar -->
|
||||||
|
<Avatar
|
||||||
|
class="h-12 w-12 ring-2 ring-accent/20 group-hover:ring-primary/40 transition-all"
|
||||||
|
>
|
||||||
|
{#if entry.avatar}
|
||||||
|
<AvatarImage src={getAssetUrl(entry.avatar, "mini")} alt={entry.display_name} />
|
||||||
|
{/if}
|
||||||
|
<AvatarFallback
|
||||||
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
|
||||||
|
>
|
||||||
|
{getUserInitials(entry.display_name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-semibold truncate group-hover:text-primary transition-colors">
|
||||||
|
{entry.display_name || $_("common.anonymous")}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground flex items-center gap-3">
|
||||||
|
<span title={$_("gamification.recordings")}>
|
||||||
|
<span class="icon-[ri--video-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||||
|
{entry.recordings_count}
|
||||||
|
</span>
|
||||||
|
<span title={$_("gamification.plays")}>
|
||||||
|
<span class="icon-[ri--play-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||||
|
{entry.playbacks_count}
|
||||||
|
</span>
|
||||||
|
<span title={$_("gamification.achievements")}>
|
||||||
|
<span class="icon-[ri--trophy-line] w-3.5 h-3.5 inline mr-1"></span>
|
||||||
|
{entry.achievements_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Score -->
|
||||||
|
<div class="text-right flex-shrink-0">
|
||||||
|
<div class="text-2xl font-bold text-primary">
|
||||||
|
{formatPoints(entry.total_weighted_points)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{$_("gamification.points")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrow indicator -->
|
||||||
|
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span class="icon-[ri--arrow-right-s-line] w-5 h-5 text-muted-foreground"></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.pagination.hasMore}
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
href="/play/leaderboard?offset={data.pagination.offset +
|
||||||
|
data.pagination.limit}&limit={data.pagination.limit}"
|
||||||
|
>
|
||||||
|
{$_("common.load_more")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Info Card -->
|
||||||
|
<Card class="mt-6 bg-card/50 border-border/50">
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<h3 class="font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<span class="icon-[ri--information-line] w-4 h-4 text-primary"></span>
|
||||||
|
{$_("gamification.how_it_works")}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
{$_("gamification.how_it_works_description")}
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="icon-[ri--video-add-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{$_("gamification.earn_by_creating")}</div>
|
||||||
|
<div class="text-muted-foreground">{$_("gamification.earn_by_creating_desc")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="icon-[ri--play-circle-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"
|
||||||
|
></span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{$_("gamification.earn_by_playing")}</div>
|
||||||
|
<div class="text-muted-foreground">{$_("gamification.earn_by_playing_desc")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="icon-[ri--time-line] w-5 h-5 text-primary flex-shrink-0 mt-0.5"></span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{$_("gamification.stay_active")}</div>
|
||||||
|
<div class="text-muted-foreground">{$_("gamification.stay_active_desc")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { getRecordings } from "$lib/services";
|
||||||
|
|
||||||
|
export async function load({ fetch }) {
|
||||||
|
return {
|
||||||
|
recordings: await getRecordings(fetch).catch(() => []),
|
||||||
|
};
|
||||||
|
}
|
||||||
122
packages/frontend/src/routes/play/recordings/+page.svelte
Normal file
122
packages/frontend/src/routes/play/recordings/+page.svelte
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { deleteRecording, updateRecording } from "$lib/services";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Empty from "$lib/components/ui/empty";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
|
||||||
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
let recordings = $state(untrack(() => data.recordings));
|
||||||
|
let deleteTarget = $state<string | null>(null);
|
||||||
|
let deleteOpen = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
function handleDeleteRecording(id: string) {
|
||||||
|
deleteTarget = id;
|
||||||
|
deleteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteRecording() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await deleteRecording(deleteTarget);
|
||||||
|
recordings = recordings.filter((r) => r.id !== deleteTarget);
|
||||||
|
toast.success($_("me.recordings.delete_success"));
|
||||||
|
deleteOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
} catch {
|
||||||
|
toast.error($_("me.recordings.delete_error"));
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublishRecording(id: string) {
|
||||||
|
try {
|
||||||
|
await updateRecording(id, { status: "published" });
|
||||||
|
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "published" } : r));
|
||||||
|
toast.success($_("me.recordings.publish_success"));
|
||||||
|
} catch {
|
||||||
|
toast.error($_("me.recordings.publish_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnpublishRecording(id: string) {
|
||||||
|
try {
|
||||||
|
await updateRecording(id, { status: "draft" });
|
||||||
|
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "draft" } : r));
|
||||||
|
toast.success($_("me.recordings.unpublish_success"));
|
||||||
|
} catch {
|
||||||
|
toast.error($_("me.recordings.unpublish_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayRecording(id: string) {
|
||||||
|
goto(`/play/buttplug?recording=${id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Meta title={$_("me.recordings.title")} />
|
||||||
|
|
||||||
|
<div class="py-3 sm:py-6 lg:pl-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recordings.length === 0}
|
||||||
|
<Empty.Root>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Media>
|
||||||
|
<span class="icon-[ri--play-list-2-line] w-8 h-8"></span>
|
||||||
|
</Empty.Media>
|
||||||
|
<Empty.Title>{$_("me.recordings.no_recordings")}</Empty.Title>
|
||||||
|
<Empty.Description>{$_("me.recordings.no_recordings_description")}</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
<Empty.Content>
|
||||||
|
<Button
|
||||||
|
href="/play/buttplug"
|
||||||
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
|
||||||
|
{$_("me.recordings.go_to_play")}
|
||||||
|
</Button>
|
||||||
|
</Empty.Content>
|
||||||
|
</Empty.Root>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{#each recordings as recording (recording.id)}
|
||||||
|
<RecordingCard
|
||||||
|
{recording}
|
||||||
|
onPlay={handlePlayRecording}
|
||||||
|
onPublish={handlePublishRecording}
|
||||||
|
onUnpublish={handleUnpublishRecording}
|
||||||
|
onDelete={handleDeleteRecording}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={deleteOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
|
||||||
|
<Dialog.Description>This cannot be undone.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (deleteOpen = false)}>
|
||||||
|
{$_("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
|
||||||
|
{deleting ? "Deleting…" : $_("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span>
|
<span class="icon-[ri--trophy-line] w-6 h-6 text-primary"></span>
|
||||||
{$_("gamification.stats")}
|
{$_("gamification.stats")}
|
||||||
</h2>
|
</h2>
|
||||||
<Button variant="outline" size="sm" href="/leaderboard">
|
<Button variant="outline" size="sm" href="/play/leaderboard">
|
||||||
<span class="icon-[ri--bar-chart-line] w-4 h-4 mr-2"></span>
|
<span class="icon-[ri--bar-chart-line] w-4 h-4 mr-2"></span>
|
||||||
{$_("gamification.leaderboard")}
|
{$_("gamification.leaderboard")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import path from "path";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import wasm from "vite-plugin-wasm";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), tailwindcss(), wasm()],
|
plugins: [sveltekit(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { $lib: path.resolve("./src/lib"), "@": path.resolve("./src/lib") },
|
alias: { $lib: path.resolve("./src/lib"), "@": path.resolve("./src/lib") },
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
external: ["@sexy.pivoine.art/buttplug"],
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -19,6 +23,11 @@ export default defineConfig({
|
|||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
|
"/buttplug": {
|
||||||
|
rewrite: (path) => path.replace(/^\/buttplug/, ""),
|
||||||
|
target: "http://localhost:8080",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@sexy.pivoine.art/types",
|
"name": "@sexy.pivoine.art/types",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
|||||||
384
pnpm-lock.yaml
generated
384
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
|||||||
argon2:
|
argon2:
|
||||||
specifier: ^0.43.0
|
specifier: ^0.43.0
|
||||||
version: 0.43.1
|
version: 0.43.1
|
||||||
|
bullmq:
|
||||||
|
specifier: ^5.70.4
|
||||||
|
version: 5.70.4
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.44.1
|
specifier: ^0.44.1
|
||||||
version: 0.44.7(@types/pg@8.18.0)(knex@3.1.0(pg@8.19.0))(pg@8.19.0)
|
version: 0.44.7(@types/pg@8.18.0)(knex@3.1.0(pg@8.19.0))(pg@8.19.0)
|
||||||
@@ -151,9 +154,6 @@ importers:
|
|||||||
|
|
||||||
packages/frontend:
|
packages/frontend:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sexy.pivoine.art/buttplug':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../buttplug
|
|
||||||
'@sexy.pivoine.art/types':
|
'@sexy.pivoine.art/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../types
|
version: link:../types
|
||||||
@@ -183,11 +183,14 @@ importers:
|
|||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.1(tailwindcss@4.2.1)
|
version: 1.2.1(tailwindcss@4.2.1)
|
||||||
'@internationalized/date':
|
'@internationalized/date':
|
||||||
specifier: ^3.11.0
|
specifier: ^3.12.0
|
||||||
version: 3.11.0
|
version: 3.12.0
|
||||||
'@lucide/svelte':
|
'@lucide/svelte':
|
||||||
specifier: ^0.561.0
|
specifier: ^0.561.0
|
||||||
version: 0.561.0(svelte@5.53.7)
|
version: 0.561.0(svelte@5.53.7)
|
||||||
|
'@sexy.pivoine.art/buttplug':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../buttplug
|
||||||
'@sveltejs/adapter-node':
|
'@sveltejs/adapter-node':
|
||||||
specifier: ^5.5.4
|
specifier: ^5.5.4
|
||||||
version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))
|
version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))
|
||||||
@@ -214,7 +217,7 @@ importers:
|
|||||||
version: 5.0.8
|
version: 5.0.8
|
||||||
bits-ui:
|
bits-ui:
|
||||||
specifier: 2.16.2
|
specifier: 2.16.2
|
||||||
version: 2.16.2(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
|
version: 2.16.2(@internationalized/date@3.12.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@@ -227,9 +230,6 @@ importers:
|
|||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
specifier: ^3.5.1
|
specifier: ^3.5.1
|
||||||
version: 3.5.1(prettier@3.8.1)(svelte@5.53.7)
|
version: 3.5.1(prettier@3.8.1)(svelte@5.53.7)
|
||||||
super-sitemap:
|
|
||||||
specifier: ^1.0.7
|
|
||||||
version: 1.0.7(svelte@5.53.7)
|
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.53.7
|
specifier: ^5.53.7
|
||||||
version: 5.53.7
|
version: 5.53.7
|
||||||
@@ -257,9 +257,6 @@ importers:
|
|||||||
vite:
|
vite:
|
||||||
specifier: ^7.3.1
|
specifier: ^7.3.1
|
||||||
version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)
|
version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)
|
||||||
vite-plugin-wasm:
|
|
||||||
specifier: 3.5.0
|
|
||||||
version: 3.5.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0))
|
|
||||||
|
|
||||||
packages/types:
|
packages/types:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -1094,67 +1091,79 @@ packages:
|
|||||||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm@1.0.5':
|
'@img/sharp-libvips-linux-arm@1.0.5':
|
||||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-s390x@1.0.4':
|
'@img/sharp-libvips-linux-s390x@1.0.4':
|
||||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.0.4':
|
'@img/sharp-libvips-linux-x64@1.0.4':
|
||||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
||||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
||||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linux-arm64@0.33.5':
|
'@img/sharp-linux-arm64@0.33.5':
|
||||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-arm@0.33.5':
|
'@img/sharp-linux-arm@0.33.5':
|
||||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-s390x@0.33.5':
|
'@img/sharp-linux-s390x@0.33.5':
|
||||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.33.5':
|
'@img/sharp-linux-x64@0.33.5':
|
||||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-arm64@0.33.5':
|
'@img/sharp-linuxmusl-arm64@0.33.5':
|
||||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-x64@0.33.5':
|
'@img/sharp-linuxmusl-x64@0.33.5':
|
||||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.33.5':
|
'@img/sharp-wasm32@0.33.5':
|
||||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||||
@@ -1173,8 +1182,11 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@internationalized/date@3.11.0':
|
'@internationalized/date@3.12.0':
|
||||||
resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==}
|
resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==}
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.0':
|
||||||
|
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
|
||||||
|
|
||||||
'@ioredis/commands@1.5.1':
|
'@ioredis/commands@1.5.1':
|
||||||
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||||
@@ -1211,6 +1223,36 @@ packages:
|
|||||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||||
|
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
|
||||||
|
resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
|
||||||
|
resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
|
||||||
|
resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
|
||||||
|
resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||||
|
resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@phc/format@1.0.0':
|
'@phc/format@1.0.0':
|
||||||
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1305,66 +1347,79 @@ packages:
|
|||||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||||
@@ -1491,24 +1546,28 @@ packages:
|
|||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||||
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||||
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||||
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||||
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
||||||
@@ -1698,10 +1757,6 @@ packages:
|
|||||||
ajv@8.18.0:
|
ajv@8.18.0:
|
||||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||||
|
|
||||||
ansi-styles@3.2.1:
|
|
||||||
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
argon2@0.43.1:
|
argon2@0.43.1:
|
||||||
resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==}
|
resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==}
|
||||||
engines: {node: '>=16.17.0'}
|
engines: {node: '>=16.17.0'}
|
||||||
@@ -1710,14 +1765,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
|
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
array-back@3.1.0:
|
|
||||||
resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==}
|
|
||||||
engines: {node: '>=6'}
|
|
||||||
|
|
||||||
array-back@4.0.2:
|
|
||||||
resolution: {integrity: sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
async@0.2.10:
|
async@0.2.10:
|
||||||
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
|
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
|
||||||
|
|
||||||
@@ -1767,15 +1814,14 @@ packages:
|
|||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
bullmq@5.70.4:
|
||||||
|
resolution: {integrity: sha512-S58YT/tGdhc4pEPcIahtZRBR1TcTLpss1UKiXimF+Vy4yZwF38pW2IvhHqs4j4dEbZqDt8oi0jGGN/WYQHbPDg==}
|
||||||
|
|
||||||
ce-la-react@0.3.2:
|
ce-la-react@0.3.2:
|
||||||
resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==}
|
resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=17.0.0'
|
react: '>=17.0.0'
|
||||||
|
|
||||||
chalk@2.4.2:
|
|
||||||
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
@@ -1796,9 +1842,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
color-convert@1.9.3:
|
|
||||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -1819,14 +1862,6 @@ packages:
|
|||||||
colorette@2.0.19:
|
colorette@2.0.19:
|
||||||
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
|
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
|
||||||
|
|
||||||
command-line-args@5.2.1:
|
|
||||||
resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
|
|
||||||
engines: {node: '>=4.0.0'}
|
|
||||||
|
|
||||||
command-line-usage@6.1.3:
|
|
||||||
resolution: {integrity: sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==}
|
|
||||||
engines: {node: '>=8.0.0'}
|
|
||||||
|
|
||||||
commander@10.0.1:
|
commander@10.0.1:
|
||||||
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1859,6 +1894,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cron-parser@4.9.0:
|
||||||
|
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
cross-inspect@1.0.1:
|
cross-inspect@1.0.1:
|
||||||
resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==}
|
resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -1916,10 +1955,6 @@ packages:
|
|||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
deep-extend@0.6.0:
|
|
||||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
|
||||||
engines: {node: '>=4.0.0'}
|
|
||||||
|
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
@@ -1946,11 +1981,6 @@ packages:
|
|||||||
devalue@5.6.3:
|
devalue@5.6.3:
|
||||||
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
|
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
|
||||||
|
|
||||||
directory-tree@3.6.0:
|
|
||||||
resolution: {integrity: sha512-rTMWs+zxr0QEbzQKRfwV6SeEy+zIHFkorskI4bhG2o7ayr82c+FC7yWg3yLpurgp6Hs2NGy1NWrKIaDodr2r8A==}
|
|
||||||
engines: {node: '>=10.0'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
@@ -2114,10 +2144,6 @@ packages:
|
|||||||
escape-html@1.0.3:
|
escape-html@1.0.3:
|
||||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||||
|
|
||||||
escape-string-regexp@1.0.5:
|
|
||||||
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
|
|
||||||
engines: {node: '>=0.8.0'}
|
|
||||||
|
|
||||||
escape-string-regexp@4.0.0:
|
escape-string-regexp@4.0.0:
|
||||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2239,13 +2265,6 @@ packages:
|
|||||||
fast-uri@3.1.0:
|
fast-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||||
|
|
||||||
fast-xml-builder@1.0.0:
|
|
||||||
resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==}
|
|
||||||
|
|
||||||
fast-xml-parser@5.4.2:
|
|
||||||
resolution: {integrity: sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
fastify-plugin@5.1.0:
|
fastify-plugin@5.1.0:
|
||||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||||
|
|
||||||
@@ -2275,10 +2294,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==}
|
resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
find-replace@3.0.0:
|
|
||||||
resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
|
|
||||||
engines: {node: '>=4.0.0'}
|
|
||||||
|
|
||||||
find-up@5.0.0:
|
find-up@5.0.0:
|
||||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2406,10 +2421,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==}
|
resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==}
|
||||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||||
|
|
||||||
has-flag@3.0.0:
|
|
||||||
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2451,6 +2462,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==}
|
resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==}
|
||||||
engines: {node: '>=12.22.0'}
|
engines: {node: '>=12.22.0'}
|
||||||
|
|
||||||
|
ioredis@5.9.3:
|
||||||
|
resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==}
|
||||||
|
engines: {node: '>=12.22.0'}
|
||||||
|
|
||||||
ipaddr.js@2.3.0:
|
ipaddr.js@2.3.0:
|
||||||
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -2591,24 +2606,28 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.31.1:
|
lightningcss-linux-arm64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.31.1:
|
lightningcss-linux-x64-gnu@1.31.1:
|
||||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.31.1:
|
lightningcss-linux-x64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.31.1:
|
lightningcss-win32-arm64-msvc@1.31.1:
|
||||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||||
@@ -2637,9 +2656,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
lodash.camelcase@4.3.0:
|
|
||||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
|
||||||
|
|
||||||
lodash.defaults@4.2.0:
|
lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
@@ -2659,6 +2675,10 @@ packages:
|
|||||||
lru-queue@0.1.0:
|
lru-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==}
|
resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==}
|
||||||
|
|
||||||
|
luxon@3.7.2:
|
||||||
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
lz-string@1.5.0:
|
lz-string@1.5.0:
|
||||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2750,6 +2770,13 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
msgpackr-extract@3.0.3:
|
||||||
|
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
msgpackr@1.11.5:
|
||||||
|
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -2761,10 +2788,17 @@ packages:
|
|||||||
next-tick@1.1.0:
|
next-tick@1.1.0:
|
||||||
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
|
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
|
||||||
|
|
||||||
|
node-abort-controller@3.1.1:
|
||||||
|
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||||
|
|
||||||
node-addon-api@8.6.0:
|
node-addon-api@8.6.0:
|
||||||
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
|
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
|
||||||
engines: {node: ^18 || ^20 || >= 21}
|
engines: {node: ^18 || ^20 || >= 21}
|
||||||
|
|
||||||
|
node-gyp-build-optional-packages@5.2.2:
|
||||||
|
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
node-gyp-build@4.8.4:
|
node-gyp-build@4.8.4:
|
||||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2990,10 +3024,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
reduce-flatten@2.0.0:
|
|
||||||
resolution: {integrity: sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==}
|
|
||||||
engines: {node: '>=6'}
|
|
||||||
|
|
||||||
relative-time-format@1.1.12:
|
relative-time-format@1.1.12:
|
||||||
resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==}
|
resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==}
|
||||||
|
|
||||||
@@ -3084,6 +3114,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
semver@7.7.4:
|
||||||
|
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
set-cookie-parser@2.7.2:
|
set-cookie-parser@2.7.2:
|
||||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
@@ -3145,21 +3180,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
strnum@2.2.0:
|
|
||||||
resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==}
|
|
||||||
|
|
||||||
style-to-object@1.0.14:
|
style-to-object@1.0.14:
|
||||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
||||||
|
|
||||||
super-sitemap@1.0.7:
|
|
||||||
resolution: {integrity: sha512-fXUphLw0Tss29/SFP2irhWLNUCRbBNMf/XSsncvKpEM++CThfaPT8Bis3973RIb7KatiJAVfJtFH7xPyCp1AYw==}
|
|
||||||
peerDependencies:
|
|
||||||
svelte: '>=4.0.0 <6.0.0'
|
|
||||||
|
|
||||||
supports-color@5.5.0:
|
|
||||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0:
|
supports-preserve-symlinks-flag@1.0.0:
|
||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3217,10 +3240,6 @@ packages:
|
|||||||
tabbable@6.4.0:
|
tabbable@6.4.0:
|
||||||
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
||||||
|
|
||||||
table-layout@1.0.2:
|
|
||||||
resolution: {integrity: sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==}
|
|
||||||
engines: {node: '>=8.0.0'}
|
|
||||||
|
|
||||||
tailwind-merge@3.5.0:
|
tailwind-merge@3.5.0:
|
||||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||||
|
|
||||||
@@ -3326,14 +3345,6 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
typical@4.0.0:
|
|
||||||
resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
typical@5.2.0:
|
|
||||||
resolution: {integrity: sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
ufo@1.6.3:
|
ufo@1.6.3:
|
||||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||||
|
|
||||||
@@ -3423,10 +3434,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
wordwrapjs@4.0.1:
|
|
||||||
resolution: {integrity: sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==}
|
|
||||||
engines: {node: '>=8.0.0'}
|
|
||||||
|
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
@@ -4105,10 +4112,12 @@ snapshots:
|
|||||||
'@img/sharp-win32-x64@0.33.5':
|
'@img/sharp-win32-x64@0.33.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@internationalized/date@3.11.0':
|
'@internationalized/date@3.12.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/helpers': 0.5.19
|
'@swc/helpers': 0.5.19
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.0': {}
|
||||||
|
|
||||||
'@ioredis/commands@1.5.1': {}
|
'@ioredis/commands@1.5.1': {}
|
||||||
|
|
||||||
'@isaacs/cliui@9.0.0': {}
|
'@isaacs/cliui@9.0.0': {}
|
||||||
@@ -4144,6 +4153,24 @@ snapshots:
|
|||||||
|
|
||||||
'@lukeed/ms@2.0.2': {}
|
'@lukeed/ms@2.0.2': {}
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@phc/format@1.0.0': {}
|
'@phc/format@1.0.0': {}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
@@ -4597,10 +4624,6 @@ snapshots:
|
|||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
ansi-styles@3.2.1:
|
|
||||||
dependencies:
|
|
||||||
color-convert: 1.9.3
|
|
||||||
|
|
||||||
argon2@0.43.1:
|
argon2@0.43.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@phc/format': 1.0.0
|
'@phc/format': 1.0.0
|
||||||
@@ -4609,10 +4632,6 @@ snapshots:
|
|||||||
|
|
||||||
aria-query@5.3.1: {}
|
aria-query@5.3.1: {}
|
||||||
|
|
||||||
array-back@3.1.0: {}
|
|
||||||
|
|
||||||
array-back@4.0.2: {}
|
|
||||||
|
|
||||||
async@0.2.10: {}
|
async@0.2.10: {}
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
@@ -4642,11 +4661,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
|
||||||
bits-ui@2.16.2(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7):
|
bits-ui@2.16.2(@internationalized/date@3.12.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/core': 1.7.5
|
'@floating-ui/core': 1.7.5
|
||||||
'@floating-ui/dom': 1.7.6
|
'@floating-ui/dom': 1.7.6
|
||||||
'@internationalized/date': 3.11.0
|
'@internationalized/date': 3.12.0
|
||||||
esm-env: 1.2.2
|
esm-env: 1.2.2
|
||||||
runed: 0.35.1(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
|
runed: 0.35.1(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)))(svelte@5.53.7)
|
||||||
svelte: 5.53.7
|
svelte: 5.53.7
|
||||||
@@ -4668,16 +4687,22 @@ snapshots:
|
|||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
|
bullmq@5.70.4:
|
||||||
|
dependencies:
|
||||||
|
cron-parser: 4.9.0
|
||||||
|
ioredis: 5.9.3
|
||||||
|
msgpackr: 1.11.5
|
||||||
|
node-abort-controller: 3.1.1
|
||||||
|
semver: 7.7.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
uuid: 11.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ce-la-react@0.3.2(react@19.2.0):
|
ce-la-react@0.3.2(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
||||||
chalk@2.4.2:
|
|
||||||
dependencies:
|
|
||||||
ansi-styles: 3.2.1
|
|
||||||
escape-string-regexp: 1.0.5
|
|
||||||
supports-color: 5.5.0
|
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 4.1.2
|
readdirp: 4.1.2
|
||||||
@@ -4696,10 +4721,6 @@ snapshots:
|
|||||||
|
|
||||||
cluster-key-slot@1.1.2: {}
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
color-convert@1.9.3:
|
|
||||||
dependencies:
|
|
||||||
color-name: 1.1.3
|
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -4721,20 +4742,6 @@ snapshots:
|
|||||||
colorette@2.0.19:
|
colorette@2.0.19:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
command-line-args@5.2.1:
|
|
||||||
dependencies:
|
|
||||||
array-back: 3.1.0
|
|
||||||
find-replace: 3.0.0
|
|
||||||
lodash.camelcase: 4.3.0
|
|
||||||
typical: 4.0.0
|
|
||||||
|
|
||||||
command-line-usage@6.1.3:
|
|
||||||
dependencies:
|
|
||||||
array-back: 4.0.2
|
|
||||||
chalk: 2.4.2
|
|
||||||
table-layout: 1.0.2
|
|
||||||
typical: 5.2.0
|
|
||||||
|
|
||||||
commander@10.0.1:
|
commander@10.0.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4757,6 +4764,10 @@ snapshots:
|
|||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
cron-parser@4.9.0:
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-inspect@1.0.1:
|
cross-inspect@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -4809,8 +4820,6 @@ snapshots:
|
|||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
deep-extend@0.6.0: {}
|
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
@@ -4825,11 +4834,6 @@ snapshots:
|
|||||||
|
|
||||||
devalue@5.6.3: {}
|
devalue@5.6.3: {}
|
||||||
|
|
||||||
directory-tree@3.6.0:
|
|
||||||
dependencies:
|
|
||||||
command-line-args: 5.2.1
|
|
||||||
command-line-usage: 6.1.3
|
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -5016,8 +5020,6 @@ snapshots:
|
|||||||
|
|
||||||
escape-html@1.0.3: {}
|
escape-html@1.0.3: {}
|
||||||
|
|
||||||
escape-string-regexp@1.0.5: {}
|
|
||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)):
|
eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)):
|
||||||
@@ -5173,13 +5175,6 @@ snapshots:
|
|||||||
|
|
||||||
fast-uri@3.1.0: {}
|
fast-uri@3.1.0: {}
|
||||||
|
|
||||||
fast-xml-builder@1.0.0: {}
|
|
||||||
|
|
||||||
fast-xml-parser@5.4.2:
|
|
||||||
dependencies:
|
|
||||||
fast-xml-builder: 1.0.0
|
|
||||||
strnum: 2.2.0
|
|
||||||
|
|
||||||
fastify-plugin@5.1.0: {}
|
fastify-plugin@5.1.0: {}
|
||||||
|
|
||||||
fastify@5.7.4:
|
fastify@5.7.4:
|
||||||
@@ -5220,10 +5215,6 @@ snapshots:
|
|||||||
fast-querystring: 1.1.2
|
fast-querystring: 1.1.2
|
||||||
safe-regex2: 5.0.0
|
safe-regex2: 5.0.0
|
||||||
|
|
||||||
find-replace@3.0.0:
|
|
||||||
dependencies:
|
|
||||||
array-back: 3.1.0
|
|
||||||
|
|
||||||
find-up@5.0.0:
|
find-up@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
locate-path: 6.0.0
|
locate-path: 6.0.0
|
||||||
@@ -5341,8 +5332,6 @@ snapshots:
|
|||||||
|
|
||||||
graphql@16.13.1: {}
|
graphql@16.13.1: {}
|
||||||
|
|
||||||
has-flag@3.0.0: {}
|
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
@@ -5394,6 +5383,20 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
ioredis@5.9.3:
|
||||||
|
dependencies:
|
||||||
|
'@ioredis/commands': 1.5.0
|
||||||
|
cluster-key-slot: 1.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
denque: 2.1.0
|
||||||
|
lodash.defaults: 4.2.0
|
||||||
|
lodash.isarguments: 3.1.0
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
redis-parser: 3.0.0
|
||||||
|
standard-as-callback: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ipaddr.js@2.3.0: {}
|
ipaddr.js@2.3.0: {}
|
||||||
|
|
||||||
is-arrayish@0.3.4: {}
|
is-arrayish@0.3.4: {}
|
||||||
@@ -5542,8 +5545,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
lodash.camelcase@4.3.0: {}
|
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0: {}
|
lodash.isarguments@3.1.0: {}
|
||||||
@@ -5559,6 +5560,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es5-ext: 0.10.64
|
es5-ext: 0.10.64
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
lz-string@1.5.0: {}
|
lz-string@1.5.0: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
@@ -5643,14 +5646,37 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
msgpackr-extract@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
node-gyp-build-optional-packages: 5.2.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
msgpackr@1.11.5:
|
||||||
|
optionalDependencies:
|
||||||
|
msgpackr-extract: 3.0.3
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
next-tick@1.1.0: {}
|
next-tick@1.1.0: {}
|
||||||
|
|
||||||
|
node-abort-controller@3.1.1: {}
|
||||||
|
|
||||||
node-addon-api@8.6.0: {}
|
node-addon-api@8.6.0: {}
|
||||||
|
|
||||||
|
node-gyp-build-optional-packages@5.2.2:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
node-gyp-build@4.8.4: {}
|
node-gyp-build@4.8.4: {}
|
||||||
|
|
||||||
nodemailer@7.0.13: {}
|
nodemailer@7.0.13: {}
|
||||||
@@ -5848,8 +5874,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
redis-errors: 1.2.0
|
redis-errors: 1.2.0
|
||||||
|
|
||||||
reduce-flatten@2.0.0: {}
|
|
||||||
|
|
||||||
relative-time-format@1.1.12: {}
|
relative-time-format@1.1.12: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
@@ -5948,6 +5972,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.3: {}
|
semver@7.7.3: {}
|
||||||
|
|
||||||
|
semver@7.7.4: {}
|
||||||
|
|
||||||
set-cookie-parser@2.7.2: {}
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
set-cookie-parser@3.0.1: {}
|
set-cookie-parser@3.0.1: {}
|
||||||
@@ -6019,22 +6045,10 @@ snapshots:
|
|||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
strnum@2.2.0: {}
|
|
||||||
|
|
||||||
style-to-object@1.0.14:
|
style-to-object@1.0.14:
|
||||||
dependencies:
|
dependencies:
|
||||||
inline-style-parser: 0.2.7
|
inline-style-parser: 0.2.7
|
||||||
|
|
||||||
super-sitemap@1.0.7(svelte@5.53.7):
|
|
||||||
dependencies:
|
|
||||||
directory-tree: 3.6.0
|
|
||||||
fast-xml-parser: 5.4.2
|
|
||||||
svelte: 5.53.7
|
|
||||||
|
|
||||||
supports-color@5.5.0:
|
|
||||||
dependencies:
|
|
||||||
has-flag: 3.0.0
|
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3):
|
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3):
|
||||||
@@ -6124,13 +6138,6 @@ snapshots:
|
|||||||
|
|
||||||
tabbable@6.4.0: {}
|
tabbable@6.4.0: {}
|
||||||
|
|
||||||
table-layout@1.0.2:
|
|
||||||
dependencies:
|
|
||||||
array-back: 4.0.2
|
|
||||||
deep-extend: 0.6.0
|
|
||||||
typical: 5.2.0
|
|
||||||
wordwrapjs: 4.0.1
|
|
||||||
|
|
||||||
tailwind-merge@3.5.0: {}
|
tailwind-merge@3.5.0: {}
|
||||||
|
|
||||||
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
|
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
|
||||||
@@ -6227,10 +6234,6 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
typical@4.0.0: {}
|
|
||||||
|
|
||||||
typical@5.2.0: {}
|
|
||||||
|
|
||||||
ufo@1.6.3: {}
|
ufo@1.6.3: {}
|
||||||
|
|
||||||
undici-types@7.18.2: {}
|
undici-types@7.18.2: {}
|
||||||
@@ -6285,11 +6288,6 @@ snapshots:
|
|||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
wordwrapjs@4.0.1:
|
|
||||||
dependencies:
|
|
||||||
reduce-flatten: 2.0.0
|
|
||||||
typical: 5.2.0
|
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
ws@8.19.0: {}
|
ws@8.19.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user