Compare commits
75 Commits
24f53983d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b660dde9e | |||
| a5ad58ac7f | |||
| 3e21b88e07 | |||
| c3436233f4 | |||
| b3596d0b0a | |||
| 9b8b07c653 | |||
| 22a2e63687 | |||
| a05a96a8aa | |||
| d2deb3a218 | |||
| d0f0d865b6 | |||
| a30692b1ac | |||
| 60531771cf | |||
| bb6bf7ca11 | |||
| fdc16957a4 | |||
| f8cb365e09 | |||
| ad4f5b3700 | |||
| 3fd876180a | |||
| c5b04be981 | |||
| 96cffb9be1 | |||
| 9b1771ed6a | |||
| b842106e44 | |||
| 9abcd715d7 | |||
| ab0af9a773 | |||
| fbd2efa994 | |||
| 79932157bf | |||
| 04b0ec1a71 | |||
| cc693d8be7 | |||
| 52aa00dd13 | |||
| 8085b40af8 | |||
| 5f40a812d3 | |||
| 1b724e86c9 | |||
| a9e4ed6049 | |||
| 66179d7ba8 | |||
| 3a8fa7d8ce | |||
| fddc3f15d0 | |||
| d9a60f0572 | |||
| ba648c796a | |||
| 27e2ff5f66 | |||
| b7a29c55b3 | |||
| 99b2ed7f2b | |||
| 8357aecf98 | |||
| ab3d9f4118 | |||
| 5219fae36a | |||
| 7de1bf7a03 | |||
| a4fd1ff18b | |||
| 6605980a43 | |||
| 15d9708072 | |||
| 89c4c390fa | |||
| f5ff59b910 | |||
| fc97c1b84b | |||
| e2abb0794a | |||
| 2644e033b4 | |||
| ee1cea6d01 | |||
| 1496399b96 | |||
| 075f64f4e3 | |||
| 8c6c98d612 | |||
| 28be084781 | |||
| 21b8d2c223 | |||
| b315062d43 | |||
| 5bef996dbc | |||
| da2484d232 | |||
| 722392d19e | |||
| a07a5cb091 | |||
| ea23233645 | |||
| 6dcdc0130b | |||
| 8508e1f6e9 | |||
| 6abcfc7363 | |||
| d4b3968518 | |||
| 8f4999f127 | |||
| 4b53a25fa3 | |||
| 4f85637875 | |||
| 1175b4d0e6 | |||
| 2afa3c6e9b | |||
| b55cebea4e | |||
| 9845553d49 |
@@ -7,9 +7,19 @@ on:
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "packages/backend/**"
|
||||
- "packages/types/**"
|
||||
- "packages/email/**"
|
||||
- "Dockerfile.backend"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/backend/**"
|
||||
- "packages/types/**"
|
||||
- "packages/email/**"
|
||||
- "Dockerfile.backend"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -40,6 +50,8 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
|
||||
@@ -48,6 +48,8 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
|
||||
@@ -7,9 +7,17 @@ on:
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "packages/frontend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/frontend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -40,6 +48,8 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# ============================================================================
|
||||
# Base stage - shared dependencies
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS base
|
||||
FROM node:22.14.0-slim AS base
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN npm install -g corepack@latest && corepack enable
|
||||
@@ -32,8 +32,11 @@ COPY packages ./packages
|
||||
# Install all dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Generate SvelteKit type definitions (creates .svelte-kit/tsconfig.json)
|
||||
RUN pnpm --filter @sexy/frontend exec svelte-kit sync
|
||||
|
||||
# Build frontend
|
||||
RUN pnpm --filter @sexy.pivoine.art/frontend build
|
||||
RUN pnpm --filter @sexy/frontend build
|
||||
|
||||
# Prune dev dependencies for production
|
||||
RUN CI=true pnpm install -rP
|
||||
@@ -41,7 +44,7 @@ RUN CI=true pnpm install -rP
|
||||
# ============================================================================
|
||||
# Runner stage - minimal production image
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS runner
|
||||
FROM node:22.14.0-slim AS runner
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# ============================================================================
|
||||
# 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
|
||||
|
||||
@@ -16,25 +16,29 @@ COPY packages/backend/package.json ./packages/backend/package.json
|
||||
COPY packages/frontend/package.json ./packages/frontend/package.json
|
||||
COPY packages/buttplug/package.json ./packages/buttplug/package.json
|
||||
COPY packages/types/package.json ./packages/types/package.json
|
||||
COPY packages/email/package.json ./packages/email/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --ignore-scripts
|
||||
RUN pnpm install --frozen-lockfile --filter @sexy/backend --filter @sexy/email --ignore-scripts
|
||||
|
||||
# Rebuild native bindings (argon2, sharp)
|
||||
RUN pnpm rebuild argon2 sharp
|
||||
|
||||
COPY packages/types ./packages/types
|
||||
COPY packages/email ./packages/email
|
||||
COPY packages/backend ./packages/backend
|
||||
|
||||
RUN pnpm --filter @sexy.pivoine.art/backend build
|
||||
RUN pnpm --filter @sexy/email build
|
||||
|
||||
RUN CI=true pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --prod --ignore-scripts
|
||||
RUN pnpm --filter @sexy/backend build
|
||||
|
||||
RUN CI=true pnpm install --frozen-lockfile --filter @sexy/backend --prod --ignore-scripts
|
||||
|
||||
RUN pnpm rebuild argon2 sharp
|
||||
|
||||
# ============================================================================
|
||||
# Runner stage
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS runner
|
||||
FROM node:22.14.0-slim AS runner
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
dumb-init \
|
||||
@@ -48,7 +52,7 @@ RUN userdel -r node && \
|
||||
|
||||
WORKDIR /home/node/app
|
||||
|
||||
RUN mkdir -p packages/backend
|
||||
RUN mkdir -p packages/backend packages/email
|
||||
|
||||
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=node:node /app/package.json ./package.json
|
||||
@@ -56,6 +60,11 @@ COPY --from=builder --chown=node:node /app/packages/backend/dist ./packages/back
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/node_modules ./packages/backend/node_modules
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/package.json ./packages/backend/package.json
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/dist/migrations
|
||||
COPY --from=builder --chown=node:node /app/packages/email/dist ./packages/email/dist
|
||||
COPY --from=builder --chown=node:node /app/packages/email/node_modules ./packages/email/node_modules
|
||||
COPY --from=builder --chown=node:node /app/packages/email/email.css ./packages/email/email.css
|
||||
COPY --from=builder --chown=node:node /app/packages/email/templates ./packages/email/templates
|
||||
COPY --from=builder --chown=node:node /app/packages/email/package.json ./packages/email/package.json
|
||||
|
||||
RUN mkdir -p /data/uploads && chown node:node /data/uploads
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# ============================================================================
|
||||
# Builder stage - compile Rust/WASM and TypeScript
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS builder
|
||||
FROM node:22.14.0-slim AS builder
|
||||
|
||||
# Install build dependencies for Rust
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -35,14 +35,14 @@ 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
|
||||
RUN pnpm install --frozen-lockfile --filter @sexy/buttplug
|
||||
|
||||
# Build WASM
|
||||
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
|
||||
pnpm --filter @sexy/buttplug build:wasm
|
||||
|
||||
# Build TypeScript
|
||||
RUN pnpm --filter @sexy.pivoine.art/buttplug build
|
||||
RUN pnpm --filter @sexy/buttplug build
|
||||
|
||||
# ============================================================================
|
||||
# Runner stage - nginx serving dist/ and wasm/
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Palina & Valknar
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
40
README.md
40
README.md
@@ -1,4 +1,4 @@
|
||||
# 💋 sexy.pivoine.art
|
||||
# 💋 SEXY
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -15,8 +15,9 @@ Built with passion, technology, and the fearless spirit of sexual empowerment
|
||||
|
||||
[](https://dev.pivoine.art/valknar/sexy/actions)
|
||||
[](https://dev.pivoine.art/valknar/sexy/actions)
|
||||
[](https://dev.pivoine.art/valknar/sexy/actions)
|
||||
[](LICENSE)
|
||||
[](https://sexy.pivoine.art)
|
||||
[](https://pivoine.art)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -24,7 +25,7 @@ Built with passion, technology, and the fearless spirit of sexual empowerment
|
||||
|
||||
## 👅 What Is This Delicious Creation?
|
||||
|
||||
Welcome, dear pleasure-seeker! This is **sexy.pivoine.art** — a modern, sensual platform built from the ground up with full control over every intimate detail. A **SvelteKit** frontend caresses a purpose-built **Fastify + GraphQL** backend, while **Buttplug.io** hardware integration brings the experience into the physical world.
|
||||
Welcome, dear pleasure-seeker! This is **sexy** — a modern, sensual platform built from the ground up with full control over every intimate detail. A **SvelteKit** frontend caresses a purpose-built **Fastify + GraphQL** backend, while **Buttplug.io** hardware integration brings the experience into the physical world.
|
||||
|
||||
Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom to explore, create, and celebrate sexuality without shame. This platform is built for **models**, **creators**, and **connoisseurs** of adult content who deserve technology as sophisticated as their desires.
|
||||
|
||||
@@ -39,6 +40,7 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
|
||||
- 🌍 **Internationalization** — pleasure speaks all languages
|
||||
- 🏆 **Gamification** — achievements, leaderboards, and reward points
|
||||
- 💬 **Comments & Social** — build your community
|
||||
- 💌 **Professional HTML Emails** — Maizzle v6 + Tailwind CSS 4 templated email (verification, password reset)
|
||||
- 📊 **Analytics Integration** (Umami) — know your admirers
|
||||
- 🐳 **Self-hosted CI/CD** via Gitea Actions on `dev.pivoine.art`
|
||||
|
||||
@@ -72,6 +74,11 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
|
||||
│ ├─ TypeScript + Rust → Power and precision │
|
||||
│ └─ WebBluetooth API → Wireless intimacy │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 💌 Email Layer │
|
||||
│ ├─ Maizzle v6 → HTML email framework │
|
||||
│ ├─ @maizzle/tailwindcss → Email-safe Tailwind CSS 4 │
|
||||
│ └─ Nodemailer → SMTP delivery │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 🌸 DevOps Layer │
|
||||
│ ├─ Docker → Containerized ecstasy │
|
||||
│ ├─ Gitea Actions → Self-hosted seduction │
|
||||
@@ -88,7 +95,7 @@ Like Beate Uhse breaking barriers in post-war Germany, we believe in the freedom
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://dev.pivoine.art/valknar/sexy.git
|
||||
cd sexy.pivoine.art
|
||||
cd sexy
|
||||
|
||||
# Configure your secrets
|
||||
cp .env.example .env
|
||||
@@ -119,7 +126,7 @@ pnpm dev:data
|
||||
pnpm dev:backend
|
||||
|
||||
# Start the frontend (port 3000, proxied to :4000)
|
||||
pnpm --filter @sexy.pivoine.art/frontend dev
|
||||
pnpm --filter @sexy/frontend dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000` and let the experience begin... 💋
|
||||
@@ -130,13 +137,14 @@ GraphQL playground is available at `http://localhost:4000/graphql` — explore e
|
||||
|
||||
## 🌹 Project Structure
|
||||
|
||||
This monorepo contains three packages, each serving its purpose:
|
||||
This monorepo contains four packages, each serving its purpose:
|
||||
|
||||
```
|
||||
sexy.pivoine.art/
|
||||
sexy/
|
||||
├─ 💄 packages/frontend/ → SvelteKit app (the seduction)
|
||||
├─ ⚡ packages/backend/ → Fastify + GraphQL API (the engine)
|
||||
└─ 🎮 packages/buttplug/ → Hardware control (the connection)
|
||||
├─ 🎮 packages/buttplug/ → Hardware control (the connection)
|
||||
└─ 💌 packages/email/ → Maizzle HTML email templates
|
||||
```
|
||||
|
||||
### 💄 Frontend (`packages/frontend/`)
|
||||
@@ -156,6 +164,13 @@ Files stored as `<UPLOAD_DIR>/<uuid>/<filename>` with on-demand WebP transforms
|
||||
Hybrid TypeScript/Rust package for intimate hardware control via WebBluetooth.
|
||||
Compiled to WebAssembly for browser-based Bluetooth device communication.
|
||||
|
||||
### 💌 Email (`packages/email/`)
|
||||
|
||||
Professional HTML email templates built with **Maizzle v6** + **Tailwind CSS 4** (`@maizzle/tailwindcss`).
|
||||
Design tokens mirror the frontend's `app.css` exactly — same oklch colors, Noto Sans font, semantic classes.
|
||||
LightningCSS automatically converts oklch values to hex for email client compatibility.
|
||||
Exported functions: `renderVerification({ token })` and `renderPasswordReset({ token })` — each returns `{ subject, html }`.
|
||||
|
||||
---
|
||||
|
||||
## 🗃️ Database Schema
|
||||
@@ -241,6 +256,7 @@ Automated builds run on **[dev.pivoine.art](https://dev.pivoine.art/valknar/sexy
|
||||
|
||||
- ✅ Frontend image → `dev.pivoine.art/valknar/sexy:latest`
|
||||
- ✅ Backend image → `dev.pivoine.art/valknar/sexy-backend:latest`
|
||||
- ✅ Buttplug image → `dev.pivoine.art/valknar/sexy-buttplug:latest`
|
||||
- ✅ Triggers on push to `main`, `develop`, or version tags (`v*.*.*`)
|
||||
- ✅ Build cache via registry for fast successive builds
|
||||
|
||||
@@ -312,7 +328,7 @@ graph LR
|
||||
|
||||
### 🌸 Created with Love by 🌸
|
||||
|
||||
**[Palina](https://sexy.pivoine.art) & [Valknar](https://sexy.pivoine.art)**
|
||||
**[Palina](https://palina.pivoine.art) & [Valknar](https://pivoine.art)**
|
||||
|
||||
_Für die Mäuse..._ 🐭💕
|
||||
|
||||
@@ -329,6 +345,8 @@ _Für die Mäuse..._ 🐭💕
|
||||
| [Drizzle ORM](https://orm.drizzle.team/) | Database |
|
||||
| [Sharp](https://sharp.pixelplumbing.com/) | Image transforms |
|
||||
| [Buttplug.io](https://buttplug.io/) | Hardware |
|
||||
| [Maizzle](https://maizzle.com/) | HTML email framework |
|
||||
| [Nodemailer](https://nodemailer.com/) | Email delivery |
|
||||
| [bits-ui](https://www.bits-ui.com/) | UI components |
|
||||
| [Gitea](https://dev.pivoine.art) | Self-hosted VCS & CI |
|
||||
|
||||
@@ -362,7 +380,7 @@ _"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."_
|
||||
|
||||
[](https://dev.pivoine.art/valknar/sexy)
|
||||
[](https://dev.pivoine.art/valknar/sexy/issues)
|
||||
[](https://sexy.pivoine.art)
|
||||
[](https://pivoine.art)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -383,6 +401,6 @@ _"Eine Frau, die ihre Sexualität selbstbestimmt lebt, ist eine freie Frau."_
|
||||
|
||||
_Pleasure is a human right. Technology is freedom. Together, they are power._
|
||||
|
||||
**[sexy.pivoine.art](https://sexy.pivoine.art)** | © 2025 Palina & Valknar
|
||||
**[pivoine.art](https://pivoine.art)** | © 2026 Palina & Valknar
|
||||
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ services:
|
||||
COOKIE_SECRET: change-me-in-production
|
||||
SMTP_HOST: localhost
|
||||
SMTP_PORT: 587
|
||||
EMAIL_FROM: noreply@sexy.pivoine.art
|
||||
EMAIL_FROM: noreply@sexy
|
||||
PUBLIC_URL: http://localhost:3000
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
14
package.json
14
package.json
@@ -5,17 +5,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
|
||||
"build:backend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/backend build",
|
||||
"dev:buttplug": "pnpm --filter @sexy.pivoine.art/buttplug serve",
|
||||
"build:frontend": "pnpm --filter @sexy/frontend build",
|
||||
"build:backend": "pnpm --filter @sexy/backend build",
|
||||
"dev:buttplug": "pnpm --filter @sexy/buttplug serve",
|
||||
"dev:data": "docker compose up -d postgres redis",
|
||||
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
|
||||
"dev": "pnpm dev:data && pnpm dev:backend & pnpm dev:buttplug & pnpm --filter @sexy.pivoine.art/frontend dev",
|
||||
"dev:backend": "pnpm --filter @sexy/backend dev",
|
||||
"dev": "pnpm dev:data && pnpm dev:backend & pnpm dev:buttplug & pnpm --filter @sexy/frontend dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "pnpm -r --filter=!sexy.pivoine.art check"
|
||||
"check": "pnpm -r --filter=!sexy check"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": {
|
||||
@@ -23,7 +23,7 @@
|
||||
"email": "valknar@pivoine.art"
|
||||
},
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"argon2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/backend",
|
||||
"name": "@sexy/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -14,14 +14,16 @@
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sexy.pivoine.art/types": "workspace:*",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@pothos/core": "^4.4.0",
|
||||
"@pothos/plugin-errors": "^4.2.0",
|
||||
"@sexy/email": "workspace:*",
|
||||
"@sexy/types": "workspace:*",
|
||||
"argon2": "^0.43.0",
|
||||
"bullmq": "^5.70.4",
|
||||
"drizzle-orm": "^0.44.1",
|
||||
"fastify": "^5.4.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
pgEnum,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users } from "./users";
|
||||
import { recordings } from "./recordings";
|
||||
|
||||
@@ -68,6 +69,11 @@ export const user_points = pgTable(
|
||||
(t) => [
|
||||
index("user_points_user_idx").on(t.user_id),
|
||||
index("user_points_date_idx").on(t.date_created),
|
||||
uniqueIndex("user_points_unique_action_recording")
|
||||
.on(t.user_id, t.action, t.recording_id)
|
||||
.where(
|
||||
sql`"action" IN ('RECORDING_CREATE', 'RECORDING_FEATURED') AND "recording_id" IS NOT NULL`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./resolvers/recordings.js";
|
||||
import "./resolvers/comments.js";
|
||||
import "./resolvers/gamification.js";
|
||||
import "./resolvers/stats.js";
|
||||
import "./resolvers/queues.js";
|
||||
import { builder } from "./builder";
|
||||
|
||||
export const schema = builder.toSchema();
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ReplyLike {
|
||||
}
|
||||
import { hash, verify as verifyArgon } from "../../lib/argon";
|
||||
import { setSession, deleteSession } from "../../lib/auth";
|
||||
import { sendVerification, sendPasswordReset } from "../../lib/email";
|
||||
import { enqueueVerification, enqueuePasswordReset } from "../../lib/email";
|
||||
import { slugify } from "../../lib/slugify";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -131,9 +131,9 @@ builder.mutationField("register", (t) =>
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVerification(args.email, verifyToken);
|
||||
await enqueueVerification(args.email, verifyToken);
|
||||
} 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;
|
||||
},
|
||||
@@ -190,9 +190,9 @@ builder.mutationField("requestPasswordReset", (t) =>
|
||||
.where(eq(users.id, user[0].id));
|
||||
|
||||
try {
|
||||
await sendPasswordReset(args.email, token);
|
||||
await enqueuePasswordReset(args.email, token);
|
||||
} 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;
|
||||
},
|
||||
|
||||
@@ -3,8 +3,8 @@ import { builder } from "../builder";
|
||||
import { CommentType, AdminCommentListType } from "../types/index";
|
||||
import { comments, users } from "../../db/schema/index";
|
||||
import { eq, and, desc, ilike, count } from "drizzle-orm";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
||||
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
|
||||
import { gamificationQueue } from "../../queues/index";
|
||||
|
||||
builder.queryField("commentsForVideo", (t) =>
|
||||
t.field({
|
||||
@@ -59,10 +59,16 @@ builder.mutationField("createCommentForVideo", (t) =>
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Gamification (non-blocking)
|
||||
awardPoints(ctx.db, ctx.currentUser.id, "COMMENT_CREATE")
|
||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "social"))
|
||||
.catch((e) => console.error("Gamification error on comment:", e));
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "COMMENT_CREATE",
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "social",
|
||||
});
|
||||
|
||||
const user = await ctx.db
|
||||
.select({
|
||||
@@ -92,6 +98,18 @@ builder.mutationField("deleteComment", (t) =>
|
||||
if (!comment[0]) throw new GraphQLError("Comment not found");
|
||||
requireOwnerOrAdmin(ctx, comment[0].user_id);
|
||||
await ctx.db.delete(comments).where(eq(comments.id, args.id));
|
||||
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: comment[0].user_id,
|
||||
action: "COMMENT_CREATE",
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: comment[0].user_id,
|
||||
category: "social",
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
|
||||
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 { eq, and, desc, ilike, count, type SQL } from "drizzle-orm";
|
||||
import { slugify } from "../../lib/slugify";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
import { gamificationQueue } from "../../queues/index";
|
||||
|
||||
builder.queryField("recordings", (t) =>
|
||||
t.field({
|
||||
@@ -122,11 +122,18 @@ builder.mutationField("createRecording", (t) =>
|
||||
|
||||
const recording = newRecording[0];
|
||||
|
||||
// Gamification (non-blocking)
|
||||
if (recording.status === "published") {
|
||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
|
||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
||||
.catch((e) => console.error("Gamification error on recording create:", e));
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: recording.id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "recordings",
|
||||
});
|
||||
}
|
||||
|
||||
return recording;
|
||||
@@ -180,15 +187,45 @@ builder.mutationField("updateRecording", (t) =>
|
||||
|
||||
const recording = updated[0];
|
||||
|
||||
// Gamification (non-blocking)
|
||||
if (args.status === "published" && existing[0].status !== "published") {
|
||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_CREATE", recording.id)
|
||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
||||
.catch((e) => console.error("Gamification error on recording publish:", e));
|
||||
// draft → published: award creation points
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: recording.id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "recordings",
|
||||
});
|
||||
} else if (args.status === "draft" && existing[0].status === "published") {
|
||||
// published → draft: revoke creation points
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: recording.id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "recordings",
|
||||
});
|
||||
} else if (args.status === "published" && recording.featured && !existing[0].featured) {
|
||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_FEATURED", recording.id)
|
||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "recordings"))
|
||||
.catch((e) => console.error("Gamification error on recording feature:", e));
|
||||
// newly featured while published: award featured bonus
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_FEATURED",
|
||||
recordingId: recording.id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "recordings",
|
||||
});
|
||||
}
|
||||
|
||||
return recording;
|
||||
@@ -214,6 +251,28 @@ builder.mutationField("deleteRecording", (t) =>
|
||||
if (!existing[0]) throw new GraphQLError("Recording not found");
|
||||
if (existing[0].user_id !== ctx.currentUser.id) throw new GraphQLError("Forbidden");
|
||||
|
||||
if (existing[0].status === "published") {
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_CREATE",
|
||||
recordingId: args.id,
|
||||
});
|
||||
if (existing[0].featured) {
|
||||
await gamificationQueue.add("revokePoints", {
|
||||
job: "revokePoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_FEATURED",
|
||||
recordingId: args.id,
|
||||
});
|
||||
}
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "content",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(recordings).where(eq(recordings.id, args.id));
|
||||
|
||||
return true;
|
||||
@@ -290,11 +349,18 @@ builder.mutationField("recordRecordingPlay", (t) =>
|
||||
})
|
||||
.returning({ id: recording_plays.id });
|
||||
|
||||
// Gamification (non-blocking)
|
||||
if (ctx.currentUser && recording[0].user_id !== ctx.currentUser.id) {
|
||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_PLAY", args.recordingId)
|
||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
|
||||
.catch((e) => console.error("Gamification error on recording play:", e));
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_PLAY",
|
||||
recordingId: args.recordingId,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "playback",
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, play_id: play[0].id };
|
||||
@@ -329,11 +395,18 @@ builder.mutationField("updateRecordingPlay", (t) =>
|
||||
})
|
||||
.where(eq(recording_plays.id, args.playId));
|
||||
|
||||
// Gamification (non-blocking)
|
||||
if (args.completed && !wasCompleted && ctx.currentUser) {
|
||||
awardPoints(ctx.db, ctx.currentUser.id, "RECORDING_COMPLETE", existing[0].recording_id)
|
||||
.then(() => checkAchievements(ctx.db, ctx.currentUser!.id, "playback"))
|
||||
.catch((e) => console.error("Gamification error on recording complete:", e));
|
||||
await gamificationQueue.add("awardPoints", {
|
||||
job: "awardPoints",
|
||||
userId: ctx.currentUser.id,
|
||||
action: "RECORDING_COMPLETE",
|
||||
recordingId: existing[0].recording_id,
|
||||
});
|
||||
await gamificationQueue.add("checkAchievements", {
|
||||
job: "checkAchievements",
|
||||
userId: ctx.currentUser.id,
|
||||
category: "playback",
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
RecentPoint,
|
||||
UserGamification,
|
||||
Achievement,
|
||||
} from "@sexy.pivoine.art/types";
|
||||
} from "@sexy/types";
|
||||
|
||||
type AdminUserDetail = User & { photos: ModelPhoto[] };
|
||||
import { builder } from "../builder";
|
||||
@@ -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
|
||||
.objectRef<{ items: Video[]; total: number }>("VideoList")
|
||||
.implement({
|
||||
|
||||
@@ -16,6 +16,8 @@ import { db } from "./db/connection";
|
||||
import { redis } from "./lib/auth";
|
||||
import { logger } from "./lib/logger";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { startMailWorker } from "./queues/workers/mail";
|
||||
import { startGamificationWorker } from "./queues/workers/gamification";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "4000");
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
||||
@@ -28,6 +30,11 @@ async function main() {
|
||||
await migrate(db, { migrationsFolder });
|
||||
logger.info("Migrations complete");
|
||||
|
||||
// Start background workers
|
||||
startMailWorker();
|
||||
startGamificationWorker();
|
||||
logger.info("Queue workers started");
|
||||
|
||||
const fastify = Fastify({ loggerInstance: logger });
|
||||
|
||||
await fastify.register(fastifyCookie, {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { renderVerification, renderPasswordReset } from "@sexy/email";
|
||||
import { mailQueue } from "../queues/index.js";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || "localhost",
|
||||
@@ -12,23 +14,24 @@ const transporter = nodemailer.createTransport({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
|
||||
const BASE_URL = process.env.PUBLIC_URL || "http://localhost:3000";
|
||||
const FROM = process.env.EMAIL_FROM || "noreply@sexy";
|
||||
|
||||
export async function sendVerification(email: string, token: string): Promise<void> {
|
||||
await transporter.sendMail({
|
||||
from: FROM,
|
||||
to: email,
|
||||
subject: "Verify your email",
|
||||
html: `<p>Click <a href="${BASE_URL}/signup/verify?token=${token}">here</a> to verify your email.</p>`,
|
||||
});
|
||||
const { subject, html } = await renderVerification({ token });
|
||||
await transporter.sendMail({ from: FROM, to: email, subject, html });
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(email: string, token: string): Promise<void> {
|
||||
await transporter.sendMail({
|
||||
from: FROM,
|
||||
to: email,
|
||||
subject: "Reset your password",
|
||||
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`,
|
||||
});
|
||||
const { subject, html } = await renderPasswordReset({ token });
|
||||
await transporter.sendMail({ from: FROM, to: email, subject, html });
|
||||
}
|
||||
|
||||
const jobOpts = { attempts: 3, backoff: { type: "exponential" as const, delay: 5000 } };
|
||||
|
||||
export async function enqueueVerification(email: string, token: string): Promise<void> {
|
||||
await mailQueue.add("sendVerification", { email, token }, jobOpts);
|
||||
}
|
||||
|
||||
export async function enqueuePasswordReset(email: string, token: string): Promise<void> {
|
||||
await mailQueue.add("sendPasswordReset", { email, token }, jobOpts);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, sql, and, gt, isNotNull, count, sum } from "drizzle-orm";
|
||||
import { eq, sql, and, gt, isNull, isNotNull, count, sum } from "drizzle-orm";
|
||||
import type { DB } from "../db/connection";
|
||||
import {
|
||||
user_points,
|
||||
@@ -28,21 +28,57 @@ export async function awardPoints(
|
||||
recordingId?: string,
|
||||
): Promise<void> {
|
||||
const points = POINT_VALUES[action];
|
||||
await db.insert(user_points).values({
|
||||
await db
|
||||
.insert(user_points)
|
||||
.values({
|
||||
user_id: userId,
|
||||
action,
|
||||
points,
|
||||
recording_id: recordingId || null,
|
||||
date_created: new Date(),
|
||||
});
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await updateUserStats(db, userId);
|
||||
}
|
||||
|
||||
export async function revokePoints(
|
||||
db: DB,
|
||||
userId: string,
|
||||
action: keyof typeof POINT_VALUES,
|
||||
recordingId?: string,
|
||||
): Promise<void> {
|
||||
const recordingCondition = recordingId
|
||||
? eq(user_points.recording_id, recordingId)
|
||||
: isNull(user_points.recording_id);
|
||||
|
||||
// When no recordingId (e.g. COMMENT_CREATE), delete only one row so each
|
||||
// revoke undoes exactly one prior award.
|
||||
if (!recordingId) {
|
||||
const row = await db
|
||||
.select({ id: user_points.id })
|
||||
.from(user_points)
|
||||
.where(
|
||||
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
|
||||
)
|
||||
.limit(1);
|
||||
if (row[0]) {
|
||||
await db.delete(user_points).where(eq(user_points.id, row[0].id));
|
||||
}
|
||||
} else {
|
||||
await db
|
||||
.delete(user_points)
|
||||
.where(
|
||||
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
|
||||
);
|
||||
}
|
||||
|
||||
await updateUserStats(db, userId);
|
||||
}
|
||||
|
||||
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
|
||||
const now = new Date();
|
||||
const result = await db.execute(sql`
|
||||
SELECT SUM(
|
||||
points * EXP(-${DECAY_LAMBDA} * EXTRACT(EPOCH FROM (${now}::timestamptz - date_created)) / 86400)
|
||||
points * EXP(${sql.raw(String(-DECAY_LAMBDA))} * EXTRACT(EPOCH FROM (NOW() - date_created)) / 86400)
|
||||
) as weighted_score
|
||||
FROM user_points
|
||||
WHERE user_id = ${userId}
|
||||
@@ -96,7 +132,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||
const commentsResult = await db
|
||||
.select({ count: count() })
|
||||
.from(comments)
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
|
||||
const commentsCount = commentsResult[0]?.count || 0;
|
||||
|
||||
const achievementsResult = await db
|
||||
@@ -175,7 +211,9 @@ export async function checkAchievements(db: DB, userId: string, category?: strin
|
||||
.update(user_achievements)
|
||||
.set({
|
||||
progress,
|
||||
date_unlocked: isUnlocked ? existing[0].date_unlocked || new Date() : null,
|
||||
date_unlocked: isUnlocked
|
||||
? (existing[0].date_unlocked ?? new Date())
|
||||
: existing[0].date_unlocked,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
@@ -257,7 +295,7 @@ async function getAchievementProgress(
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(comments)
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "recordings")));
|
||||
.where(and(eq(comments.user_id, userId), eq(comments.collection, "videos")));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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/
|
||||
1394
packages/buttplug/dist/index.js
vendored
1394
packages/buttplug/dist/index.js
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/buttplug",
|
||||
"name": "@sexy/buttplug",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -146,7 +146,6 @@ export class ButtplugClientDeviceFeature {
|
||||
// Make sure the requested feature is valid
|
||||
this.isInputValid(inputType);
|
||||
const inputAttributes = this._feature.Input[inputType];
|
||||
console.log(this._feature.Input);
|
||||
if (
|
||||
inputCommand === Messages.InputCommandType.Unsubscribe &&
|
||||
!inputAttributes.Command.includes(Messages.InputCommandType.Subscribe) &&
|
||||
|
||||
@@ -23,6 +23,7 @@ type FFICallback = js_sys::Function;
|
||||
type FFICallbackContext = u32;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[allow(dead_code)]
|
||||
pub struct FFICallbackContextWrapper(FFICallbackContext);
|
||||
|
||||
unsafe impl Send for FFICallbackContextWrapper {
|
||||
@@ -50,7 +51,7 @@ pub fn send_server_message(
|
||||
let buf = json.as_bytes();
|
||||
let this = JsValue::null();
|
||||
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
|
||||
callback.call1(&this, &JsValue::from(uint8buf));
|
||||
let _ = callback.call1(&this, &JsValue::from(uint8buf));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ pub fn buttplug_client_send_json_message(
|
||||
let buf = json.as_bytes();
|
||||
let this = JsValue::null();
|
||||
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
|
||||
callback.call1(&this, &JsValue::from(uint8buf));
|
||||
let _ = callback.call1(&this, &JsValue::from(uint8buf));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ impl HardwareSpecializer for WebBluetoothHardwareSpecializer {
|
||||
pub enum WebBluetoothEvent {
|
||||
// This is the only way we have to get our endpoints back to device creation
|
||||
// right now. My god this is a mess.
|
||||
#[allow(dead_code)]
|
||||
Connected(Vec<Endpoint>),
|
||||
Disconnected,
|
||||
}
|
||||
@@ -201,6 +202,7 @@ pub enum WebBluetoothDeviceCommand {
|
||||
HardwareSubscribeCmd,
|
||||
oneshot::Sender<Result<(), ButtplugDeviceError>>,
|
||||
),
|
||||
#[allow(dead_code)]
|
||||
Unsubscribe(
|
||||
HardwareUnsubscribeCmd,
|
||||
oneshot::Sender<Result<(), ButtplugDeviceError>>,
|
||||
@@ -271,7 +273,7 @@ async fn run_webbluetooth_loop(
|
||||
//let web_btle_device = WebBluetoothDeviceImpl::new(device, char_map);
|
||||
info!("device created!");
|
||||
let endpoints = char_map.keys().into_iter().cloned().collect();
|
||||
device_local_event_sender
|
||||
let _ = device_local_event_sender
|
||||
.send(WebBluetoothEvent::Connected(endpoints))
|
||||
.await;
|
||||
while let Some(msg) = device_command_receiver.recv().await {
|
||||
@@ -337,6 +339,7 @@ async fn run_webbluetooth_loop(
|
||||
#[derive(Debug)]
|
||||
pub struct WebBluetoothHardware {
|
||||
device_command_sender: mpsc::Sender<WebBluetoothDeviceCommand>,
|
||||
#[allow(dead_code)]
|
||||
device_event_receiver: mpsc::Receiver<WebBluetoothEvent>,
|
||||
event_sender: broadcast::Sender<HardwareEvent>,
|
||||
}
|
||||
|
||||
1
packages/buttplug/wasm/.gitignore
vendored
1
packages/buttplug/wasm/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
*
|
||||
64
packages/buttplug/wasm/index.d.ts
vendored
64
packages/buttplug/wasm/index.d.ts
vendored
@@ -1,64 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function buttplug_activate_env_logger(_max_level: string): void;
|
||||
|
||||
export function buttplug_client_send_json_message(server_ptr: number, buf: Uint8Array, callback: Function): void;
|
||||
|
||||
export function buttplug_create_embedded_wasm_server(callback: Function): number;
|
||||
|
||||
export function buttplug_free_embedded_wasm_server(ptr: number): void;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly create_test_dcm: (a: number, b: number) => void;
|
||||
readonly buttplug_activate_env_logger: (a: number, b: number) => void;
|
||||
readonly buttplug_client_send_json_message: (a: number, b: number, c: number, d: any) => void;
|
||||
readonly buttplug_create_embedded_wasm_server: (a: any) => number;
|
||||
readonly buttplug_free_embedded_wasm_server: (a: number) => void;
|
||||
readonly wasm_bindgen__closure__destroy__h72b504abf7ea70fd: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__closure__destroy__h0f95d90d24796def: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd: (a: number, b: number) => void;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __externref_table_alloc: () => number;
|
||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||
readonly __wbindgen_exn_store: (a: number) => void;
|
||||
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
readonly __externref_table_dealloc: (a: number) => void;
|
||||
readonly __wbindgen_start: () => void;
|
||||
}
|
||||
|
||||
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||
|
||||
/**
|
||||
* Instantiates the given `module`, which can either be bytes or
|
||||
* a precompiled `WebAssembly.Module`.
|
||||
*
|
||||
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {InitOutput}
|
||||
*/
|
||||
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||
|
||||
/**
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||
@@ -1,814 +0,0 @@
|
||||
/* @ts-self-types="./index.d.ts" */
|
||||
|
||||
/**
|
||||
* @param {string} _max_level
|
||||
*/
|
||||
export function buttplug_activate_env_logger(_max_level) {
|
||||
const ptr0 = passStringToWasm0(_max_level, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.buttplug_activate_env_logger(ptr0, len0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} server_ptr
|
||||
* @param {Uint8Array} buf
|
||||
* @param {Function} callback
|
||||
*/
|
||||
export function buttplug_client_send_json_message(server_ptr, buf, callback) {
|
||||
const ptr0 = passArray8ToWasm0(buf, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.buttplug_client_send_json_message(server_ptr, ptr0, len0, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Function} callback
|
||||
* @returns {number}
|
||||
*/
|
||||
export function buttplug_create_embedded_wasm_server(callback) {
|
||||
const ret = wasm.buttplug_create_embedded_wasm_server(callback);
|
||||
return ret >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ptr
|
||||
*/
|
||||
export function buttplug_free_embedded_wasm_server(ptr) {
|
||||
wasm.buttplug_free_embedded_wasm_server(ptr);
|
||||
}
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg___wbindgen_debug_string_a1b3fd0656850da8: function(arg0, arg1) {
|
||||
const ret = debugString(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_is_function_82aa5b8e9371b250: function(arg0) {
|
||||
const ret = typeof(arg0) === 'function';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_object_61452b678ecf7ecf: function(arg0) {
|
||||
const val = arg0;
|
||||
const ret = typeof(val) === 'object' && val !== null;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_string_91960b7ba9d4d76b: function(arg0) {
|
||||
const ret = typeof(arg0) === 'string';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_undefined_7b12045c262a3121: function(arg0) {
|
||||
const ret = arg0 === undefined;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_throw_83ebd457a191bc2a: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg__wbg_cb_unref_4fc42a417bb095f4: function(arg0) {
|
||||
arg0._wbg_cb_unref();
|
||||
},
|
||||
__wbg_bluetooth_5967024a158f671e: function(arg0) {
|
||||
const ret = arg0.bluetooth;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_buffer_bfae6dde33a7e5a0: function(arg0) {
|
||||
const ret = arg0.buffer;
|
||||
return ret;
|
||||
},
|
||||
__wbg_byteLength_5fbecf2b9f6cc625: function(arg0) {
|
||||
const ret = arg0.byteLength;
|
||||
return ret;
|
||||
},
|
||||
__wbg_call_72a54043615c73e3: function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.call(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_connect_61050e3a8e3f5f1c: function(arg0) {
|
||||
const ret = arg0.connect();
|
||||
return ret;
|
||||
},
|
||||
__wbg_crypto_38df2bab126b63dc: function(arg0) {
|
||||
const ret = arg0.crypto;
|
||||
return ret;
|
||||
},
|
||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
},
|
||||
__wbg_gatt_aeca65a254a75f07: function(arg0) {
|
||||
const ret = arg0.gatt;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_getCharacteristic_4daa5211272f941c: function(arg0, arg1, arg2) {
|
||||
const ret = arg0.getCharacteristic(getStringFromWasm0(arg1, arg2));
|
||||
return ret;
|
||||
},
|
||||
__wbg_getPrimaryService_8b6197119664f448: function(arg0, arg1, arg2) {
|
||||
const ret = arg0.getPrimaryService(getStringFromWasm0(arg1, arg2));
|
||||
return ret;
|
||||
},
|
||||
__wbg_getRandomValues_3f44b700395062e5: function() { return handleError(function (arg0, arg1) {
|
||||
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
|
||||
}, arguments); },
|
||||
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
|
||||
arg0.getRandomValues(arg1);
|
||||
}, arguments); },
|
||||
__wbg_getRandomValues_ef8a9e8b447216e2: function() { return handleError(function (arg0, arg1) {
|
||||
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
|
||||
}, arguments); },
|
||||
__wbg_get_bda2de250e7f67d3: function() { return handleError(function (arg0, arg1) {
|
||||
const ret = Reflect.get(arg0, arg1);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_id_c3a9ae039584e9f6: function(arg0, arg1) {
|
||||
const ret = arg1.id;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg_instanceof_Window_3bc43738919f4587: function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof Window;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
},
|
||||
__wbg_length_684e7f4ac265724c: function(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
},
|
||||
__wbg_log_0c201ade58bb55e1: function(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.log(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3), getStringFromWasm0(arg4, arg5), getStringFromWasm0(arg6, arg7));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
},
|
||||
__wbg_log_ce2c4456b290c5e7: function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.log(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
},
|
||||
__wbg_mark_b4d943f3bc2d2404: function(arg0, arg1) {
|
||||
performance.mark(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_measure_84362959e621a2c1: function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
let deferred1_0;
|
||||
let deferred1_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
deferred1_0 = arg2;
|
||||
deferred1_1 = arg3;
|
||||
performance.measure(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
|
||||
}
|
||||
}, arguments); },
|
||||
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
|
||||
const ret = arg0.msCrypto;
|
||||
return ret;
|
||||
},
|
||||
__wbg_name_0a4944b9b89a9be1: function(arg0, arg1) {
|
||||
const ret = arg1.name;
|
||||
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg_navigator_91b141c3f3b6b96b: function(arg0) {
|
||||
const ret = arg0.navigator;
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_18cda2e4779f118c: function(arg0) {
|
||||
const ret = new Uint8Array(arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_227d7c05414eb861: function() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_5c365a7570baea64: function() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_with_byte_offset_ba99c41da925551a: function(arg0, arg1) {
|
||||
const ret = new Uint8Array(arg0, arg1 >>> 0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_with_length_875a3f1ab82a1a1f: function(arg0) {
|
||||
const ret = new Uint8Array(arg0 >>> 0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_node_84ea875411254db1: function(arg0) {
|
||||
const ret = arg0.node;
|
||||
return ret;
|
||||
},
|
||||
__wbg_now_55c5352b4b61d145: function(arg0) {
|
||||
const ret = arg0.now();
|
||||
return ret;
|
||||
},
|
||||
__wbg_now_7627eff456aa5959: function(arg0) {
|
||||
const ret = arg0.now();
|
||||
return ret;
|
||||
},
|
||||
__wbg_performance_aa4d78060a5b8a2f: function(arg0) {
|
||||
const ret = arg0.performance;
|
||||
return ret;
|
||||
},
|
||||
__wbg_process_44c7a14e11e9f69e: function(arg0) {
|
||||
const ret = arg0.process;
|
||||
return ret;
|
||||
},
|
||||
__wbg_prototypesetcall_7c3092bff32833dc: function(arg0, arg1, arg2) {
|
||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
||||
},
|
||||
__wbg_queueMicrotask_17a58d631cc9ab4b: function(arg0) {
|
||||
queueMicrotask(arg0);
|
||||
},
|
||||
__wbg_queueMicrotask_4114767fcf2790b9: function(arg0) {
|
||||
const ret = arg0.queueMicrotask;
|
||||
return ret;
|
||||
},
|
||||
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
|
||||
arg0.randomFillSync(arg1);
|
||||
}, arguments); },
|
||||
__wbg_readValue_c83601164f2a0105: function(arg0) {
|
||||
const ret = arg0.readValue();
|
||||
return ret;
|
||||
},
|
||||
__wbg_requestDevice_26b08548120ca1d9: function(arg0, arg1) {
|
||||
const ret = arg0.requestDevice(arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
|
||||
const ret = module.require;
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_resolve_67a1b1ca24efbc5c: function(arg0) {
|
||||
const ret = Promise.resolve(arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_setTimeout_05a790c35d76ff25: function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.setTimeout(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_set_filters_7e7f78143ddef831: function(arg0, arg1, arg2) {
|
||||
arg0.filters = getArrayJsValueViewFromWasm0(arg1, arg2);
|
||||
},
|
||||
__wbg_set_name_be5429dc123f1dd9: function(arg0, arg1, arg2) {
|
||||
arg0.name = getStringFromWasm0(arg1, arg2);
|
||||
},
|
||||
__wbg_set_name_prefix_41b281c72726c519: function(arg0, arg1, arg2) {
|
||||
arg0.namePrefix = getStringFromWasm0(arg1, arg2);
|
||||
},
|
||||
__wbg_set_oncharacteristicvaluechanged_d54b71ecfbe76b96: function(arg0, arg1) {
|
||||
arg0.oncharacteristicvaluechanged = arg1;
|
||||
},
|
||||
__wbg_set_ongattserverdisconnected_960815a2e872d5a0: function(arg0, arg1) {
|
||||
arg0.ongattserverdisconnected = arg1;
|
||||
},
|
||||
__wbg_set_optional_services_e183aa24417dc7cb: function(arg0, arg1, arg2) {
|
||||
arg0.optionalServices = getArrayJsValueViewFromWasm0(arg1, arg2);
|
||||
},
|
||||
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg_startNotifications_acc2fc1198e7dd6f: function(arg0) {
|
||||
const ret = arg0.startNotifications();
|
||||
return ret;
|
||||
},
|
||||
__wbg_static_accessor_GLOBAL_833a66cb4996dbd8: function() {
|
||||
const ret = typeof global === 'undefined' ? null : global;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_GLOBAL_THIS_fc74cdbdccd80770: function() {
|
||||
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_SELF_066699022f35d48b: function() {
|
||||
const ret = typeof self === 'undefined' ? null : self;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_WINDOW_f821c7eb05393790: function() {
|
||||
const ret = typeof window === 'undefined' ? null : window;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_subarray_22ac454570db4e4f: function(arg0, arg1, arg2) {
|
||||
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_target_188bf8368fa5f001: function(arg0) {
|
||||
const ret = arg0.target;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_then_420f698ab0b99678: function(arg0, arg1) {
|
||||
const ret = arg0.then(arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbg_then_95c29fbd346ee84e: function(arg0, arg1, arg2) {
|
||||
const ret = arg0.then(arg1, arg2);
|
||||
return ret;
|
||||
},
|
||||
__wbg_value_d1ef9362b7bf7a47: function(arg0) {
|
||||
const ret = arg0.value;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_versions_276b2795b1c6a219: function(arg0) {
|
||||
const ret = arg0.versions;
|
||||
return ret;
|
||||
},
|
||||
__wbg_writeValue_99570c64ee612498: function() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.writeValue(arg1);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 3402, function: Function { arguments: [Externref], shim_idx: 3403, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h72b504abf7ea70fd, wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 3413, function: Function { arguments: [], shim_idx: 3414, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd, wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothDevice")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTCharacteristic")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTServer")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("BluetoothRemoteGATTService")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("DataView")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("Event")], shim_idx: 86, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 86, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 80, function: Function { arguments: [NamedExternref("undefined")], shim_idx: 81, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h0f95d90d24796def, wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
|
||||
const ret = getArrayU8FromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./index_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function addToExternrefTable0(obj) {
|
||||
const idx = wasm.__externref_table_alloc();
|
||||
wasm.__wbindgen_externrefs.set(idx, obj);
|
||||
return idx;
|
||||
}
|
||||
|
||||
const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => state.dtor(state.a, state.b));
|
||||
|
||||
function debugString(val) {
|
||||
// primitive types
|
||||
const type = typeof val;
|
||||
if (type == 'number' || type == 'boolean' || val == null) {
|
||||
return `${val}`;
|
||||
}
|
||||
if (type == 'string') {
|
||||
return `"${val}"`;
|
||||
}
|
||||
if (type == 'symbol') {
|
||||
const description = val.description;
|
||||
if (description == null) {
|
||||
return 'Symbol';
|
||||
} else {
|
||||
return `Symbol(${description})`;
|
||||
}
|
||||
}
|
||||
if (type == 'function') {
|
||||
const name = val.name;
|
||||
if (typeof name == 'string' && name.length > 0) {
|
||||
return `Function(${name})`;
|
||||
} else {
|
||||
return 'Function';
|
||||
}
|
||||
}
|
||||
// objects
|
||||
if (Array.isArray(val)) {
|
||||
const length = val.length;
|
||||
let debug = '[';
|
||||
if (length > 0) {
|
||||
debug += debugString(val[0]);
|
||||
}
|
||||
for(let i = 1; i < length; i++) {
|
||||
debug += ', ' + debugString(val[i]);
|
||||
}
|
||||
debug += ']';
|
||||
return debug;
|
||||
}
|
||||
// Test for built-in
|
||||
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
|
||||
let className;
|
||||
if (builtInMatches && builtInMatches.length > 1) {
|
||||
className = builtInMatches[1];
|
||||
} else {
|
||||
// Failed to match the standard '[object ClassName]'
|
||||
return toString.call(val);
|
||||
}
|
||||
if (className == 'Object') {
|
||||
// we're a user defined class or Object
|
||||
// JSON.stringify avoids problems with cycles, and is generally much
|
||||
// easier than looping through ownProperties of `val`.
|
||||
try {
|
||||
return 'Object(' + JSON.stringify(val) + ')';
|
||||
} catch (_) {
|
||||
return 'Object';
|
||||
}
|
||||
}
|
||||
// errors
|
||||
if (val instanceof Error) {
|
||||
return `${val.name}: ${val.message}\n${val.stack}`;
|
||||
}
|
||||
// TODO we could test for more things here, like `Set`s and `Map`s.
|
||||
return className;
|
||||
}
|
||||
|
||||
function getArrayJsValueViewFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
const mem = getDataViewMemory0();
|
||||
const result = [];
|
||||
for (let i = ptr; i < ptr + 4 * len; i += 4) {
|
||||
result.push(wasm.__wbindgen_externrefs.get(mem.getUint32(i, true)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return decodeText(ptr, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
const idx = addToExternrefTable0(e);
|
||||
wasm.__wbindgen_exn_store(idx);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function makeMutClosure(arg0, arg1, dtor, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1, dtor };
|
||||
const real = (...args) => {
|
||||
|
||||
// First up with a closure we increment the internal reference
|
||||
// count. This ensures that the Rust closure environment won't
|
||||
// be deallocated while we're invoking it.
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
state.a = a;
|
||||
real._wbg_cb_unref();
|
||||
}
|
||||
};
|
||||
real._wbg_cb_unref = () => {
|
||||
if (--state.cnt === 0) {
|
||||
state.dtor(state.a, state.b);
|
||||
state.a = 0;
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
}
|
||||
};
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('index_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
@@ -1,770 +0,0 @@
|
||||
let wasm;
|
||||
export function __wbg_set_wasm(val) {
|
||||
wasm = val;
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function addToExternrefTable0(obj) {
|
||||
const idx = wasm.__externref_table_alloc();
|
||||
wasm.__wbindgen_export_1.set(idx, obj);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
const idx = addToExternrefTable0(e);
|
||||
wasm.__wbindgen_exn_store(idx);
|
||||
}
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
const lTextDecoder =
|
||||
typeof TextDecoder === "undefined" ? (0, module.require)("util").TextDecoder : TextDecoder;
|
||||
|
||||
let cachedTextDecoder = new lTextDecoder("utf-8", { ignoreBOM: true, fatal: true });
|
||||
|
||||
cachedTextDecoder.decode();
|
||||
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new lTextDecoder("utf-8", { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return decodeText(ptr, len);
|
||||
}
|
||||
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
const lTextEncoder =
|
||||
typeof TextEncoder === "undefined" ? (0, module.require)("util").TextEncoder : TextEncoder;
|
||||
|
||||
const cachedTextEncoder = new lTextEncoder("utf-8");
|
||||
|
||||
const encodeString =
|
||||
typeof cachedTextEncoder.encodeInto === "function"
|
||||
? function (arg, view) {
|
||||
return cachedTextEncoder.encodeInto(arg, view);
|
||||
}
|
||||
: function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length,
|
||||
};
|
||||
};
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0()
|
||||
.subarray(ptr, ptr + buf.length)
|
||||
.set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7f) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, (len = offset + arg.length * 3), 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = encodeString(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
|
||||
function getDataViewMemory0() {
|
||||
if (
|
||||
cachedDataViewMemory0 === null ||
|
||||
cachedDataViewMemory0.buffer.detached === true ||
|
||||
(cachedDataViewMemory0.buffer.detached === undefined &&
|
||||
cachedDataViewMemory0.buffer !== wasm.memory.buffer)
|
||||
) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function debugString(val) {
|
||||
// primitive types
|
||||
const type = typeof val;
|
||||
if (type == "number" || type == "boolean" || val == null) {
|
||||
return `${val}`;
|
||||
}
|
||||
if (type == "string") {
|
||||
return `"${val}"`;
|
||||
}
|
||||
if (type == "symbol") {
|
||||
const description = val.description;
|
||||
if (description == null) {
|
||||
return "Symbol";
|
||||
} else {
|
||||
return `Symbol(${description})`;
|
||||
}
|
||||
}
|
||||
if (type == "function") {
|
||||
const name = val.name;
|
||||
if (typeof name == "string" && name.length > 0) {
|
||||
return `Function(${name})`;
|
||||
} else {
|
||||
return "Function";
|
||||
}
|
||||
}
|
||||
// objects
|
||||
if (Array.isArray(val)) {
|
||||
const length = val.length;
|
||||
let debug = "[";
|
||||
if (length > 0) {
|
||||
debug += debugString(val[0]);
|
||||
}
|
||||
for (let i = 1; i < length; i++) {
|
||||
debug += ", " + debugString(val[i]);
|
||||
}
|
||||
debug += "]";
|
||||
return debug;
|
||||
}
|
||||
// Test for built-in
|
||||
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
|
||||
let className;
|
||||
if (builtInMatches && builtInMatches.length > 1) {
|
||||
className = builtInMatches[1];
|
||||
} else {
|
||||
// Failed to match the standard '[object ClassName]'
|
||||
return toString.call(val);
|
||||
}
|
||||
if (className == "Object") {
|
||||
// we're a user defined class or Object
|
||||
// JSON.stringify avoids problems with cycles, and is generally much
|
||||
// easier than looping through ownProperties of `val`.
|
||||
try {
|
||||
return "Object(" + JSON.stringify(val) + ")";
|
||||
} catch (_) {
|
||||
return "Object";
|
||||
}
|
||||
}
|
||||
// errors
|
||||
if (val instanceof Error) {
|
||||
return `${val.name}: ${val.message}\n${val.stack}`;
|
||||
}
|
||||
// TODO we could test for more things here, like `Set`s and `Map`s.
|
||||
return className;
|
||||
}
|
||||
|
||||
const CLOSURE_DTORS =
|
||||
typeof FinalizationRegistry === "undefined"
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry((state) => {
|
||||
wasm.__wbindgen_export_6.get(state.dtor)(state.a, state.b);
|
||||
});
|
||||
|
||||
function makeMutClosure(arg0, arg1, dtor, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1, dtor };
|
||||
const real = (...args) => {
|
||||
// First up with a closure we increment the internal reference
|
||||
// count. This ensures that the Rust closure environment won't
|
||||
// be deallocated while we're invoking it.
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
if (--state.cnt === 0) {
|
||||
wasm.__wbindgen_export_6.get(state.dtor)(a, state.b);
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
} else {
|
||||
state.a = a;
|
||||
}
|
||||
}
|
||||
};
|
||||
real.original = state;
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
/**
|
||||
* @param {number} server_ptr
|
||||
* @param {Uint8Array} buf
|
||||
* @param {Function} callback
|
||||
*/
|
||||
export function buttplug_client_send_json_message(server_ptr, buf, callback) {
|
||||
const ptr0 = passArray8ToWasm0(buf, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.buttplug_client_send_json_message(server_ptr, ptr0, len0, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ptr
|
||||
*/
|
||||
export function buttplug_free_embedded_wasm_server(ptr) {
|
||||
wasm.buttplug_free_embedded_wasm_server(ptr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} _max_level
|
||||
*/
|
||||
export function buttplug_activate_env_logger(_max_level) {
|
||||
const ptr0 = passStringToWasm0(_max_level, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.buttplug_activate_env_logger(ptr0, len0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Function} callback
|
||||
* @returns {number}
|
||||
*/
|
||||
export function buttplug_create_embedded_wasm_server(callback) {
|
||||
const ret = wasm.buttplug_create_embedded_wasm_server(callback);
|
||||
return ret >>> 0;
|
||||
}
|
||||
|
||||
function __wbg_adapter_8(arg0, arg1, arg2) {
|
||||
wasm.closure3251_externref_shim(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function __wbg_adapter_11(arg0, arg1, arg2) {
|
||||
wasm.closure202_externref_shim(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function __wbg_adapter_16(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h239e120689ecd2a1(arg0, arg1);
|
||||
}
|
||||
|
||||
export function __wbg_bluetooth_b95c3b6935c8a51a(arg0) {
|
||||
const ret = arg0.bluetooth;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
|
||||
export function __wbg_buffer_068eb5b0fbad96d8(arg0) {
|
||||
const ret = arg0.buffer;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_byteLength_59ab6482fa6cb38b(arg0) {
|
||||
const ret = arg0.byteLength;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_call_2f8d426a20a307fe() {
|
||||
return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.call(arg1);
|
||||
return ret;
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_call_f53f0647ceb9c567() {
|
||||
return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.call(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_connect_b91d2ba90a1ff675(arg0) {
|
||||
const ret = arg0.connect();
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_crypto_86f2631e91b51511(arg0) {
|
||||
const ret = arg0.crypto;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_error_7534b8e9a36f1ab4(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function __wbg_gatt_06b2bdb9e7f8fb84(arg0) {
|
||||
const ret = arg0.gatt;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
|
||||
export function __wbg_getCharacteristic_1cb1de10f54aa049(arg0, arg1, arg2) {
|
||||
const ret = arg0.getCharacteristic(getStringFromWasm0(arg1, arg2));
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_getPrimaryService_f8c0b8be4fded7fd(arg0, arg1, arg2) {
|
||||
const ret = arg0.getPrimaryService(getStringFromWasm0(arg1, arg2));
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_getRandomValues_1c61fac11405ffdc() {
|
||||
return handleError(function (arg0, arg1) {
|
||||
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_getRandomValues_9c5c1b115e142bb8() {
|
||||
return handleError(function (arg0, arg1) {
|
||||
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_getRandomValues_b3f15fcbfabb0f8b() {
|
||||
return handleError(function (arg0, arg1) {
|
||||
arg0.getRandomValues(arg1);
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_get_27b4bcbec57323ca() {
|
||||
return handleError(function (arg0, arg1) {
|
||||
const ret = Reflect.get(arg0, arg1);
|
||||
return ret;
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_id_d769eab9a0939b42(arg0, arg1) {
|
||||
const ret = arg1.id;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
|
||||
export function __wbg_instanceof_Window_7f29e5c72acbfd60(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof Window;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_length_904c0910ed998bf3(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_log_0cc1b7768397bcfe(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.log(
|
||||
getStringFromWasm0(arg0, arg1),
|
||||
getStringFromWasm0(arg2, arg3),
|
||||
getStringFromWasm0(arg4, arg5),
|
||||
getStringFromWasm0(arg6, arg7),
|
||||
);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function __wbg_log_cb9e190acc5753fb(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.log(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function __wbg_mark_7438147ce31e9d4b(arg0, arg1) {
|
||||
performance.mark(getStringFromWasm0(arg0, arg1));
|
||||
}
|
||||
|
||||
export function __wbg_measure_fb7825c11612c823() {
|
||||
return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
let deferred1_0;
|
||||
let deferred1_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
deferred1_0 = arg2;
|
||||
deferred1_1 = arg3;
|
||||
performance.measure(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
|
||||
}
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_msCrypto_d562bbe83e0d4b91(arg0) {
|
||||
const ret = arg0.msCrypto;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_name_cf5d973f5e1d9b1e(arg0, arg1) {
|
||||
const ret = arg1.name;
|
||||
var ptr1 = isLikeNone(ret)
|
||||
? 0
|
||||
: passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
|
||||
export function __wbg_navigator_b6d1cae68d750613(arg0) {
|
||||
const ret = arg0.navigator;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_new_1930cbb8d9ffc31b() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_new_8a6f238a6ece86ea() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_new_9190433fb67ed635(arg0) {
|
||||
const ret = new Uint8Array(arg0);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_new_e969dc3f68d25093() {
|
||||
const ret = [];
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_newnoargs_a81330f6e05d8aca(arg0, arg1) {
|
||||
const ret = new Function(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_newwithbyteoffset_b204dc995f2352f4(arg0, arg1) {
|
||||
const ret = new Uint8Array(arg0, arg1 >>> 0);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_newwithlength_ed0ee6c1edca86fc(arg0) {
|
||||
const ret = new Uint8Array(arg0 >>> 0);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_node_e1f24f89a7336c2e(arg0) {
|
||||
const ret = arg0.node;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_now_0dc4920a47cf7280(arg0) {
|
||||
const ret = arg0.now();
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_now_1f875e5cd673bc3c(arg0) {
|
||||
const ret = arg0.now();
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_performance_6adc3b899e448a23(arg0) {
|
||||
const ret = arg0.performance;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_process_3975fd6c72f520aa(arg0) {
|
||||
const ret = arg0.process;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_prototypesetcall_c5f74efd31aea86b(arg0, arg1, arg2) {
|
||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
||||
}
|
||||
|
||||
export function __wbg_push_cd3ac7d5b094565d(arg0, arg1) {
|
||||
const ret = arg0.push(arg1);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_queueMicrotask_bcc6e26d899696db(arg0) {
|
||||
const ret = arg0.queueMicrotask;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_queueMicrotask_f24a794d09c42640(arg0) {
|
||||
queueMicrotask(arg0);
|
||||
}
|
||||
|
||||
export function __wbg_randomFillSync_f8c153b79f285817() {
|
||||
return handleError(function (arg0, arg1) {
|
||||
arg0.randomFillSync(arg1);
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_readValue_9d41d1a73a07165f(arg0) {
|
||||
const ret = arg0.readValue();
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_requestDevice_17840c36162ed342(arg0, arg1) {
|
||||
const ret = arg0.requestDevice(arg1);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_require_b74f47fc2d022fd6() {
|
||||
return handleError(function () {
|
||||
const ret = module.require;
|
||||
return ret;
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_resolve_5775c0ef9222f556(arg0) {
|
||||
const ret = Promise.resolve(arg0);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_setTimeout_63008613644b07af() {
|
||||
return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.setTimeout(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbg_setfilters_b142ba75a84ace1a(arg0, arg1) {
|
||||
arg0.filters = arg1;
|
||||
}
|
||||
|
||||
export function __wbg_setname_decc08ea308195a6(arg0, arg1, arg2) {
|
||||
arg0.name = getStringFromWasm0(arg1, arg2);
|
||||
}
|
||||
|
||||
export function __wbg_setnameprefix_3c1f973506cd8f08(arg0, arg1, arg2) {
|
||||
arg0.namePrefix = getStringFromWasm0(arg1, arg2);
|
||||
}
|
||||
|
||||
export function __wbg_setoncharacteristicvaluechanged_a126981aa4e69da5(arg0, arg1) {
|
||||
arg0.oncharacteristicvaluechanged = arg1;
|
||||
}
|
||||
|
||||
export function __wbg_setongattserverdisconnected_d43d7f3a48a58fa4(arg0, arg1) {
|
||||
arg0.ongattserverdisconnected = arg1;
|
||||
}
|
||||
|
||||
export function __wbg_setoptionalservices_b6c19589e7aa503e(arg0, arg1) {
|
||||
arg0.optionalServices = arg1;
|
||||
}
|
||||
|
||||
export function __wbg_stack_0ed75d68575b0f3c(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
|
||||
export function __wbg_startNotifications_e1edb2183f9289f5(arg0) {
|
||||
const ret = arg0.startNotifications();
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_static_accessor_GLOBAL_1f13249cc3acc96d() {
|
||||
const ret = typeof global === "undefined" ? null : global;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
|
||||
export function __wbg_static_accessor_GLOBAL_THIS_df7ae94b1e0ed6a3() {
|
||||
const ret = typeof globalThis === "undefined" ? null : globalThis;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
|
||||
export function __wbg_static_accessor_SELF_6265471db3b3c228() {
|
||||
const ret = typeof self === "undefined" ? null : self;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
|
||||
export function __wbg_static_accessor_WINDOW_16fb482f8ec52863() {
|
||||
const ret = typeof window === "undefined" ? null : window;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
|
||||
export function __wbg_subarray_a219824899e59712(arg0, arg1, arg2) {
|
||||
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_target_bfb4281bfa013115(arg0) {
|
||||
const ret = arg0.target;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
|
||||
export function __wbg_then_8d2fcccde5380a03(arg0, arg1, arg2) {
|
||||
const ret = arg0.then(arg1, arg2);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_then_9cc266be2bf537b6(arg0, arg1) {
|
||||
const ret = arg0.then(arg1);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_value_5e240e44f81872c5(arg0) {
|
||||
const ret = arg0.value;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
|
||||
export function __wbg_versions_4e31226f5e8dc909(arg0) {
|
||||
const ret = arg0.versions;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_wbindgencbdrop_a85ed476c6a370b9(arg0) {
|
||||
const obj = arg0.original;
|
||||
if (obj.cnt-- == 1) {
|
||||
obj.a = 0;
|
||||
return true;
|
||||
}
|
||||
const ret = false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_wbindgendebugstring_bb652b1bc2061b6d(arg0, arg1) {
|
||||
const ret = debugString(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
|
||||
export function __wbg_wbindgenisfunction_ea72b9d66a0e1705(arg0) {
|
||||
const ret = typeof arg0 === "function";
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_wbindgenisobject_dfe064a121d87553(arg0) {
|
||||
const val = arg0;
|
||||
const ret = typeof val === "object" && val !== null;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_wbindgenisstring_4b74e4111ba029e6(arg0) {
|
||||
const ret = typeof arg0 === "string";
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_wbindgenisundefined_71f08a6ade4354e7(arg0) {
|
||||
const ret = arg0 === undefined;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbg_wbindgenthrow_4c11a24fca429ccf(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
}
|
||||
|
||||
export function __wbg_writeValue_586734e0fc6e7c73() {
|
||||
return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.writeValue(arg1);
|
||||
return ret;
|
||||
}, arguments);
|
||||
}
|
||||
|
||||
export function __wbindgen_cast_0f76fca626397b3d(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 201, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_11);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbindgen_cast_2241b6af4c4b2941(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbindgen_cast_30a893855537e77b(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 201, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_11);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbindgen_cast_76c05e7c8d82d9b7(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 3269, function: Function { arguments: [], shim_idx: 3270, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, 3269, __wbg_adapter_16);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbindgen_cast_cb3d47e2c086b274(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { dtor_idx: 3250, function: Function { arguments: [Externref], shim_idx: 3251, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, 3250, __wbg_adapter_8);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbindgen_cast_cb9088102bce6b30(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
|
||||
const ret = getArrayU8FromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function __wbindgen_init_externref_table() {
|
||||
const table = wasm.__wbindgen_export_1;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
}
|
||||
Binary file not shown.
29
packages/buttplug/wasm/index_bg.wasm.d.ts
vendored
29
packages/buttplug/wasm/index_bg.wasm.d.ts
vendored
@@ -1,29 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const create_test_dcm: (a: number, b: number) => void;
|
||||
export const buttplug_activate_env_logger: (a: number, b: number) => void;
|
||||
export const buttplug_client_send_json_message: (a: number, b: number, c: number, d: any) => void;
|
||||
export const buttplug_create_embedded_wasm_server: (a: any) => number;
|
||||
export const buttplug_free_embedded_wasm_server: (a: number) => void;
|
||||
export const wasm_bindgen__closure__destroy__h72b504abf7ea70fd: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__closure__destroy__ha3c8e2c9b0cf79cd: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__closure__destroy__h0f95d90d24796def: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hcd253b168dd40e38: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_3: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_4: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_5: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_6: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0628356d4885b6d1_9: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h996ec8878d3e4243_8: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h20343c2d1e7cb4cd: (a: number, b: number) => void;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __externref_table_alloc: () => number;
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __wbindgen_exn_store: (a: number) => void;
|
||||
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
export const __externref_table_dealloc: (a: number) => void;
|
||||
export const __wbindgen_start: () => void;
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "buttplug_wasm",
|
||||
"type": "module",
|
||||
"collaborators": [
|
||||
"Nonpolynomial Labs, LLC <kyle@nonpolynomial.com>"
|
||||
],
|
||||
"description": "WASM Interop for the Buttplug Intimate Hardware Control Library",
|
||||
"version": "10.0.0",
|
||||
"license": "BSD-3-Clause",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/buttplugio/buttplug.git"
|
||||
},
|
||||
"files": [
|
||||
"index_bg.wasm",
|
||||
"index.js",
|
||||
"index.d.ts"
|
||||
],
|
||||
"main": "index.js",
|
||||
"homepage": "http://buttplug.io",
|
||||
"types": "index.d.ts",
|
||||
"sideEffects": [
|
||||
"./snippets/*"
|
||||
],
|
||||
"keywords": [
|
||||
"usb",
|
||||
"serial",
|
||||
"hardware",
|
||||
"bluetooth",
|
||||
"teledildonics"
|
||||
]
|
||||
}
|
||||
18
packages/email/email.css
Normal file
18
packages/email/email.css
Normal file
@@ -0,0 +1,18 @@
|
||||
@import "@maizzle/tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* ── Design tokens — exact mirror of frontend app.css :root ── */
|
||||
--color-background: oklch(0.98 0.01 320);
|
||||
--color-foreground: oklch(0.08 0.02 280);
|
||||
--color-card: oklch(0.99 0.005 320);
|
||||
--color-card-foreground: oklch(0.08 0.02 280);
|
||||
--color-muted: oklch(0.95 0.01 280);
|
||||
--color-muted-foreground: oklch(0.4 0.02 280);
|
||||
--color-border: oklch(0.85 0.02 280);
|
||||
--color-primary: oklch(56.971% 0.27455 319.257);
|
||||
--color-primary-foreground: oklch(0.98 0.01 320);
|
||||
|
||||
/* ── Font ── */
|
||||
--font-sans:
|
||||
"Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
25
packages/email/package.json
Normal file
25
packages/email/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@sexy/email",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maizzle/framework": "6.0.0-15",
|
||||
"@maizzle/tailwindcss": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
25
packages/email/src/index.ts
Normal file
25
packages/email/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { renderTemplate } from "./render.js";
|
||||
|
||||
const BASE_URL = process.env.PUBLIC_URL ?? "https://sexy.pivoine.art";
|
||||
|
||||
export async function renderVerification(data: {
|
||||
token: string;
|
||||
}): Promise<{ subject: string; html: string }> {
|
||||
return {
|
||||
subject: "Verify your email address — sexy.pivoine.art",
|
||||
html: await renderTemplate("verification", {
|
||||
url: `${BASE_URL}/signup/verify?token=${data.token}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderPasswordReset(data: {
|
||||
token: string;
|
||||
}): Promise<{ subject: string; html: string }> {
|
||||
return {
|
||||
subject: "Reset your password — sexy.pivoine.art",
|
||||
html: await renderTemplate("password-reset", {
|
||||
url: `${BASE_URL}/password/reset?token=${data.token}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
42
packages/email/src/render.ts
Normal file
42
packages/email/src/render.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
// At runtime (dist/render.js), __dirname is packages/email/dist/
|
||||
const PKG_ROOT = path.join(__dirname, "..");
|
||||
const TEMPLATES_ROOT = path.join(PKG_ROOT, "templates");
|
||||
const CSS_PATH = path.join(PKG_ROOT, "email.css");
|
||||
|
||||
const BASE_URL = process.env.PUBLIC_URL ?? "https://sexy.pivoine.art";
|
||||
|
||||
export interface RenderOptions {
|
||||
url: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function renderTemplate(name: string, locals: RenderOptions): Promise<string> {
|
||||
// Dynamic import: @maizzle/framework v6 is ESM-only
|
||||
const { render } = await import("@maizzle/framework");
|
||||
|
||||
const html = await readFile(path.join(TEMPLATES_ROOT, `${name}.html`), "utf8");
|
||||
|
||||
const { html: rendered } = await render(html, {
|
||||
components: {
|
||||
root: TEMPLATES_ROOT,
|
||||
folders: ["layouts"],
|
||||
},
|
||||
// Override PostCSS `from` so @import "@maizzle/tailwindcss" resolves
|
||||
// from this package's node_modules (defu gives our value priority).
|
||||
postcss: {
|
||||
options: {
|
||||
from: CSS_PATH,
|
||||
},
|
||||
},
|
||||
locals: {
|
||||
cssPath: CSS_PATH, // layout uses {{ cssPath }} in <link href="{{ cssPath }}" inline>
|
||||
baseUrl: BASE_URL,
|
||||
...locals,
|
||||
},
|
||||
});
|
||||
|
||||
return rendered;
|
||||
}
|
||||
81
packages/email/templates/layouts/main.html
Normal file
81
packages/email/templates/layouts/main.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<!--[if mso]>
|
||||
<noscript
|
||||
><xml
|
||||
><o:OfficeDocumentSettings
|
||||
><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings
|
||||
></xml
|
||||
></noscript
|
||||
>
|
||||
<![endif]-->
|
||||
<title>{{ page.title || 'sexy' }}</title>
|
||||
|
||||
<!-- Noto Sans — progressive enhancement for clients that support web fonts -->
|
||||
<style plain>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;600;700&display=swap");
|
||||
</style>
|
||||
|
||||
<!-- Design tokens + Tailwind preset — path resolved by render.ts -->
|
||||
<link rel="stylesheet" href="{{ cssPath }}" inline />
|
||||
</head>
|
||||
<body class="bg-background m-0 p-0 font-sans">
|
||||
<!-- Preview text (hidden) -->
|
||||
<if condition="page.previewText || previewText">
|
||||
<div class="hidden max-h-0 overflow-hidden">
|
||||
{{ page.previewText || previewText }}
|
||||
‌ ‌ ‌ ‌ ‌ ‌
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<div class="py-8 px-4">
|
||||
<table
|
||||
class="w-full max-w-[600px] mx-auto"
|
||||
role="presentation"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
border="0"
|
||||
>
|
||||
<!-- Brand header — uses --foreground as dark bg -->
|
||||
<tr>
|
||||
<td class="bg-foreground rounded-t-2xl px-8 py-6 text-center">
|
||||
<a href="{{ baseUrl }}" style="text-decoration: none">
|
||||
<span class="text-sm font-semibold tracking-[0.22em] uppercase text-background">
|
||||
sexy<span class="text-primary">.</span>pivoine<span class="text-primary">.</span>art
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Card body -->
|
||||
<tr>
|
||||
<td class="bg-card px-8 py-10 text-[14px] text-card-foreground leading-relaxed">
|
||||
<yield />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td class="bg-muted border-t border-border rounded-b-2xl px-8 py-6 text-center">
|
||||
<p class="text-[11px] text-muted-foreground m-0">
|
||||
© {{ new Date().getFullYear() }} sexy — For adults only (18+)
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground mt-2 mb-0">
|
||||
If you did not request this email, you can safely ignore it.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
41
packages/email/templates/password-reset.html
Normal file
41
packages/email/templates/password-reset.html
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: "Reset your password — sexy"
|
||||
previewText: "You requested a password reset. Use the link below to set a new one."
|
||||
---
|
||||
|
||||
<x-main>
|
||||
<h1 class="text-[22px] font-semibold text-foreground m-0 mb-2">Reset your password</h1>
|
||||
<p class="text-muted-foreground m-0 mb-6">
|
||||
We received a request to reset the password for your account. Click the button below to choose a
|
||||
new one.
|
||||
</p>
|
||||
|
||||
<!-- CTA button — inline style needed for Outlook -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mb-6">
|
||||
<tr>
|
||||
<td class="rounded-lg" style="background: #b700d9">
|
||||
<a
|
||||
href="{{ url }}"
|
||||
class="inline-block px-8 py-[14px] text-[14px] font-semibold text-primary-foreground no-underline rounded-lg"
|
||||
style="background: #b700d9; color: #faf4fb"
|
||||
>
|
||||
Reset my password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="text-[13px] text-muted-foreground m-0 mb-6">
|
||||
This link expires in <strong class="text-foreground">1 hour</strong>. If you did not request a
|
||||
password reset, no action is needed — your account remains secure.
|
||||
</p>
|
||||
|
||||
<hr class="border-0 border-t border-border my-6" />
|
||||
|
||||
<p class="text-[12px] text-muted-foreground m-0">
|
||||
Button not working? Copy and paste this link into your browser:
|
||||
</p>
|
||||
<p class="text-[12px] m-0 mt-1">
|
||||
<a href="{{ url }}" class="text-primary break-all" style="color: #b700d9"> {{ url }} </a>
|
||||
</p>
|
||||
</x-main>
|
||||
40
packages/email/templates/verification.html
Normal file
40
packages/email/templates/verification.html
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "Verify your email — sexy"
|
||||
previewText: "Almost there — confirm your email address to activate your account."
|
||||
---
|
||||
|
||||
<x-main>
|
||||
<h1 class="text-[22px] font-semibold text-foreground m-0 mb-2">Verify your email address</h1>
|
||||
<p class="text-muted-foreground m-0 mb-6">
|
||||
Thanks for signing up! Click the button below to confirm your email address and activate your
|
||||
account.
|
||||
</p>
|
||||
|
||||
<!-- CTA button — inline style needed for Outlook -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mb-6">
|
||||
<tr>
|
||||
<td class="rounded-lg" style="background: #b700d9">
|
||||
<a
|
||||
href="{{ url }}"
|
||||
class="inline-block px-8 py-[14px] text-[14px] font-semibold text-primary-foreground no-underline rounded-lg"
|
||||
style="background: #b700d9; color: #faf4fb"
|
||||
>
|
||||
Verify my email
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="text-[13px] text-muted-foreground m-0 mb-6">
|
||||
This link expires in <strong class="text-foreground">24 hours</strong>.
|
||||
</p>
|
||||
|
||||
<hr class="border-0 border-t border-border my-6" />
|
||||
|
||||
<p class="text-[12px] text-muted-foreground m-0">
|
||||
Button not working? Copy and paste this link into your browser:
|
||||
</p>
|
||||
<p class="text-[12px] m-0 mt-1">
|
||||
<a href="{{ url }}" class="text-primary break-all" style="color: #b700d9"> {{ url }} </a>
|
||||
</p>
|
||||
</x-main>
|
||||
14
packages/email/tsconfig.json
Normal file
14
packages/email/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/frontend",
|
||||
"name": "@sexy/frontend",
|
||||
"version": "1.0.0",
|
||||
"author": "valknarogg",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -12,10 +11,10 @@
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json --threshold warning"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||
"@sexy/buttplug": "workspace:*",
|
||||
"@iconify-json/ri": "^1.2.10",
|
||||
"@iconify/tailwind4": "^1.2.1",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
@@ -30,7 +29,6 @@
|
||||
"glob": "^13.0.6",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"super-sitemap": "^1.0.7",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"svelte-sonner": "^1.0.8",
|
||||
@@ -39,11 +37,10 @@
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-wasm": "3.5.0"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sexy.pivoine.art/types": "workspace:*",
|
||||
"@sexy/types": "workspace:*",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-request": "^7.1.2",
|
||||
"javascript-time-ago": "^2.6.4",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isAuthenticated } from "$lib/services";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { isAuthenticated, UnauthorizedError } from "$lib/services";
|
||||
import { logger, generateRequestId } from "$lib/logger";
|
||||
import type { Handle } from "@sveltejs/kit";
|
||||
|
||||
@@ -65,6 +66,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
const loginUrl = `/login?redirect=${encodeURIComponent(url.pathname)}`;
|
||||
throw redirect(303, loginUrl);
|
||||
}
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error("Request handler error", {
|
||||
requestId,
|
||||
|
||||
@@ -10,23 +10,23 @@
|
||||
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
class={`bg-foreground h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
class={`bg-foreground h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? "translate-x-10" : ""}`}
|
||||
></div>
|
||||
|
||||
<div
|
||||
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? "translate-x-0 w-12" : ""}`}
|
||||
>
|
||||
<div
|
||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
|
||||
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? "rotate-45" : ""}`}
|
||||
></div>
|
||||
<div
|
||||
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
|
||||
class={`absolute bg-foreground h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? "-rotate-45" : ""}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
isMobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function isActiveLink(link: { name: string; href: string }) {
|
||||
function isActiveLink(link: { name?: string; href: string }) {
|
||||
return (
|
||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||
@@ -47,7 +47,7 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||
class="sticky top-0 z-50 w-full backdrop-blur-xl shadow-[0_4px_24px_-8px_color-mix(in_oklab,var(--color-primary)_12%,transparent)] bg-card/50"
|
||||
>
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-evenly h-16">
|
||||
@@ -76,28 +76,14 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Desktop Auth Actions -->
|
||||
<!-- Auth Actions -->
|
||||
{#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">
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/me" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||
href="/me"
|
||||
title={$_("header.dashboard")}
|
||||
>
|
||||
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
|
||||
<span
|
||||
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/me" }) ? "w-full" : "group-hover:w-full"}`}
|
||||
></span>
|
||||
<span class="sr-only">{$_("header.dashboard")}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||
class={`flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/play" }) ? "text-foreground" : "hover:text-foreground"}`}
|
||||
href="/play"
|
||||
title={$_("header.play")}
|
||||
>
|
||||
@@ -112,7 +98,7 @@
|
||||
<Button
|
||||
variant="link"
|
||||
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"
|
||||
title="Admin"
|
||||
>
|
||||
@@ -124,7 +110,7 @@
|
||||
</Button>
|
||||
{/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">
|
||||
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
||||
@@ -138,35 +124,24 @@
|
||||
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||
</AvatarFallback>
|
||||
</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]}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="link"
|
||||
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}
|
||||
title={$_("header.logout")}
|
||||
>
|
||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hidden lg:flex w-full items-center justify-end gap-4">
|
||||
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button>
|
||||
<Button
|
||||
href="/signup"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
||||
>{$_("header.signup")}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Burger button — mobile/tablet only -->
|
||||
<div class="lg:hidden ml-auto">
|
||||
<div class="lg:hidden ml-2">
|
||||
<BurgerMenuButton
|
||||
label={$_("header.navigation")}
|
||||
bind:isMobileMenuOpen
|
||||
@@ -174,6 +149,27 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full flex items-center justify-end gap-2">
|
||||
<div class="flex gap-4">
|
||||
<Button variant="outline" class="font-medium" href="/login">{$_("header.login")}</Button
|
||||
>
|
||||
<Button
|
||||
href="/signup"
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
||||
>{$_("header.signup")}</Button
|
||||
>
|
||||
</div>
|
||||
<div class="lg:hidden ml-2">
|
||||
<BurgerMenuButton
|
||||
label={$_("header.navigation")}
|
||||
bind:isMobileMenuOpen
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={className}
|
||||
class={`rounded-full ring-2 ring-primary/20 ${className}`}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 10240 10240"
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
</script>
|
||||
|
||||
<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="max-w-5xl mx-auto">
|
||||
<h1
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
let { currentPage, totalPages, onPageChange }: Props = $props();
|
||||
|
||||
const pageNumbers = $derived(() => {
|
||||
const pages: (number | -1)[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push(-1);
|
||||
for (
|
||||
let i = Math.max(2, currentPage - 1);
|
||||
i <= Math.min(totalPages - 1, currentPage + 1);
|
||||
i++
|
||||
)
|
||||
pages.push(i);
|
||||
if (currentPage < totalPages - 2) pages.push(-1);
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={currentPage <= 1}
|
||||
onclick={() => onPageChange(currentPage - 1)}
|
||||
>
|
||||
{$_("common.previous")}
|
||||
</Button>
|
||||
{#each pageNumbers() as p, i (i)}
|
||||
{#if p === -1}
|
||||
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={p === currentPage ? "default" : "outline"}
|
||||
class="min-w-9"
|
||||
onclick={() => onPageChange(p)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={currentPage >= totalPages}
|
||||
onclick={() => onPageChange(currentPage + 1)}
|
||||
>
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,16 +2,18 @@
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import type { Recording, DeviceInfo } from "$lib/types";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
recording: Recording;
|
||||
onPlay?: (id: string) => void;
|
||||
onPublish?: (id: string) => void;
|
||||
onUnpublish?: (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 {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
@@ -19,17 +21,6 @@
|
||||
const seconds = totalSeconds % 60;
|
||||
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>
|
||||
|
||||
<Card
|
||||
@@ -42,9 +33,14 @@
|
||||
<h3 class="font-semibold text-card-foreground group-hover:text-primary transition-colors">
|
||||
{recording.title}
|
||||
</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}`)}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{#if recording.description}
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||
@@ -149,12 +145,35 @@
|
||||
{$_("recording_card.play")}
|
||||
</Button>
|
||||
{/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}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => onDelete?.(recording.id)}
|
||||
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>
|
||||
</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: {
|
||||
title: "Dashboard",
|
||||
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",
|
||||
settings: {
|
||||
title: "Settings",
|
||||
@@ -134,6 +151,10 @@ export default {
|
||||
delete_confirm: "Are you sure you want to delete this recording?",
|
||||
delete_success: "Recording deleted successfully",
|
||||
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: {
|
||||
@@ -144,6 +165,8 @@ export default {
|
||||
status_draft: "Draft",
|
||||
status_published: "Published",
|
||||
play: "Play",
|
||||
publish: "Publish",
|
||||
unpublish: "Unpublish",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
public: "Public",
|
||||
@@ -799,11 +822,19 @@ export default {
|
||||
questions_email: "support@pivoine.art",
|
||||
},
|
||||
play: {
|
||||
title: "SexyPlay",
|
||||
description: "Bring your toys.",
|
||||
title: "Play",
|
||||
description: "Connect and control your Bluetooth toys.",
|
||||
scan: "Start Scan",
|
||||
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: {
|
||||
not_found: "Oops! Page Not Found",
|
||||
@@ -905,14 +936,15 @@ export default {
|
||||
},
|
||||
admin: {
|
||||
nav: {
|
||||
back_to_site: "← Back to site",
|
||||
back_mobile: "← Back",
|
||||
back_to_site: "Back to site",
|
||||
back_mobile: "Back",
|
||||
title: "Admin",
|
||||
users: "Users",
|
||||
videos: "Videos",
|
||||
articles: "Articles",
|
||||
comments: "Comments",
|
||||
recordings: "Recordings",
|
||||
queues: "Queues",
|
||||
},
|
||||
common: {
|
||||
save_changes: "Save changes",
|
||||
@@ -927,8 +959,8 @@ export default {
|
||||
cover_image: "Cover image",
|
||||
tags: "Tags",
|
||||
publish_date: "Publish date",
|
||||
title_field: "Title *",
|
||||
slug_field: "Slug *",
|
||||
title_field: "Title",
|
||||
slug_field: "Slug",
|
||||
title_slug_required: "Title and slug are required",
|
||||
image_uploaded: "Image uploaded",
|
||||
image_upload_failed: "Image upload failed",
|
||||
@@ -1058,6 +1090,36 @@ export default {
|
||||
delete_success: "Recording deleted",
|
||||
delete_error: "Failed to delete recording",
|
||||
},
|
||||
queues: {
|
||||
title: "Job Queues",
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
paused_badge: "Paused",
|
||||
retry: "Retry",
|
||||
remove: "Remove",
|
||||
retry_success: "Job retried",
|
||||
retry_error: "Failed to retry job",
|
||||
remove_success: "Job removed",
|
||||
remove_error: "Failed to remove job",
|
||||
pause_success: "Queue paused",
|
||||
pause_error: "Failed to pause queue",
|
||||
resume_success: "Queue resumed",
|
||||
resume_error: "Failed to resume queue",
|
||||
col_id: "ID",
|
||||
col_name: "Name",
|
||||
col_status: "Status",
|
||||
col_attempts: "Attempts",
|
||||
col_created: "Created",
|
||||
col_actions: "Actions",
|
||||
no_jobs: "No jobs found",
|
||||
status_all: "All",
|
||||
status_waiting: "Waiting",
|
||||
status_active: "Active",
|
||||
status_completed: "Completed",
|
||||
status_failed: "Failed",
|
||||
status_delayed: "Delayed",
|
||||
failed_reason: "Reason: {reason}",
|
||||
},
|
||||
article_form: {
|
||||
new_title: "New article",
|
||||
edit_title: "Edit article",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Server-side logging utility for sexy.pivoine.art
|
||||
* Server-side logging utility for sexy
|
||||
* Provides structured logging with context and request tracing
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@ interface LogContext {
|
||||
|
||||
class Logger {
|
||||
private isDev = process.env.NODE_ENV === "development";
|
||||
private serviceName = "sexy.pivoine.art";
|
||||
private serviceName = "sexy";
|
||||
|
||||
private formatLog(ctx: LogContext): string {
|
||||
const { timestamp, level, message, context, requestId, userId, path, method, duration, error } =
|
||||
@@ -126,7 +126,7 @@ class Logger {
|
||||
};
|
||||
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("🍑 sexy.pivoine.art - Server Starting 💜");
|
||||
console.log("🍑 sexy - Server Starting 💜");
|
||||
console.log("=".repeat(60));
|
||||
console.log("\n📋 Environment Configuration:");
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
|
||||
@@ -16,6 +16,22 @@ import type {
|
||||
} from "$lib/types";
|
||||
import { logger } from "$lib/logger";
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
function isUnauthorizedError(error: unknown): boolean {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const resp = (error as { response?: { errors?: { message: string }[] } }).response;
|
||||
if (resp?.errors?.some((e) => e.message === "Unauthorized")) return true;
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return msg.startsWith("Unauthorized");
|
||||
}
|
||||
|
||||
// Helper to log API calls
|
||||
async function loggedApiCall<T>(
|
||||
operationName: string,
|
||||
@@ -32,6 +48,10 @@ async function loggedApiCall<T>(
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
if (isUnauthorizedError(error)) {
|
||||
logger.debug(`🔒 API: ${operationName} unauthorized`, { duration, context });
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
logger.error(`❌ API: ${operationName} failed`, {
|
||||
duration,
|
||||
context,
|
||||
@@ -816,13 +836,12 @@ const RECORDINGS_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export async function getRecordings(fetchFn?: typeof globalThis.fetch) {
|
||||
export async function getRecordings(fetchFn?: typeof globalThis.fetch, token?: string) {
|
||||
return loggedApiCall(
|
||||
"getRecordings",
|
||||
async () => {
|
||||
const data = await getGraphQLClient(fetchFn).request<{ recordings: Recording[] }>(
|
||||
RECORDINGS_QUERY,
|
||||
);
|
||||
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
|
||||
const data = await client.request<{ recordings: Recording[] }>(RECORDINGS_QUERY);
|
||||
return data.recordings;
|
||||
},
|
||||
{},
|
||||
@@ -902,6 +921,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`
|
||||
mutation DeleteRecording($id: String!) {
|
||||
deleteRecording(id: $id)
|
||||
@@ -940,14 +979,12 @@ const RECORDING_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch) {
|
||||
export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch, token?: string) {
|
||||
return loggedApiCall(
|
||||
"getRecording",
|
||||
async () => {
|
||||
const data = await getGraphQLClient(fetchFn).request<{ recording: Recording | null }>(
|
||||
RECORDING_QUERY,
|
||||
{ id },
|
||||
);
|
||||
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
|
||||
const data = await client.request<{ recording: Recording | null }>(RECORDING_QUERY, { id });
|
||||
return data.recording;
|
||||
},
|
||||
{ id },
|
||||
@@ -1779,13 +1816,12 @@ const ANALYTICS_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export async function getAnalytics(fetchFn?: typeof globalThis.fetch) {
|
||||
export async function getAnalytics(fetchFn?: typeof globalThis.fetch, token?: string) {
|
||||
return loggedApiCall(
|
||||
"getAnalytics",
|
||||
async () => {
|
||||
const data = await getGraphQLClient(fetchFn).request<{ analytics: Analytics | null }>(
|
||||
ANALYTICS_QUERY,
|
||||
);
|
||||
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
|
||||
const data = await client.request<{ analytics: Analytics | null }>(ANALYTICS_QUERY);
|
||||
return data.analytics;
|
||||
},
|
||||
{},
|
||||
@@ -1876,3 +1912,150 @@ export async function adminDeleteRecording(id: string): Promise<void> {
|
||||
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,
|
||||
fetchFn?: typeof globalThis.fetch,
|
||||
token?: string,
|
||||
): Promise<Job[]> {
|
||||
return loggedApiCall("getAdminQueueJobs", async () => {
|
||||
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
|
||||
const data = await client.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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ export type {
|
||||
RecentPoint,
|
||||
UserGamification,
|
||||
Achievement,
|
||||
} from "@sexy.pivoine.art/types";
|
||||
} from "@sexy/types";
|
||||
|
||||
import type { CurrentUser } from "@sexy.pivoine.art/types";
|
||||
import type { ButtplugClientDevice } from "@sexy.pivoine.art/buttplug";
|
||||
import type { CurrentUser } from "@sexy/types";
|
||||
import type { ButtplugClientDevice } from "@sexy/buttplug";
|
||||
|
||||
// ─── Frontend-only types ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
|
||||
<div class="bg-background text-foreground min-h-screen">
|
||||
<!-- 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 -->
|
||||
<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"
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<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 } = $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([
|
||||
{ name: $_("admin.nav.users"), href: "/admin/users", icon: "icon-[ri--team-line]" },
|
||||
@@ -14,6 +23,11 @@
|
||||
href: "/admin/recordings",
|
||||
icon: "icon-[ri--record-circle-line]",
|
||||
},
|
||||
{
|
||||
name: $_("admin.nav.queues"),
|
||||
href: "/admin/queues",
|
||||
icon: "icon-[ri--stack-line]",
|
||||
},
|
||||
]);
|
||||
|
||||
function isActive(href: string) {
|
||||
@@ -28,9 +42,10 @@
|
||||
<div class="flex items-center gap-1 overflow-x-auto py-2 scrollbar-none">
|
||||
<a
|
||||
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>
|
||||
{#each navLinks as link (link.href)}
|
||||
<a
|
||||
@@ -53,10 +68,33 @@
|
||||
<!-- 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="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")}
|
||||
</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>
|
||||
|
||||
<nav class="flex-1 p-3 space-y-1">
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { Article } from "$lib/types";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -64,8 +66,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="py-3 sm:py-6 sm:pl-6">
|
||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||
<Meta title={$_("admin.articles.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.articles.title")}</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-muted-foreground"
|
||||
@@ -81,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
placeholder={$_("admin.articles.search_placeholder")}
|
||||
class="max-w-xs"
|
||||
@@ -201,7 +205,7 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{#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 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -211,32 +215,15 @@
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset === 0}
|
||||
onclick={() => {
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(Math.max(0, data.offset - data.limit)));
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{$_("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset + data.limit >= data.total}
|
||||
onclick={() => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(data.offset + data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
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 { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -93,29 +95,48 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/articles" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.article_form.edit_title")}</h1>
|
||||
<Meta title={$_("admin.article_form.edit_title")} description={null} />
|
||||
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{data.article.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 class="space-y-5 max-w-4xl">
|
||||
<Card class="bg-card/50 border-primary/20 max-w-4xl">
|
||||
<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} />
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
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} />
|
||||
<Input
|
||||
id="slug"
|
||||
bind:value={slug}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||
<Textarea id="excerpt" bind:value={excerpt} rows={2} />
|
||||
<Textarea
|
||||
id="excerpt"
|
||||
bind:value={excerpt}
|
||||
rows={2}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Markdown editor with live preview -->
|
||||
@@ -123,22 +144,24 @@
|
||||
<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
|
||||
variant="ghost"
|
||||
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
|
||||
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
|
||||
<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 ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
||||
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" : ""}`}
|
||||
@@ -166,11 +189,10 @@
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.article_form.author")}</Label>
|
||||
<Select type="single" bind:value={authorId}>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectTrigger class="w-full bg-background/50 border-primary/20">
|
||||
{#if selectedAuthor}
|
||||
{#if selectedAuthor.avatar}
|
||||
<img
|
||||
@@ -205,7 +227,11 @@
|
||||
<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} />
|
||||
<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>
|
||||
@@ -215,7 +241,10 @@
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
@@ -223,15 +252,13 @@
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
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")}
|
||||
</Button>
|
||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||
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 slug = $state("");
|
||||
@@ -75,15 +77,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/articles" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<Meta title={$_("admin.article_form.new_title")} description={null} />
|
||||
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("admin.article_form.new_title")}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 max-w-4xl">
|
||||
<Card class="bg-card/50 border-primary/20 max-w-4xl">
|
||||
<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>
|
||||
@@ -94,6 +96,7 @@
|
||||
if (!slug) slug = generateSlug(title);
|
||||
}}
|
||||
placeholder={$_("admin.article_form.title_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
@@ -102,6 +105,7 @@
|
||||
id="slug"
|
||||
bind:value={slug}
|
||||
placeholder={$_("admin.article_form.slug_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +117,7 @@
|
||||
bind:value={excerpt}
|
||||
placeholder={$_("admin.article_form.excerpt_placeholder")}
|
||||
rows={2}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -121,15 +126,17 @@
|
||||
<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
|
||||
variant="ghost"
|
||||
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
|
||||
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
|
||||
<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>
|
||||
@@ -138,7 +145,7 @@
|
||||
<Textarea
|
||||
bind:value={content}
|
||||
placeholder={$_("admin.article_form.content_placeholder")}
|
||||
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
||||
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" : ""}`}
|
||||
@@ -157,9 +164,9 @@
|
||||
<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}
|
||||
{#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">
|
||||
@@ -169,6 +176,7 @@
|
||||
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">
|
||||
@@ -179,7 +187,10 @@
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
@@ -187,15 +198,13 @@
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
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")}
|
||||
</Button>
|
||||
<Button variant="outline" href="/admin/articles">{$_("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const timeAgo = new TimeAgo("en");
|
||||
@@ -53,15 +55,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="py-3 sm:py-6 sm:pl-6">
|
||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||
<Meta title={$_("admin.comments.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.comments.title")}</h1>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||
>
|
||||
</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
|
||||
placeholder={$_("admin.comments.search_placeholder")}
|
||||
class="max-w-xs"
|
||||
@@ -150,7 +154,7 @@
|
||||
</div>
|
||||
|
||||
{#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 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -160,28 +164,15 @@
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset === 0}
|
||||
onclick={() => {
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(Math.max(0, data.offset - data.limit)));
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}>{$_("common.previous")}</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset + data.limit >= data.total}
|
||||
onclick={() => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(data.offset + data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}>{$_("common.next")}</Button
|
||||
>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
35
packages/frontend/src/routes/admin/queues/+page.server.ts
Normal file
35
packages/frontend/src/routes/admin/queues/+page.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getAdminQueues, getAdminQueueJobs } from "$lib/services";
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
export async function load({ fetch, cookies, url }) {
|
||||
const token = cookies.get("session_token") || "";
|
||||
const queues = await getAdminQueues(fetch, token).catch(() => []);
|
||||
|
||||
const queueParam = url.searchParams.get("queue") ?? queues[0]?.name ?? null;
|
||||
const status = url.searchParams.get("status") ?? null;
|
||||
const offset = parseInt(url.searchParams.get("offset") ?? "0") || 0;
|
||||
|
||||
let jobs: Awaited<ReturnType<typeof getAdminQueueJobs>> = [];
|
||||
let total = 0;
|
||||
|
||||
if (queueParam) {
|
||||
jobs = await getAdminQueueJobs(
|
||||
queueParam,
|
||||
status ?? undefined,
|
||||
LIMIT,
|
||||
offset,
|
||||
fetch,
|
||||
token,
|
||||
).catch(() => []);
|
||||
|
||||
const queueInfo = queues.find((q) => q.name === queueParam);
|
||||
if (queueInfo) {
|
||||
const { waiting, active, completed, failed, delayed } = queueInfo.counts;
|
||||
const counts: Record<string, number> = { waiting, active, completed, failed, delayed };
|
||||
total = status ? (counts[status] ?? 0) : Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return { queues, queue: queueParam, status, jobs, total, offset, limit: LIMIT };
|
||||
}
|
||||
293
packages/frontend/src/routes/admin/queues/+page.svelte
Normal file
293
packages/frontend/src/routes/admin/queues/+page.svelte
Normal file
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { SvelteURLSearchParams } from "svelte/reactivity";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { 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";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
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") },
|
||||
];
|
||||
|
||||
function navigate(overrides: Record<string, string | null>) {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v === null) params.delete(k);
|
||||
else params.set(k, v);
|
||||
}
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
function selectQueue(name: string) {
|
||||
navigate({ queue: name, status: null, offset: null });
|
||||
}
|
||||
|
||||
function selectStatus(status: string | null) {
|
||||
navigate({ status, offset: null });
|
||||
}
|
||||
|
||||
async function retryJob(job: Job) {
|
||||
try {
|
||||
await adminRetryJob(job.queue, job.id);
|
||||
toast.success($_("admin.queues.retry_success"));
|
||||
await invalidateAll();
|
||||
} 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"));
|
||||
await invalidateAll();
|
||||
} 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 invalidateAll();
|
||||
} catch {
|
||||
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
|
||||
} finally {
|
||||
togglingQueue = null;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
{#if data.queue && data.total > 0}
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.total", { values: { total: data.total } })}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Queue cards -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
{#each data.queues as queue (queue.name)}
|
||||
{@const isSelected = data.queue === 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 data.queue}
|
||||
<!-- Status filter tabs -->
|
||||
<div class="flex gap-1 mb-4 flex-wrap">
|
||||
{#each STATUS_FILTERS as f (f.value ?? "all")}
|
||||
<Button
|
||||
variant={data.status === 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">
|
||||
{#each data.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 data.jobs.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
||||
>{$_("admin.queues.no_jobs")}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if data.total > data.limit}
|
||||
<div class="flex items-center justify-between mt-4 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
start: data.offset + 1,
|
||||
end: Math.min(data.offset + data.limit, data.total),
|
||||
total: data.total,
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => navigate({ offset: String((p - 1) * data.limit) })}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -11,6 +11,8 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { Recording } from "$lib/types";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const timeAgo = new TimeAgo("en");
|
||||
@@ -63,15 +65,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="py-3 sm:py-6 sm:pl-6">
|
||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||
<Meta title={$_("admin.recordings.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.recordings.title")}</h1>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||
>
|
||||
</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
|
||||
placeholder={$_("admin.recordings.search_placeholder")}
|
||||
class="max-w-xs"
|
||||
@@ -128,10 +132,12 @@
|
||||
<td class="px-4 py-3 hidden sm:table-cell">
|
||||
<div class="flex gap-1">
|
||||
<Badge
|
||||
variant={recording.status === "published" ? "default" : "outline"}
|
||||
class={recording.status === "draft" ? "text-muted-foreground" : ""}
|
||||
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.status}
|
||||
{$_(`recording_card.status_${recording.status}`)}
|
||||
</Badge>
|
||||
{#if recording.public}
|
||||
<Badge variant="outline" class="text-blue-600 border-blue-500/40 bg-blue-500/10"
|
||||
@@ -174,7 +180,7 @@
|
||||
</div>
|
||||
|
||||
{#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 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -184,28 +190,15 @@
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset === 0}
|
||||
onclick={() => {
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(Math.max(0, data.offset - data.limit)));
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}>{$_("common.previous")}</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset + data.limit >= data.total}
|
||||
onclick={() => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(data.offset + data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}>{$_("common.next")}</Button
|
||||
>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { User } from "$lib/types";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -84,8 +86,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="py-3 sm:py-6 sm:pl-6">
|
||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||
<Meta title={$_("admin.users.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.users.title")}</h1>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>{$_("admin.users.total", { values: { total: data.total } })}</span
|
||||
@@ -93,7 +97,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
placeholder={$_("admin.users.search_placeholder")}
|
||||
class="max-w-xs"
|
||||
@@ -225,7 +229,7 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{#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 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -235,32 +239,15 @@
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset === 0}
|
||||
onclick={() => {
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(Math.max(0, data.offset - data.limit)));
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{$_("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset + data.limit >= data.total}
|
||||
onclick={() => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(data.offset + data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
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 Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -125,11 +127,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3 sm:p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/users" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<Meta title={data.user.artist_name || data.user.email} description={null} />
|
||||
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{data.user.artist_name || data.user.email}</h1>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@@ -140,25 +141,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Basic info -->
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<!-- Profile & files card -->
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<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="firstName">{$_("admin.user_edit.first_name")}</Label>
|
||||
<Input id="firstName" bind:value={firstName} />
|
||||
<Input
|
||||
id="firstName"
|
||||
bind:value={firstName}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
|
||||
<Input id="lastName" bind:value={lastName} />
|
||||
<Input
|
||||
id="lastName"
|
||||
bind:value={lastName}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
|
||||
<Input id="artistName" bind:value={artistName} />
|
||||
<Input
|
||||
id="artistName"
|
||||
bind:value={artistName}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.user_edit.avatar")}</Label>
|
||||
{#if avatarId}
|
||||
@@ -168,10 +182,13 @@
|
||||
class="h-20 w-20 rounded-full object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} />
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
maxFileSize={10 * MEGABYTE}
|
||||
onUpload={handleAvatarUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.user_edit.banner")}</Label>
|
||||
{#if bannerId}
|
||||
@@ -181,10 +198,13 @@
|
||||
class="w-full h-24 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
|
||||
<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>
|
||||
@@ -195,10 +215,13 @@
|
||||
class="w-full h-48 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload2} />
|
||||
<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"
|
||||
>
|
||||
@@ -213,18 +236,19 @@
|
||||
</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"
|
||||
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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Photo gallery -->
|
||||
<div class="space-y-3 pt-4 border-t border-border/40">
|
||||
<!-- 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}
|
||||
@@ -236,13 +260,14 @@
|
||||
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"
|
||||
<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)}
|
||||
type="button"
|
||||
aria-label="Remove photo"
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -251,6 +276,7 @@
|
||||
{/if}
|
||||
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { Video } from "$lib/types";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -61,8 +63,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="py-3 sm:py-6 sm:pl-6">
|
||||
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
||||
<Meta title={$_("admin.videos.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.videos.title")}</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-muted-foreground"
|
||||
@@ -78,7 +82,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
placeholder={$_("admin.videos.search_placeholder")}
|
||||
class="max-w-xs"
|
||||
@@ -206,7 +210,7 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{#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 flex-wrap gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
@@ -216,32 +220,15 @@
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset === 0}
|
||||
onclick={() => {
|
||||
<Pagination
|
||||
currentPage={Math.floor(data.offset / data.limit) + 1}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={(p) => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(Math.max(0, data.offset - data.limit)));
|
||||
params.set("offset", String((p - 1) * data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{$_("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={data.offset + data.limit >= data.total}
|
||||
onclick={() => {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(data.offset + data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
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 { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
|
||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -102,15 +104,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3 sm:p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/videos" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<h1 class="text-2xl font-bold">{$_("admin.video_form.edit_title")}</h1>
|
||||
<Meta title={$_("admin.video_form.edit_title")} description={null} />
|
||||
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{data.video.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 class="space-y-5">
|
||||
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||
<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>
|
||||
@@ -118,11 +125,17 @@
|
||||
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")} />
|
||||
<Input
|
||||
id="slug"
|
||||
bind:value={slug}
|
||||
placeholder={$_("admin.video_form.slug_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +146,7 @@
|
||||
bind:value={description}
|
||||
placeholder={$_("admin.video_form.description_placeholder")}
|
||||
rows={3}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -165,7 +179,10 @@
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
@@ -192,7 +209,7 @@
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.video_form.models")}</Label>
|
||||
<Select type="multiple" bind:value={selectedModelIds}>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectTrigger class="w-full bg-background/50 border-primary/20">
|
||||
{#if selectedModelIds.length}
|
||||
{$_("admin.video_form.models_selected", {
|
||||
values: { count: selectedModelIds.length },
|
||||
@@ -219,15 +236,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
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")}
|
||||
</Button>
|
||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import { DatePicker } from "$lib/components/ui/date-picker";
|
||||
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();
|
||||
|
||||
@@ -97,15 +99,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3 sm:p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" href="/admin/videos" size="sm">
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4 mr-1"></span>{$_("common.back")}
|
||||
</Button>
|
||||
<Meta title={$_("admin.video_form.new_title")} description={null} />
|
||||
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("admin.video_form.new_title")}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||
<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>
|
||||
@@ -116,11 +118,17 @@
|
||||
if (!slug) slug = generateSlug(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")} />
|
||||
<Input
|
||||
id="slug"
|
||||
bind:value={slug}
|
||||
placeholder={$_("admin.video_form.slug_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,28 +139,32 @@
|
||||
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}
|
||||
{#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}
|
||||
{#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} />
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
@@ -177,12 +189,13 @@
|
||||
|
||||
{#if data.models.length > 0}
|
||||
<div class="space-y-2">
|
||||
<Label>Models</Label>
|
||||
<Label>{$_("admin.video_form.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 ${
|
||||
<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"
|
||||
@@ -190,21 +203,19 @@
|
||||
onclick={() => toggleModel(model.id)}
|
||||
>
|
||||
{model.artist_name || model.id}
|
||||
</button>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
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")}
|
||||
</Button>
|
||||
<Button variant="outline" href="/admin/videos">{$_("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,5 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
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 function load() {
|
||||
throw redirect(301, "/play/leaderboard");
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import SexyBackground from "$lib/components/background/background.svelte";
|
||||
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const timeAgo = new TimeAgo("en");
|
||||
const { data } = $props();
|
||||
@@ -49,23 +50,6 @@
|
||||
else params.delete("page");
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||
|
||||
const pageNumbers = $derived(() => {
|
||||
const pages: (number | -1)[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (data.page > 3) pages.push(-1);
|
||||
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
|
||||
pages.push(i);
|
||||
if (data.page < totalPages - 2) pages.push(-1);
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_("magazine.title")} description={$_("magazine.description")} />
|
||||
@@ -308,38 +292,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
{#if Math.ceil(data.total / data.limit) > 1}
|
||||
<div class="flex flex-col items-center gap-3 mt-10">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => goToPage(data.page - 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
|
||||
>
|
||||
{#each pageNumbers() as p, i (i)}
|
||||
{#if p === -1}
|
||||
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={p === data.page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => goToPage(p)}
|
||||
class={p === data.page
|
||||
? "bg-gradient-to-r from-primary to-accent min-w-9"
|
||||
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= totalPages}
|
||||
onclick={() => goToPage(data.page + 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
|
||||
>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={data.page}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_("common.total_results", { values: { total: data.total } })}
|
||||
</p>
|
||||
|
||||
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 { getAnalytics, getFolders, getRecordings } from "$lib/services";
|
||||
import { isModel } from "$lib/api";
|
||||
|
||||
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,
|
||||
};
|
||||
export function load() {
|
||||
throw redirect(302, "/me/profile");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
packages/frontend/src/routes/me/analytics/+page.server.ts
Normal file
13
packages/frontend/src/routes/me/analytics/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { isModel } from "$lib/api";
|
||||
import { getAnalytics } from "$lib/services";
|
||||
|
||||
export async function load({ locals, fetch, cookies }) {
|
||||
if (!isModel(locals.authStatus.user!)) {
|
||||
throw redirect(302, "/me/profile");
|
||||
}
|
||||
const token = cookies.get("session_token") || "";
|
||||
return {
|
||||
analytics: await getAnalytics(fetch, token).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>
|
||||
275
packages/frontend/src/routes/me/profile/+page.svelte
Normal file
275
packages/frontend/src/routes/me/profile/+page.svelte
Normal file
@@ -0,0 +1,275 @@
|
||||
<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 } 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>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_("me.settings.profile_subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||
<CardContent class="space-y-4 pt-6">
|
||||
<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>
|
||||
154
packages/frontend/src/routes/me/security/+page.svelte
Normal file
154
packages/frontend/src/routes/me/security/+page.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<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 } 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="mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("me.settings.privacy_title")}</h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_("me.settings.privacy_subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||
<CardContent class="space-y-4 pt-6">
|
||||
<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>
|
||||
@@ -11,6 +11,7 @@
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import SexyBackground from "$lib/components/background/background.svelte";
|
||||
import PageHero from "$lib/components/page-hero/page-hero.svelte";
|
||||
import Pagination from "$lib/components/pagination/pagination.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -42,23 +43,6 @@
|
||||
else params.delete("page");
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const totalPages = $derived(Math.ceil(data.total / data.limit));
|
||||
|
||||
const pageNumbers = $derived(() => {
|
||||
const pages: (number | -1)[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (data.page > 3) pages.push(-1);
|
||||
for (let i = Math.max(2, data.page - 1); i <= Math.min(totalPages - 1, data.page + 1); i++)
|
||||
pages.push(i);
|
||||
if (data.page < totalPages - 2) pages.push(-1);
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Meta title={$_("models.title")} description={$_("models.description")} />
|
||||
@@ -196,38 +180,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
{#if Math.ceil(data.total / data.limit) > 1}
|
||||
<div class="flex flex-col items-center gap-3 mt-10">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => goToPage(data.page - 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
|
||||
>
|
||||
{#each pageNumbers() as p, i (i)}
|
||||
{#if p === -1}
|
||||
<span class="px-2 text-muted-foreground select-none">…</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={p === data.page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => goToPage(p)}
|
||||
class={p === data.page
|
||||
? "bg-gradient-to-r from-primary to-accent min-w-9"
|
||||
: "border-primary/20 hover:bg-primary/10 min-w-9"}>{p}</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= totalPages}
|
||||
onclick={() => goToPage(data.page + 1)}
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
|
||||
>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={data.page}
|
||||
totalPages={Math.ceil(data.total / data.limit)}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_("common.total_results", { values: { total: data.total } })}
|
||||
</p>
|
||||
|
||||
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 };
|
||||
}
|
||||
125
packages/frontend/src/routes/play/+layout.svelte
Normal file
125
packages/frontend/src/routes/play/+layout.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<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 overflow-hidden"
|
||||
>
|
||||
<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 type { Recording } from "$lib/types";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export async function load({ locals, url, fetch }) {
|
||||
const recordingId = url.searchParams.get("recording");
|
||||
|
||||
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,
|
||||
};
|
||||
export function load() {
|
||||
throw redirect(302, "/play/buttplug");
|
||||
}
|
||||
|
||||
14
packages/frontend/src/routes/play/buttplug/+page.server.ts
Normal file
14
packages/frontend/src/routes/play/buttplug/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getRecording } from "$lib/services";
|
||||
import type { Recording } from "$lib/types";
|
||||
|
||||
export async function load({ url, fetch, cookies }) {
|
||||
const recordingId = url.searchParams.get("recording");
|
||||
const token = cookies.get("session_token") || "";
|
||||
|
||||
let recording: Recording | null = null;
|
||||
if (recordingId) {
|
||||
recording = await getRecording(recordingId, fetch, token).catch(() => null);
|
||||
}
|
||||
|
||||
return { recording };
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
<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 type * as ButtplugTypes from "@sexy/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 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";
|
||||
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;
|
||||
@@ -40,7 +40,6 @@
|
||||
|
||||
async function init() {
|
||||
const connector = new ButtplugWasmClientConnector();
|
||||
// await ButtplugWasmClientConnector.activateLogging("info");
|
||||
await client.connect(connector);
|
||||
client.on("deviceadded", onDeviceAdded);
|
||||
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
|
||||
@@ -61,7 +60,6 @@
|
||||
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 {
|
||||
@@ -94,16 +92,13 @@
|
||||
const outputType = actuator.outputType as typeof ButtplugTypes.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;
|
||||
}
|
||||
if (devices.length === 0) return;
|
||||
isRecording = true;
|
||||
recordingStartTime = performance.now();
|
||||
recordedEvents = [];
|
||||
@@ -130,7 +125,7 @@
|
||||
device_name: device.name,
|
||||
actuator_index: actuatorIdx,
|
||||
actuator_type: actuator.outputType,
|
||||
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
|
||||
value: (value / actuator.maxSteps) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,7 +160,11 @@
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) {
|
||||
async function handleSaveRecording(saveData: {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
|
||||
name: d.name,
|
||||
index: d.info.index,
|
||||
@@ -173,33 +172,20 @@
|
||||
}));
|
||||
|
||||
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,
|
||||
await createRecording({
|
||||
title: saveData.title,
|
||||
description: saveData.description,
|
||||
duration: Math.round(recordingDuration),
|
||||
events: recordedEvents,
|
||||
device_info: deviceInfo,
|
||||
tags: data.tags,
|
||||
tags: saveData.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.");
|
||||
@@ -212,24 +198,19 @@
|
||||
recordingDuration = 0;
|
||||
}
|
||||
|
||||
// Playback functions
|
||||
function startPlayback() {
|
||||
if (!data.recording) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -259,8 +240,6 @@
|
||||
}
|
||||
playbackProgress = 0;
|
||||
currentEventIndex = 0;
|
||||
|
||||
// Stop all devices
|
||||
devices.forEach((device) => handleStop(device));
|
||||
}
|
||||
|
||||
@@ -274,7 +253,6 @@
|
||||
|
||||
function resumePlayback() {
|
||||
if (!data.recording) return;
|
||||
|
||||
isPlaying = true;
|
||||
playbackStartTime = performance.now() - playbackProgress;
|
||||
scheduleNextEvent();
|
||||
@@ -295,12 +273,10 @@
|
||||
const delay = event.timestamp - currentTime;
|
||||
|
||||
if (delay <= 0) {
|
||||
// Execute event immediately
|
||||
executeEvent(event);
|
||||
currentEventIndex++;
|
||||
scheduleNextEvent();
|
||||
} else {
|
||||
// Schedule event
|
||||
playbackTimeoutId = setTimeout(() => {
|
||||
executeEvent(event);
|
||||
currentEventIndex++;
|
||||
@@ -311,31 +287,25 @@
|
||||
}
|
||||
|
||||
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 ButtplugTypes.OutputType;
|
||||
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
|
||||
}
|
||||
|
||||
// Update UI
|
||||
actuator.value = deviceValue;
|
||||
}
|
||||
|
||||
@@ -345,7 +315,6 @@
|
||||
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) {
|
||||
@@ -364,10 +333,6 @@
|
||||
const { data } = $props();
|
||||
|
||||
onMount(async () => {
|
||||
if (!data.authStatus.authenticated) {
|
||||
goto("/login");
|
||||
return;
|
||||
}
|
||||
// Concatenation prevents Rollup from statically resolving this URL at build time
|
||||
const buttplugUrl = "/buttplug/" + "dist/index.js";
|
||||
const bp = await import(/* @vite-ignore */ buttplugUrl);
|
||||
@@ -382,90 +347,42 @@
|
||||
|
||||
<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">
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<!-- 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 class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("play.title")}</h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_("play.description")}</p>
|
||||
</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>
|
||||
|
||||
<!-- 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
|
||||
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>
|
||||
<span class="icon-[ri--record-circle-line] w-4 h-4 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>
|
||||
<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}
|
||||
</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="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-2">
|
||||
<h2 class="text-xl font-semibold text-card-foreground mb-1">
|
||||
{data.recording.title}
|
||||
</h2>
|
||||
{#if data.recording.description}
|
||||
@@ -479,9 +396,7 @@
|
||||
<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
|
||||
)
|
||||
{Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
@@ -523,7 +438,6 @@
|
||||
<!-- Playback Buttons -->
|
||||
<div class="flex gap-2 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onclick={stopPlayback}
|
||||
disabled={!isPlaying && playbackProgress === 0}
|
||||
@@ -533,7 +447,6 @@
|
||||
</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]"
|
||||
@@ -543,7 +456,6 @@
|
||||
</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]"
|
||||
>
|
||||
@@ -570,11 +482,10 @@
|
||||
</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}
|
||||
|
||||
<!-- 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}
|
||||
@@ -582,15 +493,34 @@
|
||||
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}
|
||||
</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>
|
||||
</Button>
|
||||
</Empty.Content>
|
||||
</Empty.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -618,4 +548,3 @@
|
||||
onCancel={handleMappingCancel}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
197
packages/frontend/src/routes/play/leaderboard/+page.svelte
Normal file
197
packages/frontend/src/routes/play/leaderboard/+page.svelte
Normal file
@@ -0,0 +1,197 @@
|
||||
<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-2 sm:gap-4 px-2 py-2 sm:p-4 rounded-lg hover:bg-accent/10 transition-colors group"
|
||||
>
|
||||
<!-- Rank Badge -->
|
||||
<div class="flex-shrink-0 w-8 sm:w-14 text-center">
|
||||
{#if entry.rank <= 3}
|
||||
<span class="text-2xl sm:text-3xl">{getMedalEmoji(entry.rank)}</span>
|
||||
{:else}
|
||||
<span
|
||||
class="text-base sm:text-xl font-bold text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
>
|
||||
#{entry.rank}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<Avatar
|
||||
class="h-9 w-9 sm:h-12 sm:w-12 shrink-0 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-xs sm:text-sm text-muted-foreground flex items-center gap-2 sm:gap-3"
|
||||
>
|
||||
<span title={$_("gamification.recordings")}>
|
||||
<span class="icon-[ri--video-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
|
||||
></span>
|
||||
{entry.recordings_count}
|
||||
</span>
|
||||
<span title={$_("gamification.plays")}>
|
||||
<span class="icon-[ri--play-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
|
||||
></span>
|
||||
{entry.playbacks_count}
|
||||
</span>
|
||||
<span title={$_("gamification.achievements")}>
|
||||
<span class="icon-[ri--trophy-line] w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-0.5"
|
||||
></span>
|
||||
{entry.achievements_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-lg sm:text-2xl font-bold text-primary">
|
||||
{formatPoints(entry.total_weighted_points)}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground hidden sm:block">
|
||||
{$_("gamification.points")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow indicator -->
|
||||
<div
|
||||
class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hidden sm:block"
|
||||
>
|
||||
<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">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--information-line] w-5 h-5 text-primary"></span>
|
||||
{$_("gamification.how_it_works")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user