Compare commits
85 Commits
798495c3d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 79932157bf | |||
| 04b0ec1a71 | |||
| cc693d8be7 | |||
| 52aa00dd13 | |||
| 8085b40af8 | |||
| 5f40a812d3 | |||
| 1b724e86c9 | |||
| a9e4ed6049 | |||
| 66179d7ba8 | |||
| 3a8fa7d8ce | |||
| fddc3f15d0 | |||
| d9a60f0572 | |||
| ba648c796a | |||
| 27e2ff5f66 | |||
| b7a29c55b3 | |||
| 99b2ed7f2b | |||
| 8357aecf98 | |||
| ab3d9f4118 | |||
| 5219fae36a | |||
| 7de1bf7a03 | |||
| a4fd1ff18b | |||
| 6605980a43 | |||
| 15d9708072 | |||
| 89c4c390fa | |||
| f5ff59b910 | |||
| fc97c1b84b | |||
| e2abb0794a | |||
| 2644e033b4 | |||
| ee1cea6d01 | |||
| 1496399b96 | |||
| 075f64f4e3 | |||
| 8c6c98d612 | |||
| 28be084781 | |||
| 21b8d2c223 | |||
| b315062d43 | |||
| 5bef996dbc | |||
| da2484d232 | |||
| 722392d19e | |||
| a07a5cb091 | |||
| ea23233645 | |||
| 6dcdc0130b | |||
| 8508e1f6e9 | |||
| 6abcfc7363 | |||
| d4b3968518 | |||
| 8f4999f127 | |||
| 4b53a25fa3 | |||
| 4f85637875 | |||
| 1175b4d0e6 | |||
| 2afa3c6e9b | |||
| b55cebea4e | |||
| 9845553d49 | |||
| ced0a08da3 | |||
| f880aa5957 | |||
| 239128bf5e | |||
| 0a50c3efd8 | |||
| af4a11b73c | |||
| 627ce75719 | |||
| 446e9f835b | |||
| 422f97417e | |||
| edee98b552 | |||
| b9b98f178f | |||
| dc1850126b | |||
| 4d81266cb1 | |||
| 2980c0b637 | |||
| 7af9c0d7ca | |||
| 76d71ee7c3 | |||
| 90497e9e7c | |||
| a558449964 | |||
| e236ced12a | |||
| 8313664d70 | |||
| ae0929ad06 | |||
| b78831231d | |||
| f90b045ca5 | |||
| d2cbb1004f | |||
| 77ebccf6fa | |||
| 1c101406f6 | |||
| cb7720ca9c | |||
| df099b2700 | |||
| 291f72381f | |||
| 1a2fab3e37 | |||
| 56b57486dc | |||
| a050e886cb | |||
| 519fd45d8d | |||
| 0592d27a15 | |||
| a38883e631 |
@@ -7,9 +7,17 @@ on:
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "packages/backend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile.backend"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/backend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile.backend"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
68
.gitea/workflows/docker-build-buttplug.yml
Normal file
68
.gitea/workflows/docker-build-buttplug.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Build and Push Buttplug Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "packages/buttplug/**"
|
||||
- "Dockerfile.buttplug"
|
||||
- "nginx.buttplug.conf"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/buttplug/**"
|
||||
- "Dockerfile.buttplug"
|
||||
- "nginx.buttplug.conf"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: dev.pivoine.art
|
||||
IMAGE_NAME: valknar/sexy-buttplug
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.buttplug
|
||||
platforms: linux/amd64
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
@@ -7,9 +7,17 @@ on:
|
||||
- develop
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "packages/frontend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/frontend/**"
|
||||
- "packages/types/**"
|
||||
- "Dockerfile"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
47
Dockerfile
47
Dockerfile
@@ -3,7 +3,7 @@
|
||||
# ============================================================================
|
||||
# Base stage - shared dependencies
|
||||
# ============================================================================
|
||||
FROM node:22.11.0-slim AS base
|
||||
FROM node:22.14.0-slim AS base
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN npm install -g corepack@latest && corepack enable
|
||||
@@ -20,48 +20,22 @@ RUN mkdir -p ./packages/frontend && \
|
||||
printf 'PUBLIC_API_URL=\nPUBLIC_URL=\nPUBLIC_UMAMI_ID=\nPUBLIC_UMAMI_SCRIPT=\n' > ./packages/frontend/.env
|
||||
|
||||
# ============================================================================
|
||||
# Builder stage - compile application with Rust/WASM support
|
||||
# Builder stage - compile frontend
|
||||
# ============================================================================
|
||||
FROM base AS builder
|
||||
ARG CI=false
|
||||
ENV CI=$CI
|
||||
|
||||
# Install build dependencies for Rust and native modules
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust toolchain
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
|
||||
--default-toolchain stable \
|
||||
--profile minimal \
|
||||
--target wasm32-unknown-unknown
|
||||
|
||||
# Add Rust to PATH
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Install wasm-bindgen-cli
|
||||
RUN cargo install wasm-bindgen-cli
|
||||
|
||||
# Copy source files
|
||||
COPY packages ./packages
|
||||
|
||||
# Install all dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build packages in correct order with WASM support
|
||||
# 1. Build buttplug WASM
|
||||
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
|
||||
# Generate SvelteKit type definitions (creates .svelte-kit/tsconfig.json)
|
||||
RUN pnpm --filter @sexy.pivoine.art/frontend exec svelte-kit sync
|
||||
|
||||
# 2. Build buttplug TypeScript
|
||||
RUN pnpm --filter @sexy.pivoine.art/buttplug build
|
||||
|
||||
# 3. Build frontend
|
||||
# Build frontend
|
||||
RUN pnpm --filter @sexy.pivoine.art/frontend build
|
||||
|
||||
# Prune dev dependencies for production
|
||||
@@ -70,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 \
|
||||
@@ -91,19 +65,14 @@ COPY --from=builder --chown=node:node /app/package.json ./package.json
|
||||
COPY --from=builder --chown=node:node /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=builder --chown=node:node /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
# Create package directories
|
||||
RUN mkdir -p packages/frontend packages/buttplug
|
||||
# Create package directory
|
||||
RUN mkdir -p packages/frontend
|
||||
|
||||
# Copy frontend artifacts
|
||||
COPY --from=builder --chown=node:node /app/packages/frontend/build ./packages/frontend/build
|
||||
COPY --from=builder --chown=node:node /app/packages/frontend/node_modules ./packages/frontend/node_modules
|
||||
COPY --from=builder --chown=node:node /app/packages/frontend/package.json ./packages/frontend/package.json
|
||||
|
||||
# Copy buttplug artifacts
|
||||
COPY --from=builder --chown=node:node /app/packages/buttplug/dist ./packages/buttplug/dist
|
||||
COPY --from=builder --chown=node:node /app/packages/buttplug/node_modules ./packages/buttplug/node_modules
|
||||
COPY --from=builder --chown=node:node /app/packages/buttplug/package.json ./packages/buttplug/package.json
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
|
||||
|
||||
@@ -3,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
|
||||
|
||||
@@ -34,7 +34,7 @@ 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 \
|
||||
@@ -55,7 +55,7 @@ COPY --from=builder --chown=node:node /app/package.json ./package.json
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/dist ./packages/backend/dist
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/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/migrations
|
||||
COPY --from=builder --chown=node:node /app/packages/backend/src/migrations ./packages/backend/dist/migrations
|
||||
|
||||
RUN mkdir -p /data/uploads && chown node:node /data/uploads
|
||||
|
||||
|
||||
65
Dockerfile.buttplug
Normal file
65
Dockerfile.buttplug
Normal file
@@ -0,0 +1,65 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ============================================================================
|
||||
# Builder stage - compile Rust/WASM and TypeScript
|
||||
# ============================================================================
|
||||
FROM node:22.14.0-slim AS builder
|
||||
|
||||
# Install build dependencies for Rust
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN npm install -g corepack@latest && corepack enable
|
||||
|
||||
# Install Rust toolchain
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
|
||||
--default-toolchain stable \
|
||||
--profile minimal \
|
||||
--target wasm32-unknown-unknown
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Install wasm-bindgen-cli
|
||||
RUN cargo install wasm-bindgen-cli
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace configuration
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/buttplug ./packages/buttplug
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/buttplug
|
||||
|
||||
# Build WASM
|
||||
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js" --cfg=web_sys_unstable_apis' \
|
||||
pnpm --filter @sexy.pivoine.art/buttplug build:wasm
|
||||
|
||||
# Build TypeScript
|
||||
RUN pnpm --filter @sexy.pivoine.art/buttplug build
|
||||
|
||||
# ============================================================================
|
||||
# Runner stage - nginx serving dist/ and wasm/
|
||||
# ============================================================================
|
||||
FROM nginx:1.27-alpine AS runner
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.buttplug.conf /etc/nginx/conf.d/buttplug.conf
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder /app/packages/buttplug/dist /usr/share/nginx/html/dist
|
||||
COPY --from=builder /app/packages/buttplug/wasm /usr/share/nginx/html/wasm
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/dist/index.js || exit 1
|
||||
18
compose.yml
18
compose.yml
@@ -64,6 +64,21 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
buttplug:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.buttplug
|
||||
container_name: sexy_buttplug
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/dist/index.js"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
@@ -78,9 +93,12 @@ services:
|
||||
HOST: 0.0.0.0
|
||||
PUBLIC_API_URL: http://sexy_backend:4000
|
||||
PUBLIC_URL: http://localhost:3000
|
||||
BUTTPLUG_URL: http://sexy_buttplug:80
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
buttplug:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
|
||||
@@ -33,8 +33,6 @@ export default ts.config(
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
// Allow explicit any sparingly — we're adults here
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
// Enforce consistent type imports
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
@@ -53,7 +51,7 @@ export default ts.config(
|
||||
"**/dist/",
|
||||
"**/node_modules/",
|
||||
"**/migrations/",
|
||||
"packages/buttplug/**",
|
||||
"**/wasm/",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
23
nginx.buttplug.conf
Normal file
23
nginx.buttplug.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# WASM MIME type
|
||||
include /etc/nginx/mime.types;
|
||||
types {
|
||||
application/wasm wasm;
|
||||
}
|
||||
|
||||
# Cache JS and WASM aggressively (content-addressed by build)
|
||||
location ~* \.(js|wasm)$ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Cross-Origin-Resource-Policy "cross-origin";
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@
|
||||
"type": "module",
|
||||
"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",
|
||||
"build:frontend": "pnpm --filter @sexy.pivoine.art/frontend build",
|
||||
"build:backend": "pnpm --filter @sexy.pivoine.art/backend build",
|
||||
"dev:buttplug": "pnpm --filter @sexy.pivoine.art/buttplug serve",
|
||||
"dev:data": "docker compose up -d postgres redis",
|
||||
"dev:backend": "pnpm --filter @sexy.pivoine.art/backend dev",
|
||||
"dev": "pnpm dev:data && pnpm dev:backend & pnpm --filter @sexy.pivoine.art/frontend dev",
|
||||
"dev": "pnpm dev:data && pnpm dev:backend & pnpm dev:buttplug & pnpm --filter @sexy.pivoine.art/frontend dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
@@ -22,7 +23,7 @@
|
||||
"email": "valknar@pivoine.art"
|
||||
},
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"argon2",
|
||||
|
||||
@@ -14,14 +14,15 @@
|
||||
"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.pivoine.art/types": "workspace:*",
|
||||
"argon2": "^0.43.0",
|
||||
"bullmq": "^5.70.4",
|
||||
"drizzle-orm": "^0.44.1",
|
||||
"fastify": "^5.4.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
|
||||
@@ -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`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export const users = pgTable(
|
||||
role: roleEnum("role").notNull().default("viewer"),
|
||||
avatar: text("avatar").references(() => files.id, { onDelete: "set null" }),
|
||||
banner: text("banner").references(() => files.id, { onDelete: "set null" }),
|
||||
photo: text("photo").references(() => files.id, { onDelete: "set null" }),
|
||||
is_admin: boolean("is_admin").notNull().default(false),
|
||||
email_verified: boolean("email_verified").notNull().default(false),
|
||||
email_verify_token: text("email_verify_token"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { builder } from "../builder";
|
||||
import { ArticleType, ArticleListType, AdminArticleListType } from "../types/index";
|
||||
import { articles, users } from "../../db/schema/index";
|
||||
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains } from "drizzle-orm";
|
||||
import { eq, and, lte, desc, asc, ilike, or, count, arrayContains, type SQL } from "drizzle-orm";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
import type { DB } from "../../db/connection";
|
||||
|
||||
async function enrichArticle(db: any, article: any) {
|
||||
async function enrichArticle(db: DB, article: typeof articles.$inferSelect) {
|
||||
let author = null;
|
||||
if (article.author) {
|
||||
const authorUser = await db
|
||||
@@ -13,6 +14,7 @@ async function enrichArticle(db: any, article: any) {
|
||||
artist_name: users.artist_name,
|
||||
slug: users.slug,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article.author))
|
||||
@@ -38,7 +40,7 @@ builder.queryField("articles", (t) =>
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: any[] = [lte(articles.publish_date, new Date())];
|
||||
const conditions: SQL<unknown>[] = [lte(articles.publish_date, new Date())];
|
||||
if (args.featured !== null && args.featured !== undefined) {
|
||||
conditions.push(eq(articles.featured, args.featured));
|
||||
}
|
||||
@@ -49,28 +51,24 @@ builder.queryField("articles", (t) =>
|
||||
or(
|
||||
ilike(articles.title, `%${args.search}%`),
|
||||
ilike(articles.excerpt, `%${args.search}%`),
|
||||
),
|
||||
) as SQL<unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
const orderArgs =
|
||||
args.sortBy === "name"
|
||||
? [asc(articles.title)]
|
||||
: args.sortBy === "featured"
|
||||
? [desc(articles.featured), desc(articles.publish_date)]
|
||||
: [desc(articles.publish_date)];
|
||||
|
||||
const where = and(...conditions);
|
||||
const baseQuery = ctx.db.select().from(articles).where(where);
|
||||
const ordered =
|
||||
args.sortBy === "name"
|
||||
? baseQuery.orderBy(asc(articles.title))
|
||||
: args.sortBy === "featured"
|
||||
? baseQuery.orderBy(desc(articles.featured), desc(articles.publish_date))
|
||||
: baseQuery.orderBy(desc(articles.publish_date));
|
||||
|
||||
const [articleList, totalRows] = await Promise.all([
|
||||
(ctx.db.select().from(articles).where(where) as any)
|
||||
.orderBy(...orderArgs)
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
ordered.limit(pageSize).offset(offset),
|
||||
ctx.db.select({ total: count() }).from(articles).where(where),
|
||||
]);
|
||||
const items = await Promise.all(
|
||||
articleList.map((article: any) => enrichArticle(ctx.db, article)),
|
||||
);
|
||||
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
@@ -129,13 +127,13 @@ builder.queryField("adminListArticles", (t) =>
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: any[] = [];
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if (args.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(articles.title, `%${args.search}%`),
|
||||
ilike(articles.excerpt, `%${args.search}%`),
|
||||
),
|
||||
) as SQL<unknown>,
|
||||
);
|
||||
}
|
||||
if (args.category) conditions.push(eq(articles.category, args.category));
|
||||
@@ -153,9 +151,7 @@ builder.queryField("adminListArticles", (t) =>
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(articles).where(where),
|
||||
]);
|
||||
const items = await Promise.all(
|
||||
articleList.map((article: any) => enrichArticle(ctx.db, article)),
|
||||
);
|
||||
const items = await Promise.all(articleList.map((article) => enrichArticle(ctx.db, article)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
@@ -231,7 +227,7 @@ builder.mutationField("updateArticle", (t) =>
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(articles)
|
||||
.set(updates as any)
|
||||
.set(updates as Partial<typeof articles.$inferInsert>)
|
||||
.where(eq(articles.id, args.id))
|
||||
.returning();
|
||||
if (!updated[0]) return null;
|
||||
|
||||
@@ -3,9 +3,13 @@ import { builder } from "../builder";
|
||||
import { CurrentUserType } from "../types/index";
|
||||
import { users } from "../../db/schema/index";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
interface ReplyLike {
|
||||
header?: (name: string, value: string) => void;
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -46,12 +50,7 @@ builder.mutationField("login", (t) =>
|
||||
// Set session cookie
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||
|
||||
// For graphql-yoga response
|
||||
if ((ctx as any).serverResponse) {
|
||||
(ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue);
|
||||
}
|
||||
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
|
||||
|
||||
return user[0];
|
||||
},
|
||||
@@ -76,7 +75,7 @@ builder.mutationField("logout", (t) =>
|
||||
// Clear cookie
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const cookieValue = `session_token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isProduction ? "; Secure" : ""}`;
|
||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||
(ctx.reply as ReplyLike).header?.("Set-Cookie", cookieValue);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
@@ -132,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;
|
||||
},
|
||||
@@ -191,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;
|
||||
},
|
||||
|
||||
@@ -2,9 +2,9 @@ import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder";
|
||||
import { CommentType, AdminCommentListType } from "../types/index";
|
||||
import { comments, users } from "../../db/schema/index";
|
||||
import { eq, and, desc, ilike, or, count } from "drizzle-orm";
|
||||
import { awardPoints, checkAchievements } from "../../lib/gamification";
|
||||
import { eq, and, desc, ilike, count } from "drizzle-orm";
|
||||
import { requireOwnerOrAdmin, requireAdmin } from "../../lib/acl";
|
||||
import { gamificationQueue } from "../../queues/index";
|
||||
|
||||
builder.queryField("commentsForVideo", (t) =>
|
||||
t.field({
|
||||
@@ -20,7 +20,7 @@ builder.queryField("commentsForVideo", (t) =>
|
||||
.orderBy(desc(comments.date_created));
|
||||
|
||||
return Promise.all(
|
||||
commentList.map(async (c: any) => {
|
||||
commentList.map(async (c) => {
|
||||
const user = await ctx.db
|
||||
.select({
|
||||
id: users.id,
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
@@ -125,7 +143,7 @@ builder.queryField("adminListComments", (t) =>
|
||||
]);
|
||||
|
||||
const items = await Promise.all(
|
||||
commentList.map(async (c: any) => {
|
||||
commentList.map(async (c) => {
|
||||
const user = await ctx.db
|
||||
.select({
|
||||
id: users.id,
|
||||
|
||||
@@ -37,7 +37,7 @@ builder.queryField("leaderboard", (t) =>
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return entries.map((e: any, i: number) => ({ ...e, rank: offset + i + 1 }));
|
||||
return entries.map((e, i) => ({ ...e, rank: offset + i + 1 }));
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -101,8 +101,15 @@ builder.queryField("userGamification", (t) =>
|
||||
|
||||
return {
|
||||
stats: stats[0] ? { ...stats[0], rank } : null,
|
||||
achievements: userAchievements.map((a: any) => ({
|
||||
...a,
|
||||
achievements: userAchievements.map((a) => ({
|
||||
id: a.id!,
|
||||
code: a.code!,
|
||||
name: a.name!,
|
||||
description: a.description!,
|
||||
icon: a.icon!,
|
||||
category: a.category!,
|
||||
required_count: a.required_count!,
|
||||
progress: a.progress!,
|
||||
date_unlocked: a.date_unlocked!,
|
||||
})),
|
||||
recent_points: recentPoints,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { builder } from "../builder";
|
||||
import { ModelType, ModelListType } from "../types/index";
|
||||
import { users, user_photos, files } from "../../db/schema/index";
|
||||
import { eq, and, desc, asc, ilike, count, arrayContains } from "drizzle-orm";
|
||||
import { eq, and, desc, asc, ilike, count, arrayContains, type SQL } from "drizzle-orm";
|
||||
import type { DB } from "../../db/connection";
|
||||
|
||||
async function enrichModel(db: any, user: any) {
|
||||
async function enrichModel(db: DB, user: typeof users.$inferSelect) {
|
||||
// Fetch photos
|
||||
const photoRows = await db
|
||||
.select({ id: files.id, filename: files.filename })
|
||||
@@ -14,8 +15,8 @@ async function enrichModel(db: any, user: any) {
|
||||
|
||||
const seen = new Set<string>();
|
||||
const photos = photoRows
|
||||
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
|
||||
.map((p: any) => ({ id: p.id, filename: p.filename }));
|
||||
.filter((p) => p.id !== null && !seen.has(p.id!) && seen.add(p.id!))
|
||||
.map((p) => ({ id: p.id!, filename: p.filename! }));
|
||||
|
||||
return { ...user, photos };
|
||||
}
|
||||
@@ -35,7 +36,7 @@ builder.queryField("models", (t) =>
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: any[] = [eq(users.role, "model")];
|
||||
const conditions: SQL<unknown>[] = [eq(users.role, "model")];
|
||||
if (args.search) conditions.push(ilike(users.artist_name, `%${args.search}%`));
|
||||
if (args.tag) conditions.push(arrayContains(users.tags, [args.tag]));
|
||||
|
||||
@@ -46,7 +47,7 @@ builder.queryField("models", (t) =>
|
||||
ctx.db.select().from(users).where(where).orderBy(order).limit(pageSize).offset(offset),
|
||||
ctx.db.select({ total: count() }).from(users).where(where),
|
||||
]);
|
||||
const items = await Promise.all(modelList.map((m: any) => enrichModel(ctx.db, m)));
|
||||
const items = await Promise.all(modelList.map((m) => enrichModel(ctx.db, m)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
|
||||
151
packages/backend/src/graphql/resolvers/queues.ts
Normal file
151
packages/backend/src/graphql/resolvers/queues.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import type { Job } from "bullmq";
|
||||
import { builder } from "../builder.js";
|
||||
import { JobType, QueueInfoType } from "../types/index.js";
|
||||
import { queues } from "../../queues/index.js";
|
||||
import { requireAdmin } from "../../lib/acl.js";
|
||||
|
||||
const JOB_STATUSES = ["waiting", "active", "completed", "failed", "delayed"] as const;
|
||||
type JobStatus = (typeof JOB_STATUSES)[number];
|
||||
|
||||
async function toJobData(job: Job, queueName: string) {
|
||||
const status = await job.getState();
|
||||
return {
|
||||
id: job.id ?? "",
|
||||
name: job.name,
|
||||
queue: queueName,
|
||||
status,
|
||||
data: job.data as unknown,
|
||||
result: job.returnvalue as unknown,
|
||||
failedReason: job.failedReason ?? null,
|
||||
attemptsMade: job.attemptsMade,
|
||||
createdAt: new Date(job.timestamp),
|
||||
processedAt: job.processedOn ? new Date(job.processedOn) : null,
|
||||
finishedAt: job.finishedOn ? new Date(job.finishedOn) : null,
|
||||
progress: typeof job.progress === "number" ? job.progress : null,
|
||||
};
|
||||
}
|
||||
|
||||
builder.queryField("adminQueues", (t) =>
|
||||
t.field({
|
||||
type: [QueueInfoType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
return Promise.all(
|
||||
Object.entries(queues).map(async ([name, queue]) => {
|
||||
const counts = await queue.getJobCounts(
|
||||
"waiting",
|
||||
"active",
|
||||
"completed",
|
||||
"failed",
|
||||
"delayed",
|
||||
"paused",
|
||||
);
|
||||
const isPaused = await queue.isPaused();
|
||||
return {
|
||||
name,
|
||||
counts: {
|
||||
waiting: counts.waiting ?? 0,
|
||||
active: counts.active ?? 0,
|
||||
completed: counts.completed ?? 0,
|
||||
failed: counts.failed ?? 0,
|
||||
delayed: counts.delayed ?? 0,
|
||||
paused: counts.paused ?? 0,
|
||||
},
|
||||
isPaused,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.queryField("adminQueueJobs", (t) =>
|
||||
t.field({
|
||||
type: [JobType],
|
||||
args: {
|
||||
queue: t.arg.string({ required: true }),
|
||||
status: t.arg.string(),
|
||||
limit: t.arg.int(),
|
||||
offset: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
|
||||
const limit = args.limit ?? 25;
|
||||
const offset = args.offset ?? 0;
|
||||
const statuses: JobStatus[] = args.status ? [args.status as JobStatus] : [...JOB_STATUSES];
|
||||
|
||||
const jobs = await queue.getJobs(statuses, offset, offset + limit - 1);
|
||||
return Promise.all(jobs.map((job) => toJobData(job, args.queue)));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminRetryJob", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
queue: t.arg.string({ required: true }),
|
||||
jobId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
const job = await queue.getJob(args.jobId);
|
||||
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
|
||||
await job.retry();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminRemoveJob", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
queue: t.arg.string({ required: true }),
|
||||
jobId: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
const job = await queue.getJob(args.jobId);
|
||||
if (!job) throw new GraphQLError(`Job "${args.jobId}" not found`);
|
||||
await job.remove();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminPauseQueue", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: { queue: t.arg.string({ required: true }) },
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
await queue.pause();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("adminResumeQueue", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: { queue: t.arg.string({ required: true }) },
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
const queue = queues[args.queue];
|
||||
if (!queue) throw new GraphQLError(`Queue "${args.queue}" not found`);
|
||||
await queue.resume();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1,11 +1,11 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder";
|
||||
import { RecordingType, AdminRecordingListType } from "../types/index";
|
||||
import { recordings, recording_plays, users } from "../../db/schema/index";
|
||||
import { eq, and, desc, ilike, count } from "drizzle-orm";
|
||||
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({
|
||||
@@ -21,7 +21,7 @@ builder.queryField("recordings", (t) =>
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
|
||||
const conditions = [eq(recordings.user_id, ctx.currentUser.id)];
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as any));
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
|
||||
if (args.linkedVideoId) conditions.push(eq(recordings.linked_video, args.linkedVideoId));
|
||||
|
||||
const limit = args.limit || 50;
|
||||
@@ -115,18 +115,25 @@ builder.mutationField("createRecording", (t) =>
|
||||
user_id: ctx.currentUser.id,
|
||||
tags: args.tags || [],
|
||||
linked_video: args.linkedVideoId || null,
|
||||
status: (args.status as any) || "draft",
|
||||
status: (args.status as "draft" | "published") || "draft",
|
||||
public: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const recording = newRecording[0];
|
||||
|
||||
// Gamification (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;
|
||||
@@ -174,21 +181,51 @@ builder.mutationField("updateRecording", (t) =>
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(recordings)
|
||||
.set(updates as any)
|
||||
.set(updates as Partial<typeof recordings.$inferInsert>)
|
||||
.where(eq(recordings.id, args.id))
|
||||
.returning();
|
||||
|
||||
const recording = updated[0];
|
||||
|
||||
// Gamification (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;
|
||||
@@ -355,9 +428,9 @@ builder.queryField("adminListRecordings", (t) =>
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: any[] = [];
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if (args.search) conditions.push(ilike(recordings.title, `%${args.search}%`));
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as any));
|
||||
if (args.status) conditions.push(eq(recordings.status, args.status as "draft" | "published"));
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder";
|
||||
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
|
||||
import { users, user_photos, files } from "../../db/schema/index";
|
||||
import { eq, ilike, or, count, and } from "drizzle-orm";
|
||||
import { eq, ilike, or, count, and, asc, type SQL } from "drizzle-orm";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
|
||||
builder.queryField("me", (t) =>
|
||||
@@ -45,6 +45,7 @@ builder.mutationField("updateProfile", (t) =>
|
||||
artistName: t.arg.string(),
|
||||
description: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
avatar: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
|
||||
@@ -58,10 +59,11 @@ builder.mutationField("updateProfile", (t) =>
|
||||
if (args.description !== undefined && args.description !== null)
|
||||
updates.description = args.description;
|
||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||
if (args.avatar !== undefined) updates.avatar = args.avatar;
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set(updates as any)
|
||||
.set(updates as Partial<typeof users.$inferInsert>)
|
||||
.where(eq(users.id, ctx.currentUser.id));
|
||||
|
||||
const updated = await ctx.db
|
||||
@@ -91,27 +93,27 @@ builder.queryField("adminListUsers", (t) =>
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
let query = ctx.db.select().from(users);
|
||||
let countQuery = ctx.db.select({ total: count() }).from(users);
|
||||
|
||||
const conditions: any[] = [];
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if (args.role) {
|
||||
conditions.push(eq(users.role, args.role as any));
|
||||
conditions.push(eq(users.role, args.role as "model" | "viewer" | "admin"));
|
||||
}
|
||||
if (args.search) {
|
||||
const pattern = `%${args.search}%`;
|
||||
conditions.push(or(ilike(users.email, pattern), ilike(users.artist_name, pattern)));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
const where = conditions.length === 1 ? conditions[0] : and(...conditions);
|
||||
query = (query as any).where(where);
|
||||
countQuery = (countQuery as any).where(where);
|
||||
conditions.push(
|
||||
or(ilike(users.email, pattern), ilike(users.artist_name, pattern)) as SQL<unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const [items, totalRows] = await Promise.all([
|
||||
(query as any).limit(limit).offset(offset),
|
||||
countQuery,
|
||||
ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(where)
|
||||
.orderBy(asc(users.artist_name))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(users).where(where),
|
||||
]);
|
||||
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
@@ -132,12 +134,14 @@ builder.mutationField("adminUpdateUser", (t) =>
|
||||
artistName: t.arg.string(),
|
||||
avatarId: t.arg.string(),
|
||||
bannerId: t.arg.string(),
|
||||
photoId: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireAdmin(ctx);
|
||||
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.role !== undefined && args.role !== null) updates.role = args.role as any;
|
||||
if (args.role !== undefined && args.role !== null)
|
||||
updates.role = args.role as "model" | "viewer" | "admin";
|
||||
if (args.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin;
|
||||
if (args.firstName !== undefined && args.firstName !== null)
|
||||
updates.first_name = args.firstName;
|
||||
@@ -146,10 +150,11 @@ builder.mutationField("adminUpdateUser", (t) =>
|
||||
updates.artist_name = args.artistName;
|
||||
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
|
||||
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
|
||||
if (args.photoId !== undefined && args.photoId !== null) updates.photo = args.photoId;
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(users)
|
||||
.set(updates as any)
|
||||
.set(updates as Partial<typeof users.$inferInsert>)
|
||||
.where(eq(users.id, args.userId))
|
||||
.returning();
|
||||
|
||||
@@ -192,8 +197,8 @@ builder.queryField("adminGetUser", (t) =>
|
||||
.orderBy(user_photos.sort);
|
||||
const seen = new Set<string>();
|
||||
const photos = photoRows
|
||||
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
|
||||
.map((p: any) => ({ id: p.id, filename: p.filename }));
|
||||
.filter((p) => p.id !== null && !seen.has(p.id!) && seen.add(p.id!))
|
||||
.map((p) => ({ id: p.id!, filename: p.filename! }));
|
||||
return { ...user[0], photos };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -28,10 +28,12 @@ import {
|
||||
lt,
|
||||
gte,
|
||||
arrayContains,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { requireAdmin } from "../../lib/acl";
|
||||
import type { DB } from "../../db/connection";
|
||||
|
||||
async function enrichVideo(db: any, video: any) {
|
||||
async function enrichVideo(db: DB, video: typeof videos.$inferSelect) {
|
||||
// Fetch models
|
||||
const modelRows = await db
|
||||
.select({
|
||||
@@ -39,6 +41,7 @@ async function enrichVideo(db: any, video: any) {
|
||||
artist_name: users.artist_name,
|
||||
slug: users.slug,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(video_models)
|
||||
.leftJoin(users, eq(video_models.user_id, users.id))
|
||||
@@ -61,9 +64,19 @@ async function enrichVideo(db: any, video: any) {
|
||||
.from(video_plays)
|
||||
.where(eq(video_plays.video_id, video.id));
|
||||
|
||||
const models = modelRows
|
||||
.filter((m) => m.id !== null)
|
||||
.map((m) => ({
|
||||
id: m.id!,
|
||||
artist_name: m.artist_name,
|
||||
slug: m.slug,
|
||||
avatar: m.avatar,
|
||||
description: m.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
...video,
|
||||
models: modelRows,
|
||||
models,
|
||||
movie_file: movieFile,
|
||||
likes_count: likesCount[0]?.count || 0,
|
||||
plays_count: playsCount[0]?.count || 0,
|
||||
@@ -87,7 +100,7 @@ builder.queryField("videos", (t) =>
|
||||
const pageSize = args.limit ?? 24;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: any[] = [lte(videos.upload_date, new Date())];
|
||||
const conditions: SQL<unknown>[] = [lte(videos.upload_date, new Date())];
|
||||
if (!ctx.currentUser) conditions.push(eq(videos.premium, false));
|
||||
if (args.featured !== null && args.featured !== undefined) {
|
||||
conditions.push(eq(videos.featured, args.featured));
|
||||
@@ -107,7 +120,7 @@ builder.queryField("videos", (t) =>
|
||||
conditions.push(
|
||||
inArray(
|
||||
videos.id,
|
||||
videoIds.map((v: any) => v.video_id),
|
||||
videoIds.map((v) => v.video_id),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -148,8 +161,8 @@ builder.queryField("videos", (t) =>
|
||||
.leftJoin(files, eq(videos.movie, files.id))
|
||||
.where(fullWhere),
|
||||
]);
|
||||
const videoList = rows.map((r: any) => r.v || r);
|
||||
const items = await Promise.all(videoList.map((v: any) => enrichVideo(ctx.db, v)));
|
||||
const videoList = rows.map((r) => r.v);
|
||||
const items = await Promise.all(videoList.map((v) => enrichVideo(ctx.db, v)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
@@ -157,7 +170,7 @@ builder.queryField("videos", (t) =>
|
||||
ctx.db.select().from(videos).where(where).orderBy(order).limit(pageSize).offset(offset),
|
||||
ctx.db.select({ total: count() }).from(videos).where(where),
|
||||
]);
|
||||
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
||||
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
@@ -421,7 +434,7 @@ builder.queryField("analytics", (t) =>
|
||||
};
|
||||
}
|
||||
|
||||
const videoIds = modelVideoIds.map((v: any) => v.video_id);
|
||||
const videoIds = modelVideoIds.map((v) => v.video_id);
|
||||
const videoList = await ctx.db.select().from(videos).where(inArray(videos.id, videoIds));
|
||||
const plays = await ctx.db
|
||||
.select()
|
||||
@@ -435,14 +448,14 @@ builder.queryField("analytics", (t) =>
|
||||
const totalLikes = videoList.reduce((sum, v) => sum + (v.likes_count || 0), 0);
|
||||
const totalPlays = videoList.reduce((sum, v) => sum + (v.plays_count || 0), 0);
|
||||
|
||||
const playsByDate = plays.reduce((acc: any, play) => {
|
||||
const playsByDate = plays.reduce((acc: Record<string, number>, play) => {
|
||||
const date = new Date(play.date_created).toISOString().split("T")[0];
|
||||
if (!acc[date]) acc[date] = 0;
|
||||
acc[date]++;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const likesByDate = likes.reduce((acc: any, like) => {
|
||||
const likesByDate = likes.reduce((acc: Record<string, number>, like) => {
|
||||
const date = new Date(like.date_created).toISOString().split("T")[0];
|
||||
if (!acc[date]) acc[date] = 0;
|
||||
acc[date]++;
|
||||
@@ -499,7 +512,7 @@ builder.queryField("adminListVideos", (t) =>
|
||||
const limit = args.limit ?? 50;
|
||||
const offset = args.offset ?? 0;
|
||||
|
||||
const conditions: any[] = [];
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if (args.search) conditions.push(ilike(videos.title, `%${args.search}%`));
|
||||
if (args.premium !== null && args.premium !== undefined)
|
||||
conditions.push(eq(videos.premium, args.premium));
|
||||
@@ -517,7 +530,7 @@ builder.queryField("adminListVideos", (t) =>
|
||||
.offset(offset),
|
||||
ctx.db.select({ total: count() }).from(videos).where(where),
|
||||
]);
|
||||
const items = await Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
||||
const items = await Promise.all(rows.map((v) => enrichVideo(ctx.db, v)));
|
||||
return { items, total: totalRows[0]?.total ?? 0 };
|
||||
},
|
||||
}),
|
||||
@@ -590,7 +603,7 @@ builder.mutationField("updateVideo", (t) =>
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(videos)
|
||||
.set(updates as any)
|
||||
.set(updates as Partial<typeof videos.$inferInsert>)
|
||||
.where(eq(videos.id, args.id))
|
||||
.returning();
|
||||
if (!updated[0]) return null;
|
||||
|
||||
@@ -55,6 +55,7 @@ export const UserType = builder.objectRef<User>("User").implement({
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
photo: t.exposeString("photo", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
}),
|
||||
@@ -75,6 +76,7 @@ export const CurrentUserType = builder.objectRef<User>("CurrentUser").implement(
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
photo: t.exposeString("photo", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
}),
|
||||
@@ -86,6 +88,7 @@ export const VideoModelType = builder.objectRef<VideoModel>("VideoModel").implem
|
||||
artist_name: t.exposeString("artist_name", { nullable: true }),
|
||||
slug: t.exposeString("slug", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -132,6 +135,7 @@ export const ModelType = builder.objectRef<Model>("Model").implement({
|
||||
description: t.exposeString("description", { nullable: true }),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
photo: t.exposeString("photo", { nullable: true }),
|
||||
tags: t.exposeStringList("tags", { nullable: true }),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
photos: t.expose("photos", { type: [ModelPhotoType], nullable: true }),
|
||||
@@ -329,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({
|
||||
@@ -415,6 +487,7 @@ export const AdminUserDetailType = builder.objectRef<AdminUserDetail>("AdminUser
|
||||
is_admin: t.exposeBoolean("is_admin"),
|
||||
avatar: t.exposeString("avatar", { nullable: true }),
|
||||
banner: t.exposeString("banner", { nullable: true }),
|
||||
photo: t.exposeString("photo", { nullable: true }),
|
||||
email_verified: t.exposeBoolean("email_verified"),
|
||||
date_created: t.expose("date_created", { type: "DateTime" }),
|
||||
photos: t.expose("photos", { type: [ModelPhotoType] }),
|
||||
|
||||
@@ -7,19 +7,34 @@ import { createYoga } from "graphql-yoga";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { files } from "./db/schema/index";
|
||||
import path from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { writeFile, rm } from "fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { schema } from "./graphql/index";
|
||||
import { buildContext } from "./graphql/context";
|
||||
import { db } from "./db/connection";
|
||||
import { redis } from "./lib/auth";
|
||||
import { logger } from "./lib/logger";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { startMailWorker } from "./queues/workers/mail";
|
||||
import { startGamificationWorker } from "./queues/workers/gamification";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "4000");
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/data/uploads";
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:3000";
|
||||
|
||||
async function main() {
|
||||
// Run pending DB migrations before starting the server
|
||||
const migrationsFolder = path.join(__dirname, "migrations");
|
||||
logger.info(`Running migrations from ${migrationsFolder}`);
|
||||
await migrate(db, { migrationsFolder });
|
||||
logger.info("Migrations complete");
|
||||
|
||||
// Start background workers
|
||||
startMailWorker();
|
||||
startGamificationWorker();
|
||||
logger.info("Queue workers started");
|
||||
|
||||
const fastify = Fastify({ loggerInstance: logger });
|
||||
|
||||
await fastify.register(fastifyCookie, {
|
||||
@@ -120,6 +135,54 @@ async function main() {
|
||||
return reply.sendFile(path.join(id, filename));
|
||||
});
|
||||
|
||||
// Upload a file: POST /upload (multipart, requires session)
|
||||
fastify.post("/upload", async (request, reply) => {
|
||||
const token = request.cookies["session_token"];
|
||||
if (!token) return reply.status(401).send({ error: "Unauthorized" });
|
||||
|
||||
const sessionData = await redis.get(`session:${token}`);
|
||||
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
|
||||
const { id: userId } = JSON.parse(sessionData);
|
||||
|
||||
const data = await request.file();
|
||||
if (!data) return reply.status(400).send({ error: "No file provided" });
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const filename = data.filename;
|
||||
const mime_type = data.mimetype;
|
||||
const dir = path.join(UPLOAD_DIR, id);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const buffer = await data.toBuffer();
|
||||
await writeFile(path.join(dir, filename), buffer);
|
||||
|
||||
const [file] = await db
|
||||
.insert(files)
|
||||
.values({ id, filename, mime_type, filesize: buffer.byteLength, uploaded_by: userId })
|
||||
.returning();
|
||||
|
||||
return reply.status(201).send(file);
|
||||
});
|
||||
|
||||
// Delete a file: DELETE /assets/:id (requires session)
|
||||
fastify.delete("/assets/:id", async (request, reply) => {
|
||||
const token = request.cookies["session_token"];
|
||||
if (!token) return reply.status(401).send({ error: "Unauthorized" });
|
||||
|
||||
const sessionData = await redis.get(`session:${token}`);
|
||||
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
|
||||
|
||||
const { id } = request.params as { id: string };
|
||||
const result = await db.select().from(files).where(eq(files.id, id)).limit(1);
|
||||
if (!result[0]) return reply.status(404).send({ error: "File not found" });
|
||||
|
||||
await db.delete(files).where(eq(files.id, id));
|
||||
const dir = path.join(UPLOAD_DIR, id);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
||||
return reply.status(200).send({ ok: true });
|
||||
});
|
||||
|
||||
fastify.get("/health", async (_request, reply) => {
|
||||
return reply.send({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { mailQueue } from "../queues/index.js";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || "localhost",
|
||||
@@ -32,3 +33,13 @@ export async function sendPasswordReset(email: string, token: string): Promise<v
|
||||
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
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,26 +28,62 @@ export async function awardPoints(
|
||||
recordingId?: string,
|
||||
): Promise<void> {
|
||||
const points = POINT_VALUES[action];
|
||||
await db.insert(user_points).values({
|
||||
user_id: userId,
|
||||
action,
|
||||
points,
|
||||
recording_id: recordingId || null,
|
||||
date_created: new Date(),
|
||||
});
|
||||
await db
|
||||
.insert(user_points)
|
||||
.values({
|
||||
user_id: userId,
|
||||
action,
|
||||
points,
|
||||
recording_id: recordingId || null,
|
||||
date_created: new Date(),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await updateUserStats(db, userId);
|
||||
}
|
||||
|
||||
export async function revokePoints(
|
||||
db: DB,
|
||||
userId: string,
|
||||
action: keyof typeof POINT_VALUES,
|
||||
recordingId?: string,
|
||||
): Promise<void> {
|
||||
const recordingCondition = recordingId
|
||||
? eq(user_points.recording_id, recordingId)
|
||||
: isNull(user_points.recording_id);
|
||||
|
||||
// When no recordingId (e.g. COMMENT_CREATE), delete only one row so each
|
||||
// revoke undoes exactly one prior award.
|
||||
if (!recordingId) {
|
||||
const row = await db
|
||||
.select({ id: user_points.id })
|
||||
.from(user_points)
|
||||
.where(
|
||||
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
|
||||
)
|
||||
.limit(1);
|
||||
if (row[0]) {
|
||||
await db.delete(user_points).where(eq(user_points.id, row[0].id));
|
||||
}
|
||||
} else {
|
||||
await db
|
||||
.delete(user_points)
|
||||
.where(
|
||||
and(eq(user_points.user_id, userId), eq(user_points.action, action), recordingCondition),
|
||||
);
|
||||
}
|
||||
|
||||
await updateUserStats(db, userId);
|
||||
}
|
||||
|
||||
export async function calculateWeightedScore(db: DB, userId: string): Promise<number> {
|
||||
const now = new Date();
|
||||
const result = await db.execute(sql`
|
||||
SELECT SUM(
|
||||
points * EXP(-${DECAY_LAMBDA} * EXTRACT(EPOCH FROM (${now}::timestamptz - date_created)) / 86400)
|
||||
points * EXP(${sql.raw(String(-DECAY_LAMBDA))} * EXTRACT(EPOCH FROM (NOW() - date_created)) / 86400)
|
||||
) as weighted_score
|
||||
FROM user_points
|
||||
WHERE user_id = ${userId}
|
||||
`);
|
||||
return parseFloat((result.rows[0] as any)?.weighted_score || "0");
|
||||
return parseFloat((result.rows[0] as { weighted_score?: string })?.weighted_score || "0");
|
||||
}
|
||||
|
||||
export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||
@@ -84,7 +120,7 @@ export async function updateUserStats(db: DB, userId: string): Promise<void> {
|
||||
sql`, `,
|
||||
)})
|
||||
`);
|
||||
playbacksCount = parseInt((playbacksResult.rows[0] as any)?.count || "0");
|
||||
playbacksCount = parseInt((playbacksResult.rows[0] as { count?: string })?.count || "0");
|
||||
} else {
|
||||
const playbacksResult = await db
|
||||
.select({ count: count() })
|
||||
@@ -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(
|
||||
@@ -242,7 +280,7 @@ async function getAchievementProgress(
|
||||
WHERE rp.user_id = ${userId}
|
||||
AND r.user_id != ${userId}
|
||||
`);
|
||||
return parseInt((result.rows[0] as any)?.count || "0");
|
||||
return parseInt((result.rows[0] as { count?: string })?.count || "0");
|
||||
}
|
||||
|
||||
if (["completionist_10", "completionist_100"].includes(code)) {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -293,7 +331,7 @@ async function getAchievementProgress(
|
||||
WHERE rp.user_id = ${userId} AND r.user_id != ${userId}
|
||||
`);
|
||||
const rc = recordingsResult[0]?.count || 0;
|
||||
const pc = parseInt((playsResult.rows[0] as any)?.count || "0");
|
||||
const pc = parseInt((playsResult.rows[0] as { count?: string })?.count || "0");
|
||||
return rc >= 50 && pc >= 100 ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
1
packages/backend/src/migrations/0003_model_photo.sql
Normal file
1
packages/backend/src/migrations/0003_model_photo.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "photo" text REFERENCES "files"("id") ON DELETE set null;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Partial unique index: prevents duplicate RECORDING_CREATE / RECORDING_FEATURED points
|
||||
-- for the same recording. RECORDING_PLAY / RECORDING_COMPLETE are excluded so a user
|
||||
-- can earn play points across multiple sessions.
|
||||
CREATE UNIQUE INDEX "user_points_unique_action_recording"
|
||||
ON "user_points" ("user_id", "action", "recording_id")
|
||||
WHERE "action" IN ('RECORDING_CREATE', 'RECORDING_FEATURED') AND "recording_id" IS NOT NULL;
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1741337600000,
|
||||
"tag": "0002_remove_archived_recording_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1741420000000,
|
||||
"tag": "0003_model_photo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
16
packages/backend/src/queues/connection.ts
Normal file
16
packages/backend/src/queues/connection.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
function parseRedisUrl(url: string): { host: string; port: number; password?: string } {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port) || 6379,
|
||||
password: parsed.password || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// BullMQ creates its own IORedis connections from these options.
|
||||
// maxRetriesPerRequest: null is required for workers.
|
||||
export const redisConnectionOpts = {
|
||||
...parseRedisUrl(process.env.REDIS_URL || "redis://localhost:6379"),
|
||||
maxRetriesPerRequest: null as null,
|
||||
enableReadyCheck: false,
|
||||
};
|
||||
25
packages/backend/src/queues/index.ts
Normal file
25
packages/backend/src/queues/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { redisConnectionOpts } from "./connection.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
|
||||
const log = logger.child({ component: "queues" });
|
||||
|
||||
export const mailQueue = new Queue("mail", { connection: redisConnectionOpts });
|
||||
mailQueue.on("error", (err) => {
|
||||
log.error({ queue: "mail", err: err.message }, "Queue error");
|
||||
});
|
||||
|
||||
export const gamificationQueue = new Queue("gamification", {
|
||||
connection: redisConnectionOpts,
|
||||
defaultJobOptions: { attempts: 3, backoff: { type: "exponential", delay: 2000 } },
|
||||
});
|
||||
gamificationQueue.on("error", (err) => {
|
||||
log.error({ queue: "gamification", err: err.message }, "Queue error");
|
||||
});
|
||||
|
||||
log.info("Queues initialized");
|
||||
|
||||
export const queues: Record<string, Queue> = {
|
||||
mail: mailQueue,
|
||||
gamification: gamificationQueue,
|
||||
};
|
||||
52
packages/backend/src/queues/workers/gamification.ts
Normal file
52
packages/backend/src/queues/workers/gamification.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Worker } from "bullmq";
|
||||
import { redisConnectionOpts } from "../connection.js";
|
||||
import { awardPoints, revokePoints, checkAchievements } from "../../lib/gamification.js";
|
||||
import { db } from "../../db/connection.js";
|
||||
import { logger } from "../../lib/logger.js";
|
||||
import type { POINT_VALUES } from "../../lib/gamification.js";
|
||||
|
||||
const log = logger.child({ component: "gamification-worker" });
|
||||
|
||||
export type GamificationJobData =
|
||||
| { job: "awardPoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
|
||||
| { job: "revokePoints"; userId: string; action: keyof typeof POINT_VALUES; recordingId?: string }
|
||||
| { job: "checkAchievements"; userId: string; category?: string };
|
||||
|
||||
export function startGamificationWorker(): Worker {
|
||||
const worker = new Worker(
|
||||
"gamification",
|
||||
async (bullJob) => {
|
||||
const data = bullJob.data as GamificationJobData;
|
||||
log.info(
|
||||
{ jobId: bullJob.id, job: data.job, userId: data.userId },
|
||||
"Processing gamification job",
|
||||
);
|
||||
|
||||
switch (data.job) {
|
||||
case "awardPoints":
|
||||
await awardPoints(db, data.userId, data.action, data.recordingId);
|
||||
break;
|
||||
case "revokePoints":
|
||||
await revokePoints(db, data.userId, data.action, data.recordingId);
|
||||
break;
|
||||
case "checkAchievements":
|
||||
await checkAchievements(db, data.userId, data.category);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown gamification job: ${(data as GamificationJobData).job}`);
|
||||
}
|
||||
|
||||
log.info({ jobId: bullJob.id, job: data.job }, "Gamification job completed");
|
||||
},
|
||||
{ connection: redisConnectionOpts },
|
||||
);
|
||||
|
||||
worker.on("failed", (bullJob, err) => {
|
||||
log.error(
|
||||
{ jobId: bullJob?.id, job: (bullJob?.data as GamificationJobData)?.job, err: err.message },
|
||||
"Gamification job failed",
|
||||
);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
33
packages/backend/src/queues/workers/mail.ts
Normal file
33
packages/backend/src/queues/workers/mail.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Worker } from "bullmq";
|
||||
import { redisConnectionOpts } from "../connection.js";
|
||||
import { sendVerification, sendPasswordReset } from "../../lib/email.js";
|
||||
import { logger } from "../../lib/logger.js";
|
||||
|
||||
const log = logger.child({ component: "mail-worker" });
|
||||
|
||||
export function startMailWorker(): Worker {
|
||||
const worker = new Worker(
|
||||
"mail",
|
||||
async (job) => {
|
||||
log.info({ jobId: job.id, jobName: job.name }, `Processing mail job`);
|
||||
switch (job.name) {
|
||||
case "sendVerification":
|
||||
await sendVerification(job.data.email as string, job.data.token as string);
|
||||
break;
|
||||
case "sendPasswordReset":
|
||||
await sendPasswordReset(job.data.email as string, job.data.token as string);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown mail job: ${job.name}`);
|
||||
}
|
||||
log.info({ jobId: job.id, jobName: job.name }, `Mail job completed`);
|
||||
},
|
||||
{ connection: redisConnectionOpts },
|
||||
);
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
log.error({ jobId: job?.id, jobName: job?.name, err: err.message }, `Mail job failed`);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
5
packages/buttplug/.gitignore
vendored
Normal file
5
packages/buttplug/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
wasm/
|
||||
target/
|
||||
pkg/
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@sexy.pivoine.art/buttplug",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -10,7 +11,8 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
|
||||
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target web --release",
|
||||
"serve": "node serve.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.4",
|
||||
|
||||
39
packages/buttplug/serve.mjs
Normal file
39
packages/buttplug/serve.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
// Simple static server for local development — serves dist/ and wasm/ on port 8080
|
||||
import http from "http";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT ?? 8080;
|
||||
|
||||
const MIME = {
|
||||
".js": "application/javascript",
|
||||
".wasm": "application/wasm",
|
||||
".ts": "text/plain",
|
||||
".d.ts": "text/plain",
|
||||
};
|
||||
|
||||
http
|
||||
.createServer((req, res) => {
|
||||
const filePath = path.join(__dirname, decodeURIComponent(req.url.split("?")[0]));
|
||||
const ext = path.extname(filePath);
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Cross-Origin-Resource-Policy": "cross-origin",
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
})
|
||||
.listen(PORT, () => {
|
||||
console.log(`[buttplug] serving on http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
import { IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||
import { ButtplugMessage } from "../core/Messages";
|
||||
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||
import { type ButtplugMessage } from "../core/Messages";
|
||||
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
|
||||
|
||||
export class ButtplugBrowserWebsocketClientConnector
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { ButtplugLogger } from "../core/Logging";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { ButtplugClientDevice } from "./ButtplugClientDevice";
|
||||
import { IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||
import { type IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||
import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
|
||||
import * as Messages from "../core/Messages";
|
||||
import { ButtplugError, ButtplugInitError, ButtplugMessageError } from "../core/Exceptions";
|
||||
@@ -158,7 +158,7 @@ export class ButtplugClient extends EventEmitter {
|
||||
};
|
||||
|
||||
private parseDeviceList = (list: Messages.DeviceList) => {
|
||||
for (let [_, d] of Object.entries(list.Devices)) {
|
||||
for (const [_, d] of Object.entries(list.Devices)) {
|
||||
if (!this._devices.has(d.DeviceIndex)) {
|
||||
const device = ButtplugClientDevice.fromMsg(d, this.sendMessageClosure);
|
||||
this._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
|
||||
@@ -168,8 +168,8 @@ export class ButtplugClient extends EventEmitter {
|
||||
this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
|
||||
}
|
||||
}
|
||||
for (let [index, device] of this._devices.entries()) {
|
||||
if (!list.Devices.hasOwnProperty(index.toString())) {
|
||||
for (const [index, device] of this._devices.entries()) {
|
||||
if (!Object.prototype.hasOwnProperty.call(list.Devices, index.toString())) {
|
||||
this._devices.delete(index);
|
||||
this.emit("deviceremoved", device);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as Messages from "../core/Messages";
|
||||
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { ButtplugClientDeviceFeature } from "./ButtplugClientDeviceFeature";
|
||||
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||
|
||||
/**
|
||||
* Represents an abstract device, capable of taking certain kinds of messages.
|
||||
@@ -105,14 +105,22 @@ export class ButtplugClientDevice extends EventEmitter {
|
||||
};
|
||||
|
||||
protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
|
||||
if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
this._deviceInfo.DeviceFeatures,
|
||||
featureIndex.toString(),
|
||||
)
|
||||
) {
|
||||
throw new ButtplugDeviceError(
|
||||
`Feature index ${featureIndex} does not exist for device ${this.name}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined &&
|
||||
!this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs.hasOwnProperty(type)
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs,
|
||||
type,
|
||||
)
|
||||
) {
|
||||
throw new ButtplugDeviceError(
|
||||
`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`,
|
||||
@@ -139,8 +147,8 @@ export class ButtplugClientDevice extends EventEmitter {
|
||||
}
|
||||
|
||||
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
|
||||
let p: Promise<void>[] = [];
|
||||
for (let f of this._features.values()) {
|
||||
const p: Promise<void>[] = [];
|
||||
for (const f of this._features.values()) {
|
||||
if (f.hasOutput(cmd.outputType)) {
|
||||
p.push(f.runOutput(cmd));
|
||||
}
|
||||
@@ -164,11 +172,14 @@ export class ButtplugClientDevice extends EventEmitter {
|
||||
}
|
||||
|
||||
public async battery(): Promise<number> {
|
||||
let p: Promise<void>[] = [];
|
||||
for (let f of this._features.values()) {
|
||||
const _p: Promise<void>[] = [];
|
||||
for (const f of this._features.values()) {
|
||||
if (f.hasInput(Messages.InputType.Battery)) {
|
||||
// Right now, we only have one battery per device, so assume the first one we find is it.
|
||||
let response = await f.runInput(Messages.InputType.Battery, Messages.InputCommandType.Read);
|
||||
const response = await f.runInput(
|
||||
Messages.InputType.Battery,
|
||||
Messages.InputCommandType.Read,
|
||||
);
|
||||
if (response === undefined) {
|
||||
throw new ButtplugMessageError("Got incorrect message back.");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class PercentOrSteps {
|
||||
}
|
||||
|
||||
public static createSteps(s: number): PercentOrSteps {
|
||||
let v = new PercentOrSteps();
|
||||
const v = new PercentOrSteps();
|
||||
v._steps = s;
|
||||
return v;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class PercentOrSteps {
|
||||
throw new ButtplugDeviceError(`Percent value ${p} is not in the range 0.0 <= x <= 1.0`);
|
||||
}
|
||||
|
||||
let v = new PercentOrSteps();
|
||||
const v = new PercentOrSteps();
|
||||
v._percent = p;
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
|
||||
import * as Messages from "../core/Messages";
|
||||
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||
import { type DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
|
||||
|
||||
export class ButtplugClientDeviceFeature {
|
||||
constructor(
|
||||
@@ -26,7 +26,10 @@ export class ButtplugClientDeviceFeature {
|
||||
};
|
||||
|
||||
protected isOutputValid(type: Messages.OutputType) {
|
||||
if (this._feature.Output !== undefined && !this._feature.Output.hasOwnProperty(type)) {
|
||||
if (
|
||||
this._feature.Output !== undefined &&
|
||||
!Object.prototype.hasOwnProperty.call(this._feature.Output, type)
|
||||
) {
|
||||
throw new ButtplugDeviceError(
|
||||
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
|
||||
);
|
||||
@@ -34,7 +37,10 @@ export class ButtplugClientDeviceFeature {
|
||||
}
|
||||
|
||||
protected isInputValid(type: Messages.InputType) {
|
||||
if (this._feature.Input !== undefined && !this._feature.Input.hasOwnProperty(type)) {
|
||||
if (
|
||||
this._feature.Input !== undefined &&
|
||||
!Object.prototype.hasOwnProperty.call(this._feature.Input, type)
|
||||
) {
|
||||
throw new ButtplugDeviceError(
|
||||
`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`,
|
||||
);
|
||||
@@ -48,7 +54,7 @@ export class ButtplugClientDeviceFeature {
|
||||
throw new ButtplugDeviceError(`${command.outputType} requires value defined`);
|
||||
}
|
||||
|
||||
let type = command.outputType;
|
||||
const type = command.outputType;
|
||||
let duration: undefined | number = undefined;
|
||||
if (type == Messages.OutputType.HwPositionWithDuration) {
|
||||
if (command.duration === undefined) {
|
||||
@@ -57,18 +63,18 @@ export class ButtplugClientDeviceFeature {
|
||||
duration = command.duration;
|
||||
}
|
||||
let value: number;
|
||||
let p = command.value;
|
||||
const p = command.value;
|
||||
if (p.percent === undefined) {
|
||||
// TODO Check step limits here
|
||||
value = command.value.steps!;
|
||||
} else {
|
||||
value = Math.ceil(this._feature.Output[type]!.Value![1] * p.percent);
|
||||
}
|
||||
let newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
|
||||
let outCommand = {};
|
||||
const newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
|
||||
const outCommand = {};
|
||||
outCommand[type.toString()] = newCommand;
|
||||
|
||||
let cmd: Messages.ButtplugMessage = {
|
||||
const cmd: Messages.ButtplugMessage = {
|
||||
OutputCmd: {
|
||||
Id: 1,
|
||||
DeviceIndex: this._deviceIndex,
|
||||
@@ -111,14 +117,14 @@ export class ButtplugClientDeviceFeature {
|
||||
|
||||
public hasOutput(type: Messages.OutputType): boolean {
|
||||
if (this._feature.Output !== undefined) {
|
||||
return this._feature.Output.hasOwnProperty(type.toString());
|
||||
return Object.prototype.hasOwnProperty.call(this._feature.Output, type.toString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public hasInput(type: Messages.InputType): boolean {
|
||||
if (this._feature.Input !== undefined) {
|
||||
return this._feature.Input.hasOwnProperty(type.toString());
|
||||
return Object.prototype.hasOwnProperty.call(this._feature.Input, type.toString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -126,7 +132,7 @@ export class ButtplugClientDeviceFeature {
|
||||
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
|
||||
if (
|
||||
this._feature.Output !== undefined &&
|
||||
this._feature.Output.hasOwnProperty(cmd.outputType.toString())
|
||||
Object.prototype.hasOwnProperty.call(this._feature.Output, cmd.outputType.toString())
|
||||
) {
|
||||
return this.sendOutputCmd(cmd);
|
||||
}
|
||||
@@ -139,7 +145,7 @@ export class ButtplugClientDeviceFeature {
|
||||
): Promise<Messages.InputReading | undefined> {
|
||||
// Make sure the requested feature is valid
|
||||
this.isInputValid(inputType);
|
||||
let inputAttributes = this._feature.Input[inputType];
|
||||
const inputAttributes = this._feature.Input[inputType];
|
||||
console.log(this._feature.Input);
|
||||
if (
|
||||
inputCommand === Messages.InputCommandType.Unsubscribe &&
|
||||
@@ -149,7 +155,7 @@ export class ButtplugClientDeviceFeature {
|
||||
throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`);
|
||||
}
|
||||
|
||||
let cmd: Messages.ButtplugMessage = {
|
||||
const cmd: Messages.ButtplugMessage = {
|
||||
InputCmd: {
|
||||
Id: 1,
|
||||
DeviceIndex: this._deviceIndex,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
import { ButtplugMessage } from "../core/Messages";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { type ButtplugMessage } from "../core/Messages";
|
||||
import { type EventEmitter } from "eventemitter3";
|
||||
|
||||
export interface IButtplugClientConnector extends EventEmitter {
|
||||
connect: () => Promise<void>;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import * as Messages from "./Messages";
|
||||
import { ButtplugLogger } from "./Logging";
|
||||
import { type ButtplugLogger } from "./Logging";
|
||||
|
||||
export class ButtplugError extends Error {
|
||||
public get ErrorClass(): Messages.ErrorClass {
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface ButtplugMessage {
|
||||
}
|
||||
|
||||
export function msgId(msg: ButtplugMessage): number {
|
||||
for (let [_, entry] of Object.entries(msg)) {
|
||||
for (const [_, entry] of Object.entries(msg)) {
|
||||
if (entry != undefined) {
|
||||
return entry.Id;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function msgId(msg: ButtplugMessage): number {
|
||||
}
|
||||
|
||||
export function setMsgId(msg: ButtplugMessage, id: number) {
|
||||
for (let [_, entry] of Object.entries(msg)) {
|
||||
for (const [_, entry] of Object.entries(msg)) {
|
||||
if (entry != undefined) {
|
||||
entry.Id = id;
|
||||
return;
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||
*/
|
||||
|
||||
import { ButtplugMessage } from "./core/Messages";
|
||||
import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
|
||||
import { type ButtplugMessage } from "./core/Messages";
|
||||
import { type IButtplugClientConnector } from "./client/IButtplugClientConnector";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
|
||||
export * from "./client/ButtplugClient";
|
||||
@@ -40,7 +40,9 @@ export class ButtplugWasmClientConnector extends EventEmitter implements IButtpl
|
||||
|
||||
private static maybeLoadWasm = async () => {
|
||||
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
|
||||
ButtplugWasmClientConnector.wasmInstance = await import("../wasm/index.js");
|
||||
const wasmModule = await import("../wasm/index.js");
|
||||
await wasmModule.default(); // --target web requires calling init() before using exports
|
||||
ButtplugWasmClientConnector.wasmInstance = wasmModule;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use strict";
|
||||
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { ButtplugMessage } from "../core/Messages";
|
||||
import { type ButtplugMessage } from "../core/Messages";
|
||||
|
||||
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||
protected _ws: WebSocket | undefined;
|
||||
|
||||
@@ -40,7 +40,7 @@ export class ButtplugMessageSorter {
|
||||
public ParseIncomingMessages(msgs: Messages.ButtplugMessage[]): Messages.ButtplugMessage[] {
|
||||
const noMatch: Messages.ButtplugMessage[] = [];
|
||||
for (const x of msgs) {
|
||||
let id = Messages.msgId(x);
|
||||
const id = Messages.msgId(x);
|
||||
if (id !== Messages.SYSTEM_MESSAGE_ID && this._waitingMsgs.has(id)) {
|
||||
const [res, rej] = this._waitingMsgs.get(id)!;
|
||||
this._waitingMsgs.delete(id);
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/frontend",
|
||||
"version": "1.0.0",
|
||||
"author": "valknarogg",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -12,9 +11,10 @@
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json --threshold warning"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||
"@iconify-json/ri": "^1.2.10",
|
||||
"@iconify/tailwind4": "^1.2.1",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
@@ -29,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",
|
||||
@@ -38,11 +37,9 @@
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-wasm": "3.5.0"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||
"@sexy.pivoine.art/types": "workspace:*",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-request": "^7.1.2",
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
|
||||
@plugin "@iconify/tailwind4";
|
||||
|
||||
@utility scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@custom-variant hover (&:hover);
|
||||
@@ -194,7 +201,7 @@
|
||||
--card-foreground: oklch(0.95 0.01 280);
|
||||
--border: oklch(0.2 0.05 280);
|
||||
--input: oklch(1 0 0 / 0.15);
|
||||
--primary: oklch(0.65 0.25 320);
|
||||
--primary: oklch(65.054% 0.25033 319.934);
|
||||
--primary-foreground: oklch(0.98 0.01 320);
|
||||
--secondary: oklch(0.15 0.05 260);
|
||||
--secondary-foreground: oklch(0.9 0.02 260);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
|
||||
@@ -10,23 +10,23 @@
|
||||
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import { logout } from "$lib/services";
|
||||
import { goto } from "$app/navigation";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import LogoutButton from "../logout-button/logout-button.svelte";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||
import { getUserInitials } from "$lib/utils";
|
||||
import Separator from "../ui/separator/separator.svelte";
|
||||
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||
import Logo from "../logo/logo.svelte";
|
||||
@@ -37,7 +38,7 @@
|
||||
isMobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function isActiveLink(link: any) {
|
||||
function isActiveLink(link: { name?: string; href: string }) {
|
||||
return (
|
||||
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||
@@ -46,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">
|
||||
@@ -55,7 +56,7 @@
|
||||
href="/"
|
||||
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
||||
>
|
||||
<Logo hideName={true} />
|
||||
<Logo />
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
@@ -75,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")}
|
||||
>
|
||||
@@ -111,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"
|
||||
>
|
||||
@@ -123,44 +110,70 @@
|
||||
</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" />
|
||||
|
||||
<LogoutButton
|
||||
user={{
|
||||
name:
|
||||
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
||||
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
|
||||
email: authStatus.user!.email,
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
<a href="/me" class="flex items-center gap-2 px-1 hover:opacity-80 transition-opacity">
|
||||
<Avatar class="h-7 w-7 ring-2 ring-primary/20">
|
||||
<AvatarImage
|
||||
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
||||
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold"
|
||||
>
|
||||
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
class="hidden lg:inline text-sm font-medium text-foreground/90 max-w-24 truncate"
|
||||
>
|
||||
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
class="hidden lg:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group hover:text-destructive"
|
||||
onclick={handleLogout}
|
||||
title={$_("header.logout")}
|
||||
>
|
||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="lg:hidden ml-2">
|
||||
<BurgerMenuButton
|
||||
label={$_("header.navigation")}
|
||||
bind:isMobileMenuOpen
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="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 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}
|
||||
|
||||
<!-- Burger button — mobile/tablet only -->
|
||||
<div class="lg:hidden ml-auto">
|
||||
<BurgerMenuButton
|
||||
label={$_("header.navigation")}
|
||||
bind:isMobileMenuOpen
|
||||
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
role="presentation"
|
||||
class={`fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 lg:hidden ${isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
@@ -174,21 +187,40 @@
|
||||
>
|
||||
<!-- Panel header -->
|
||||
<div class="flex items-center px-5 h-16 shrink-0 border-b border-border/30">
|
||||
<Logo hideName={true} />
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 py-6 px-5 space-y-6">
|
||||
<!-- User logout slider -->
|
||||
<!-- User card -->
|
||||
{#if authStatus.authenticated}
|
||||
<LogoutButton
|
||||
user={{
|
||||
name: authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
|
||||
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
|
||||
email: authStatus.user!.email,
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex items-center gap-3 rounded-xl border border-border/40 bg-card/50 px-4 py-3">
|
||||
<Avatar class="h-10 w-10 ring-2 ring-primary/20 shrink-0">
|
||||
<AvatarImage
|
||||
src={getAssetUrl(authStatus.user!.avatar, "mini")!}
|
||||
alt={authStatus.user!.artist_name || authStatus.user!.email}
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-sm font-semibold"
|
||||
>
|
||||
{getUserInitials(authStatus.user!.artist_name || authStatus.user!.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-sm font-semibold text-foreground truncate">
|
||||
{authStatus.user!.artist_name || authStatus.user!.email.split("@")[0]}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground truncate">{authStatus.user!.email}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
|
||||
onclick={handleLogout}
|
||||
title={$_("header.logout")}
|
||||
>
|
||||
<span class="icon-[ri--logout-circle-r-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -145,7 +145,12 @@
|
||||
{#if isViewerOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/95 backdrop-blur-xl" onclick={closeViewer}></div>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/95 backdrop-blur-xl cursor-default"
|
||||
onclick={closeViewer}
|
||||
aria-label="Close viewer"
|
||||
></button>
|
||||
|
||||
<!-- Viewer Content -->
|
||||
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import SexyIcon from "../icon/icon.svelte";
|
||||
|
||||
const { hideName = false } = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<SexyIcon class="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<span
|
||||
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
|
||||
>
|
||||
{$_("brand.name")}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
font-family: "Dancing Script", cursive;
|
||||
}
|
||||
</style>
|
||||
<SexyIcon class="w-12 h-12" />
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { title, description, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="relative py-12 md:py-20 overflow-hidden">
|
||||
<div class="relative container mx-auto px-4 text-center">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{#if description}
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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 @@ default:
|
||||
<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 @@ default:
|
||||
{$_("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,
|
||||
};
|
||||
@@ -23,11 +23,13 @@
|
||||
...rest
|
||||
}: FileDropZoneProps = $props();
|
||||
|
||||
if (maxFiles !== undefined && fileCount === undefined) {
|
||||
console.warn(
|
||||
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
||||
);
|
||||
}
|
||||
$effect(() => {
|
||||
if (maxFiles !== undefined && fileCount === undefined) {
|
||||
console.warn(
|
||||
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let uploading = $state(false);
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export default {
|
||||
account: "Account",
|
||||
},
|
||||
brand: {
|
||||
name: "SexyArt",
|
||||
name: "Sexy",
|
||||
tagline: "Where Love Meets Artistry",
|
||||
description:
|
||||
"The premier destination for artistic adult content, intimate storytelling, and creative expression through video and magazine content.",
|
||||
@@ -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",
|
||||
@@ -310,7 +333,7 @@ export default {
|
||||
back: "Back to Videos",
|
||||
},
|
||||
magazine: {
|
||||
title: "SexyArt Magazine",
|
||||
title: "Sexy Magazine",
|
||||
description:
|
||||
"Insights, stories, and inspiration from the world of love, art, and intimate expression",
|
||||
search_placeholder: "Search articles...",
|
||||
@@ -387,7 +410,7 @@ export default {
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "About SexyArt",
|
||||
title: "About Sexy",
|
||||
subtitle:
|
||||
"Where passion meets artistry, and intimate storytelling becomes a celebration of human connection.",
|
||||
join_community: "Join Our Community",
|
||||
@@ -403,11 +426,11 @@ export default {
|
||||
subtitle:
|
||||
"Born from a vision to transform how intimate content is created, shared, and appreciated",
|
||||
description_part1:
|
||||
"SexyArt was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
|
||||
"Sexy was founded in 2019 with a simple yet powerful mission: to create a platform where intimate content could be appreciated as an art form, where creators could express their authentic selves, and where viewers could connect with content that celebrates love, passion, and human connection.",
|
||||
description_part2:
|
||||
"We recognized that the adult content industry needed a platform that prioritized artistic expression, creator empowerment, and community building. Our founders, coming from backgrounds in photography, digital media, and community management, set out to build something different.",
|
||||
description_part3:
|
||||
"Today, SexyArt is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
|
||||
"Today, Sexy is home to hundreds of talented creators and thousands of passionate community members who share our vision of elevating intimate content to new artistic heights.",
|
||||
},
|
||||
values: {
|
||||
title: "Our Values",
|
||||
@@ -447,7 +470,7 @@ export default {
|
||||
image: "/img/valknar.gif",
|
||||
bio: "DJ and visual storyteller specializing in diffusion AI art.",
|
||||
},
|
||||
subtitle: "The passionate individuals behind SexyArt's success",
|
||||
subtitle: "The passionate individuals behind Sexy's success",
|
||||
},
|
||||
mission: {
|
||||
title: "Our Mission",
|
||||
@@ -474,7 +497,7 @@ export default {
|
||||
},
|
||||
faq: {
|
||||
title: "Frequently Asked Questions",
|
||||
description: "Find answers to common questions about SexyArt, our platform, and services",
|
||||
description: "Find answers to common questions about Sexy, our platform, and services",
|
||||
search_placeholder: "Search frequently asked questions...",
|
||||
search_results: "Search Results ({count})",
|
||||
no_results: "No questions found matching your search.",
|
||||
@@ -483,24 +506,24 @@ export default {
|
||||
title: "Getting Started",
|
||||
questions: [
|
||||
{
|
||||
question: "How do I create an account on SexyArt?",
|
||||
question: "How do I create an account on Sexy?",
|
||||
answer:
|
||||
"Creating an account is simple! Click the 'Join Now' button in the top navigation, fill out the registration form with your email and basic information, verify you're 18+, and agree to our terms. You'll receive a confirmation email to activate your account.",
|
||||
},
|
||||
{
|
||||
question: "What types of content can I find on SexyArt?",
|
||||
question: "What types of content can I find on Sexy?",
|
||||
answer:
|
||||
"SexyArt features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
|
||||
"Sexy features high-quality artistic adult content including intimate photography, romantic videos, artistic nude content, and creative adult entertainment. All content is created by verified models and creators who focus on artistic expression and storytelling.",
|
||||
},
|
||||
{
|
||||
question: "Is SexyArt safe and secure?",
|
||||
question: "Is Sexy safe and secure?",
|
||||
answer:
|
||||
"Yes! We use industry-standard encryption, secure payment processing, and strict privacy measures. All creators are verified, and we have comprehensive content moderation. Your personal information and viewing habits are kept completely private.",
|
||||
},
|
||||
{
|
||||
question: "Can I access SexyArt on mobile devices?",
|
||||
question: "Can I access Sexy on mobile devices?",
|
||||
answer:
|
||||
"Absolutely! SexyArt is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
|
||||
"Absolutely! Sexy is fully responsive and works perfectly on smartphones, tablets, and desktop computers. You can enjoy the same high-quality experience across all your devices.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -508,7 +531,7 @@ export default {
|
||||
title: "For Creators & Models",
|
||||
questions: [
|
||||
{
|
||||
question: "How do I become a creator on SexyArt?",
|
||||
question: "How do I become a creator on Sexy?",
|
||||
answer:
|
||||
"To become a creator, sign up for a Creator account during registration or upgrade your existing account. You'll need to verify your identity, provide tax information, and agree to our creator terms. Once approved, you can start uploading content and building your audience.",
|
||||
},
|
||||
@@ -597,7 +620,7 @@ export default {
|
||||
company_information: "Company Information",
|
||||
company_name: {
|
||||
title: "Company Name",
|
||||
value: "SexyArt",
|
||||
value: "Sexy",
|
||||
},
|
||||
legal_form: {
|
||||
title: "Legal Form",
|
||||
@@ -614,7 +637,7 @@ export default {
|
||||
contact_information: "Contact Information",
|
||||
registered_address: "Registered Address",
|
||||
address: {
|
||||
company: "SexyArt",
|
||||
company: "Sexy",
|
||||
name: "Sebastian Krüger",
|
||||
street: "Berlingerstraße 48",
|
||||
city: "78333 Stockach",
|
||||
@@ -688,7 +711,7 @@ export default {
|
||||
acceptance: {
|
||||
title: "1. Acceptance of Terms",
|
||||
text: [
|
||||
"By accessing and using SexyArt, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
|
||||
"By accessing and using Sexy, you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.",
|
||||
],
|
||||
},
|
||||
age: {
|
||||
@@ -732,7 +755,7 @@ export default {
|
||||
values: {
|
||||
title: "Our Community Values",
|
||||
text: [
|
||||
"SexyArt is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
|
||||
"Sexy is built on respect, consent, and artistic expression. We believe in creating a space where creators and viewers can connect through shared appreciation for intimate art and storytelling.",
|
||||
],
|
||||
},
|
||||
respect: {
|
||||
@@ -799,11 +822,19 @@ export default {
|
||||
questions_email: "support@pivoine.art",
|
||||
},
|
||||
play: {
|
||||
title: "SexyPlay",
|
||||
title: "Play",
|
||||
description: "Bring your 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",
|
||||
@@ -901,18 +932,19 @@ export default {
|
||||
},
|
||||
},
|
||||
head: {
|
||||
title: "SexyArt | {title}",
|
||||
title: "Sexy | {title}",
|
||||
},
|
||||
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",
|
||||
@@ -962,6 +994,11 @@ export default {
|
||||
artist_name: "Artist name",
|
||||
avatar: "Avatar",
|
||||
banner: "Banner",
|
||||
model_photo: "Model photo",
|
||||
model_photo_hint:
|
||||
"Used in model cards and on the model profile page. Avatar is used for comments and article authors.",
|
||||
model_photo_uploaded: "Model photo uploaded",
|
||||
model_photo_failed: "Model photo upload failed",
|
||||
is_admin: "Administrator",
|
||||
is_admin_hint: "Grants full admin access to the dashboard",
|
||||
photos: "Photo gallery",
|
||||
@@ -1053,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",
|
||||
|
||||
@@ -296,6 +296,7 @@ const ARTICLE_BY_SLUG_QUERY = gql`
|
||||
artist_name
|
||||
slug
|
||||
avatar
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,6 +491,7 @@ const MODELS_QUERY = gql`
|
||||
description
|
||||
avatar
|
||||
banner
|
||||
photo
|
||||
tags
|
||||
date_created
|
||||
photos {
|
||||
@@ -539,6 +541,7 @@ const MODEL_BY_SLUG_QUERY = gql`
|
||||
description
|
||||
avatar
|
||||
banner
|
||||
photo
|
||||
tags
|
||||
date_created
|
||||
photos {
|
||||
@@ -573,6 +576,7 @@ const UPDATE_PROFILE_MUTATION = gql`
|
||||
$artistName: String
|
||||
$description: String
|
||||
$tags: [String!]
|
||||
$avatar: String
|
||||
) {
|
||||
updateProfile(
|
||||
firstName: $firstName
|
||||
@@ -580,6 +584,7 @@ const UPDATE_PROFILE_MUTATION = gql`
|
||||
artistName: $artistName
|
||||
description: $description
|
||||
tags: $tags
|
||||
avatar: $avatar
|
||||
) {
|
||||
id
|
||||
email
|
||||
@@ -609,6 +614,7 @@ export async function updateProfile(user: Partial<User> & { password?: string })
|
||||
artistName: user.artist_name,
|
||||
description: user.description,
|
||||
tags: user.tags,
|
||||
avatar: user.avatar,
|
||||
},
|
||||
);
|
||||
return data.updateProfile;
|
||||
@@ -652,7 +658,8 @@ export async function removeFile(id: string) {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to delete file: ${response.statusText}`);
|
||||
if (!response.ok && response.status !== 404)
|
||||
throw new Error(`Failed to delete file: ${response.statusText}`);
|
||||
},
|
||||
{ fileId: id },
|
||||
);
|
||||
@@ -895,6 +902,26 @@ export async function createRecording(
|
||||
);
|
||||
}
|
||||
|
||||
const UPDATE_RECORDING_MUTATION = gql`
|
||||
mutation UpdateRecording($id: String!, $status: String, $public: Boolean) {
|
||||
updateRecording(id: $id, status: $status, public: $public) {
|
||||
id
|
||||
status
|
||||
public
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function updateRecording(id: string, fields: { status?: string; public?: boolean }) {
|
||||
return loggedApiCall("updateRecording", async () => {
|
||||
const data = await getGraphQLClient().request<{ updateRecording: Recording }>(
|
||||
UPDATE_RECORDING_MUTATION,
|
||||
{ id, ...fields },
|
||||
);
|
||||
return data.updateRecording;
|
||||
});
|
||||
}
|
||||
|
||||
const DELETE_RECORDING_MUTATION = gql`
|
||||
mutation DeleteRecording($id: String!) {
|
||||
deleteRecording(id: $id)
|
||||
@@ -1146,6 +1173,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||
$artistName: String
|
||||
$avatarId: String
|
||||
$bannerId: String
|
||||
$photoId: String
|
||||
) {
|
||||
adminUpdateUser(
|
||||
userId: $userId
|
||||
@@ -1156,6 +1184,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||
artistName: $artistName
|
||||
avatarId: $avatarId
|
||||
bannerId: $bannerId
|
||||
photoId: $photoId
|
||||
) {
|
||||
id
|
||||
email
|
||||
@@ -1166,6 +1195,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||
is_admin
|
||||
avatar
|
||||
banner
|
||||
photo
|
||||
date_created
|
||||
}
|
||||
}
|
||||
@@ -1180,6 +1210,7 @@ export async function adminUpdateUser(input: {
|
||||
artistName?: string;
|
||||
avatarId?: string;
|
||||
bannerId?: string;
|
||||
photoId?: string;
|
||||
}) {
|
||||
return loggedApiCall(
|
||||
"adminUpdateUser",
|
||||
@@ -1223,6 +1254,7 @@ const ADMIN_GET_USER_QUERY = gql`
|
||||
is_admin
|
||||
avatar
|
||||
banner
|
||||
photo
|
||||
description
|
||||
tags
|
||||
email_verified
|
||||
@@ -1240,7 +1272,9 @@ export async function adminGetUser(userId: string, token?: string) {
|
||||
"adminGetUser",
|
||||
async () => {
|
||||
const client = token ? getAuthClient(token) : getGraphQLClient();
|
||||
const data = await client.request<{ adminGetUser: any }>(ADMIN_GET_USER_QUERY, { userId });
|
||||
const data = await client.request<{
|
||||
adminGetUser: User & { photos: Array<{ id: string; filename: string }> };
|
||||
}>(ADMIN_GET_USER_QUERY, { userId });
|
||||
return data.adminGetUser;
|
||||
},
|
||||
{ userId },
|
||||
@@ -1862,3 +1896,145 @@ 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,
|
||||
): Promise<Job[]> {
|
||||
return loggedApiCall("getAdminQueueJobs", async () => {
|
||||
const data = await getGraphQLClient().request<{ adminQueueJobs: Job[] }>(
|
||||
ADMIN_QUEUE_JOBS_QUERY,
|
||||
{ queue, status, limit, offset },
|
||||
);
|
||||
return data.adminQueueJobs;
|
||||
});
|
||||
}
|
||||
|
||||
const ADMIN_RETRY_JOB_MUTATION = gql`
|
||||
mutation AdminRetryJob($queue: String!, $jobId: String!) {
|
||||
adminRetryJob(queue: $queue, jobId: $jobId)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminRetryJob(queue: string, jobId: string): Promise<void> {
|
||||
return loggedApiCall("adminRetryJob", async () => {
|
||||
await getGraphQLClient().request(ADMIN_RETRY_JOB_MUTATION, { queue, jobId });
|
||||
});
|
||||
}
|
||||
|
||||
const ADMIN_REMOVE_JOB_MUTATION = gql`
|
||||
mutation AdminRemoveJob($queue: String!, $jobId: String!) {
|
||||
adminRemoveJob(queue: $queue, jobId: $jobId)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminRemoveJob(queue: string, jobId: string): Promise<void> {
|
||||
return loggedApiCall("adminRemoveJob", async () => {
|
||||
await getGraphQLClient().request(ADMIN_REMOVE_JOB_MUTATION, { queue, jobId });
|
||||
});
|
||||
}
|
||||
|
||||
const ADMIN_PAUSE_QUEUE_MUTATION = gql`
|
||||
mutation AdminPauseQueue($queue: String!) {
|
||||
adminPauseQueue(queue: $queue)
|
||||
}
|
||||
`;
|
||||
|
||||
const ADMIN_RESUME_QUEUE_MUTATION = gql`
|
||||
mutation AdminResumeQueue($queue: String!) {
|
||||
adminResumeQueue(queue: $queue)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminPauseQueue(queue: string): Promise<void> {
|
||||
return loggedApiCall("adminPauseQueue", async () => {
|
||||
await getGraphQLClient().request(ADMIN_PAUSE_QUEUE_MUTATION, { queue });
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminResumeQueue(queue: string): Promise<void> {
|
||||
return loggedApiCall("adminResumeQueue", async () => {
|
||||
await getGraphQLClient().request(ADMIN_RESUME_QUEUE_MUTATION, { queue });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
|
||||
<div class="bg-background text-foreground min-h-screen">
|
||||
<!-- 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"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
import { formatVideoDuration } from "$lib/utils.js";
|
||||
import SexyBackground from "$lib/components/background/background.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
</script>
|
||||
@@ -13,10 +14,9 @@
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
<!-- Background Gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-accent/10 to-background"></div>
|
||||
<SexyBackground />
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 container mx-auto px-4 text-center">
|
||||
<div class="max-w-5xl mx-auto space-y-12">
|
||||
<h1
|
||||
@@ -47,14 +47,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Elements -->
|
||||
<div
|
||||
class="absolute top-20 left-10 w-20 h-20 bg-primary/20 rounded-full blur-xl animate-pulse"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-20 right-10 w-32 h-32 bg-accent/20 rounded-full blur-xl animate-pulse delay-1000"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
<!-- Featured Models -->
|
||||
@@ -71,40 +63,24 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-3xl mx-auto">
|
||||
{#each data.models as model (model.slug)}
|
||||
<Card
|
||||
class="p-0 group hover:shadow-2xl hover:shadow-primary/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-primary/20"
|
||||
>
|
||||
<CardContent class="p-6 text-center">
|
||||
<div class="relative mb-4">
|
||||
<img
|
||||
src={getAssetUrl(model.avatar, "mini")}
|
||||
alt={model.artist_name}
|
||||
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all"
|
||||
/>
|
||||
<!-- <div
|
||||
class="absolute -bottom-2 -right-2 bg-primary text-primary-foreground rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold"
|
||||
>
|
||||
<HeartIcon class="w-4 h-4 fill-current" />
|
||||
</div> -->
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg mb-2">{model.artist_name}</h3>
|
||||
<!-- <div
|
||||
class="flex items-center justify-center gap-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<StarIcon class="w-4 h-4 text-yellow-500 fill-current" />
|
||||
{model.rating}
|
||||
<a href="/models/{model.slug}" class="block group">
|
||||
<Card
|
||||
class="p-0 h-full hover:shadow-2xl hover:shadow-primary/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-primary/20"
|
||||
>
|
||||
<CardContent class="p-6 text-center">
|
||||
<div class="relative mb-4">
|
||||
<img
|
||||
src={getAssetUrl(model.avatar, "thumbnail")}
|
||||
alt={model.artist_name}
|
||||
class="w-24 h-24 rounded-full mx-auto object-cover ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div>{model.videos} {$_("home.featured_models.videos")}</div>
|
||||
</div> -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="mt-4 w-full group-hover:bg-primary/10"
|
||||
href="/models/{model.slug}">{$_("home.featured_models.view_profile")}</Button
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<h3 class="font-semibold text-lg group-hover:text-primary transition-colors">
|
||||
{model.artist_name}
|
||||
</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,50 +98,44 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
||||
{#each data.videos as video (video.slug)}
|
||||
<Card
|
||||
class="p-0 group hover:shadow-2xl hover:shadow-accent/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-accent/20 overflow-hidden"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(video.image, "preview")}
|
||||
alt={video.title}
|
||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
|
||||
></div>
|
||||
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
|
||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
||||
</div>
|
||||
<!-- <div
|
||||
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
|
||||
>
|
||||
{video.views}
|
||||
{$_("home.trending.views")}
|
||||
</div> -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<a
|
||||
class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center"
|
||||
href="/videos/{video.slug}"
|
||||
aria-label={video.title}
|
||||
<a href="/videos/{video.slug}" class="block group">
|
||||
<Card
|
||||
class="p-0 h-full hover:shadow-2xl hover:shadow-accent/20 transition-all duration-300 hover:-translate-y-2 bg-gradient-to-br from-card to-card/50 border-accent/20 overflow-hidden"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(video.image, "preview")}
|
||||
alt={video.title}
|
||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent group-hover:scale-105 transition-transform duration-300"
|
||||
></div>
|
||||
<div class="absolute bottom-2 left-2 text-white text-sm font-medium">
|
||||
{#if video.movie_file?.duration}{formatVideoDuration(
|
||||
video.movie_file.duration,
|
||||
)}{/if}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||
</a>
|
||||
<div class="w-16 h-16 bg-primary/90 rounded-full flex items-center justify-center">
|
||||
<span class="icon-[ri--play-large-fill] w-8 h-8 text-white"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent class="px-4 pb-4 pt-0">
|
||||
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||
{video.title}
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span class="icon-[ri--fire-line] w-4 h-4"></span>
|
||||
{$_("home.trending.trending")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardContent class="px-4 pb-4 pt-0">
|
||||
<h3 class="font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||
{video.title}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span class="icon-[ri--fire-line] w-4 h-4"></span>
|
||||
{$_("home.trending.trending")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const stats = [
|
||||
const stats = $derived([
|
||||
{
|
||||
icon: "icon-[ri--user-heart-line]",
|
||||
value: data.stats.viewers_count,
|
||||
@@ -28,7 +28,7 @@
|
||||
value: $_("about.stats.yearsFormatted", { values: { years: 5 } }),
|
||||
label: $_("about.stats.experience"),
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const team = [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
@@ -21,29 +35,32 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-background">
|
||||
<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 flex items-center gap-2 py-3 border-b border-border/40">
|
||||
<a
|
||||
href="/"
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 mr-2"
|
||||
>
|
||||
{$_("admin.nav.back_mobile")}
|
||||
</a>
|
||||
{#each navLinks as link (link.href)}
|
||||
<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={link.href}
|
||||
class={`flex items-center gap-1.5 rounded-lg px-3 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"
|
||||
}`}
|
||||
href="/"
|
||||
class="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
|
||||
>
|
||||
<span class={`${link.icon} h-4 w-4`}></span>
|
||||
{link.name}
|
||||
<span class="icon-[ri--arrow-left-line] h-4 w-4"></span>
|
||||
<span class="hidden sm:inline">{$_("admin.nav.back_mobile")}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#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 -->
|
||||
@@ -51,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,7 @@
|
||||
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";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -20,7 +21,7 @@
|
||||
let deleteTarget: Article | null = $state(null);
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
let searchValue = $state(data.search ?? "");
|
||||
let searchValue = $derived(data.search ?? "");
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function debounceSearch(value: string) {
|
||||
@@ -64,8 +65,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 +84,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap 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"
|
||||
@@ -110,7 +113,6 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={data.featured === true ? "default" : "outline"}
|
||||
onclick={() => setFilter("featured", data.featured === true ? null : "true")}
|
||||
>
|
||||
@@ -202,7 +204,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">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
@@ -10,24 +11,44 @@
|
||||
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();
|
||||
|
||||
let title = $state(data.article.title);
|
||||
let slug = $state(data.article.slug);
|
||||
let excerpt = $state(data.article.excerpt ?? "");
|
||||
let content = $state(data.article.content ?? "");
|
||||
let category = $state(data.article.category ?? "");
|
||||
let tags = $state<string[]>(data.article.tags ?? []);
|
||||
let featured = $state(data.article.featured ?? false);
|
||||
let title = $state(untrack(() => data.article.title));
|
||||
let slug = $state(untrack(() => data.article.slug));
|
||||
let excerpt = $state(untrack(() => data.article.excerpt ?? ""));
|
||||
let content = $state(untrack(() => data.article.content ?? ""));
|
||||
let category = $state(untrack(() => data.article.category ?? ""));
|
||||
let tags = $state<string[]>(untrack(() => data.article.tags ?? []));
|
||||
let featured = $state(untrack(() => data.article.featured ?? false));
|
||||
let publishDate = $state(
|
||||
data.article.publish_date ? new Date(data.article.publish_date).toISOString().slice(0, 16) : "",
|
||||
untrack(() =>
|
||||
data.article.publish_date
|
||||
? new Date(data.article.publish_date).toISOString().slice(0, 16)
|
||||
: "",
|
||||
),
|
||||
);
|
||||
let imageId = $state<string | null>(data.article.image ?? null);
|
||||
let authorId = $state(data.article.author?.id ?? "");
|
||||
let imageId = $state<string | null>(untrack(() => data.article.image ?? null));
|
||||
let authorId = $state(untrack(() => data.article.author?.id ?? ""));
|
||||
$effect(() => {
|
||||
title = data.article.title;
|
||||
slug = data.article.slug;
|
||||
excerpt = data.article.excerpt ?? "";
|
||||
content = data.article.content ?? "";
|
||||
category = data.article.category ?? "";
|
||||
tags = data.article.tags ?? [];
|
||||
featured = data.article.featured ?? false;
|
||||
publishDate = data.article.publish_date
|
||||
? new Date(data.article.publish_date).toISOString().slice(0, 16)
|
||||
: "";
|
||||
imageId = data.article.image ?? null;
|
||||
authorId = data.article.author?.id ?? "";
|
||||
});
|
||||
let selectedAuthor = $derived(data.authors.find((a) => a.id === authorId) ?? null);
|
||||
let saving = $state(false);
|
||||
let editorTab = $state<"write" | "preview">("write");
|
||||
@@ -66,153 +87,178 @@
|
||||
});
|
||||
toast.success($_("admin.article_form.update_success"));
|
||||
goto("/admin/articles");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? $_("admin.article_form.update_error"));
|
||||
} catch (e) {
|
||||
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.article_form.update_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<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} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input id="slug" bind:value={slug} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||
<Textarea id="excerpt" bind:value={excerpt} rows={2} />
|
||||
</div>
|
||||
|
||||
<!-- Markdown editor with live preview -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{$_("admin.article_form.content")}</Label>
|
||||
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button
|
||||
>
|
||||
<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}
|
||||
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}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||
<Textarea
|
||||
bind:value={content}
|
||||
class={`h-full min-h-96 font-mono text-sm resize-none ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
||||
id="excerpt"
|
||||
bind:value={excerpt}
|
||||
rows={2}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
<div
|
||||
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
|
||||
>
|
||||
{#if preview}
|
||||
{@html preview}
|
||||
{:else}
|
||||
<p class="text-muted-foreground italic text-sm">
|
||||
{$_("admin.article_form.preview_placeholder")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Markdown editor with live preview -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{$_("admin.article_form.content")}</Label>
|
||||
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
||||
<Button
|
||||
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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||
<Textarea
|
||||
bind:value={content}
|
||||
class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
||||
/>
|
||||
<div
|
||||
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
|
||||
>
|
||||
{#if preview}
|
||||
{@html preview}
|
||||
{:else}
|
||||
<p class="text-muted-foreground italic text-sm">
|
||||
{$_("admin.article_form.preview_placeholder")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
{#if imageId}
|
||||
<img
|
||||
src={getAssetUrl(imageId, "thumbnail")}
|
||||
alt=""
|
||||
class="h-24 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
{#if imageId}
|
||||
<img
|
||||
src={getAssetUrl(imageId, "thumbnail")}
|
||||
alt=""
|
||||
class="h-24 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.article_form.author")}</Label>
|
||||
<Select type="single" bind:value={authorId}>
|
||||
<SelectTrigger class="w-full">
|
||||
{#if selectedAuthor}
|
||||
{#if selectedAuthor.avatar}
|
||||
<img
|
||||
src={getAssetUrl(selectedAuthor.avatar, "mini")}
|
||||
alt=""
|
||||
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
{/if}
|
||||
{selectedAuthor.artist_name}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
|
||||
{#each data.authors as author (author.id)}
|
||||
<SelectItem value={author.id}>
|
||||
{#if author.avatar}
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.article_form.author")}</Label>
|
||||
<Select type="single" bind:value={authorId}>
|
||||
<SelectTrigger class="w-full bg-background/50 border-primary/20">
|
||||
{#if selectedAuthor}
|
||||
{#if selectedAuthor.avatar}
|
||||
<img
|
||||
src={getAssetUrl(author.avatar, "mini")}
|
||||
src={getAssetUrl(selectedAuthor.avatar, "mini")}
|
||||
alt=""
|
||||
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
{/if}
|
||||
{author.artist_name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||
<Input id="category" bind:value={category} />
|
||||
{selectedAuthor.artist_name}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">{$_("admin.article_form.no_author")}</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{$_("admin.article_form.no_author")}</SelectItem>
|
||||
{#each data.authors as author (author.id)}
|
||||
<SelectItem value={author.id}>
|
||||
{#if author.avatar}
|
||||
<img
|
||||
src={getAssetUrl(author.avatar, "mini")}
|
||||
alt=""
|
||||
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
{/if}
|
||||
{author.artist_name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
||||
|
||||
<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}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button
|
||||
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("");
|
||||
@@ -67,135 +69,142 @@
|
||||
});
|
||||
toast.success($_("admin.article_form.create_success"));
|
||||
goto("/admin/articles");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? $_("admin.article_form.create_error"));
|
||||
} catch (e) {
|
||||
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.article_form.create_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<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}
|
||||
oninput={() => {
|
||||
if (!slug) slug = generateSlug(title);
|
||||
}}
|
||||
placeholder={$_("admin.article_form.title_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
bind:value={slug}
|
||||
placeholder={$_("admin.article_form.slug_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||
<Textarea
|
||||
id="excerpt"
|
||||
bind:value={excerpt}
|
||||
placeholder={$_("admin.article_form.excerpt_placeholder")}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Markdown editor with live preview -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{$_("admin.article_form.content")}</Label>
|
||||
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 transition-colors ${editorTab === "write" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "write")}>{$_("admin.common.write")}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</button
|
||||
>
|
||||
<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}
|
||||
oninput={() => {
|
||||
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">
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
bind:value={slug}
|
||||
placeholder={$_("admin.article_form.slug_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: single pane toggled; Desktop: side by side -->
|
||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="excerpt">{$_("admin.article_form.excerpt")}</Label>
|
||||
<Textarea
|
||||
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" : ""}`}
|
||||
id="excerpt"
|
||||
bind:value={excerpt}
|
||||
placeholder={$_("admin.article_form.excerpt_placeholder")}
|
||||
rows={2}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
<div
|
||||
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
|
||||
>
|
||||
{#if preview}
|
||||
{@html preview}
|
||||
{:else}
|
||||
<p class="text-muted-foreground italic text-sm">
|
||||
{$_("admin.article_form.preview_placeholder")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Markdown editor with live preview -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{$_("admin.article_form.content")}</Label>
|
||||
<div class="flex rounded-lg border border-border/40 overflow-hidden text-xs sm:hidden">
|
||||
<Button
|
||||
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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={`px-3 py-1 h-auto rounded-none transition-colors ${editorTab === "preview" ? "bg-primary/10 text-primary" : "text-muted-foreground"}`}
|
||||
onclick={() => (editorTab = "preview")}>{$_("admin.common.preview")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: single pane toggled; Desktop: side by side -->
|
||||
<div class="sm:grid sm:grid-cols-2 sm:gap-4 min-h-96">
|
||||
<Textarea
|
||||
bind:value={content}
|
||||
placeholder={$_("admin.article_form.content_placeholder")}
|
||||
class={`h-full min-h-96 font-mono text-sm resize-none bg-background/50 border-primary/20 focus:border-primary ${editorTab === "preview" ? "hidden sm:flex" : ""}`}
|
||||
/>
|
||||
<div
|
||||
class={`rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground min-h-96 ${editorTab === "write" ? "hidden sm:block" : ""}`}
|
||||
>
|
||||
{#if preview}
|
||||
{@html preview}
|
||||
{:else}
|
||||
<p class="text-muted-foreground italic text-sm">
|
||||
{$_("admin.article_form.preview_placeholder")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
{#if imageId}<p class="text-xs text-green-600 mt-1">
|
||||
{$_("admin.common.image_uploaded")} ✓
|
||||
</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||
<Input
|
||||
id="category"
|
||||
bind:value={category}
|
||||
placeholder={$_("admin.article_form.category_placeholder")}
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
{#if imageId}
|
||||
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="category">{$_("admin.article_form.category")}</Label>
|
||||
<Input
|
||||
id="category"
|
||||
bind:value={category}
|
||||
placeholder={$_("admin.article_form.category_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker bind:value={publishDate} placeholder={$_("admin.common.publish_date")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button
|
||||
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,7 @@
|
||||
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";
|
||||
|
||||
const { data } = $props();
|
||||
const timeAgo = new TimeAgo("en");
|
||||
@@ -17,7 +18,7 @@
|
||||
let deleteTarget: { id: number; comment: string } | null = $state(null);
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
let searchValue = $state(data.search ?? "");
|
||||
let searchValue = $derived(data.search ?? "");
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function debounceSearch(value: string) {
|
||||
@@ -53,15 +54,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 +153,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">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getAdminQueues } from "$lib/services";
|
||||
|
||||
export async function load({ fetch, cookies }) {
|
||||
const token = cookies.get("session_token") || "";
|
||||
const queues = await getAdminQueues(fetch, token).catch(() => []);
|
||||
return { queues };
|
||||
}
|
||||
302
packages/frontend/src/routes/admin/queues/+page.svelte
Normal file
302
packages/frontend/src/routes/admin/queues/+page.svelte
Normal file
@@ -0,0 +1,302 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
getAdminQueueJobs,
|
||||
adminRetryJob,
|
||||
adminRemoveJob,
|
||||
adminPauseQueue,
|
||||
adminResumeQueue,
|
||||
} from "$lib/services";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import type { Job } from "$lib/services";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const queues = $derived(data.queues);
|
||||
|
||||
// null means "user hasn't picked yet" — fall back to first queue
|
||||
let selectedQueueOverride = $state<string | null>(null);
|
||||
const selectedQueue = $derived(selectedQueueOverride ?? queues[0]?.name ?? null);
|
||||
let selectedStatus = $state<string | null>(null);
|
||||
let jobs = $state<Job[]>([]);
|
||||
let loadingJobs = $state(false);
|
||||
let togglingQueue = $state<string | null>(null);
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: null, label: $_("admin.queues.status_all") },
|
||||
{ value: "waiting", label: $_("admin.queues.status_waiting") },
|
||||
{ value: "active", label: $_("admin.queues.status_active") },
|
||||
{ value: "completed", label: $_("admin.queues.status_completed") },
|
||||
{ value: "failed", label: $_("admin.queues.status_failed") },
|
||||
{ value: "delayed", label: $_("admin.queues.status_delayed") },
|
||||
];
|
||||
|
||||
async function loadJobs() {
|
||||
if (!selectedQueue) return;
|
||||
loadingJobs = true;
|
||||
try {
|
||||
jobs = await getAdminQueueJobs(selectedQueue, selectedStatus ?? undefined, 50, 0);
|
||||
} finally {
|
||||
loadingJobs = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectQueue(name: string) {
|
||||
selectedQueueOverride = name;
|
||||
selectedStatus = null;
|
||||
await loadJobs();
|
||||
}
|
||||
|
||||
async function selectStatus(status: string | null) {
|
||||
selectedStatus = status;
|
||||
await loadJobs();
|
||||
}
|
||||
|
||||
async function retryJob(job: Job) {
|
||||
try {
|
||||
await adminRetryJob(job.queue, job.id);
|
||||
toast.success($_("admin.queues.retry_success"));
|
||||
await loadJobs();
|
||||
await refreshCounts();
|
||||
} catch {
|
||||
toast.error($_("admin.queues.retry_error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeJob(job: Job) {
|
||||
try {
|
||||
await adminRemoveJob(job.queue, job.id);
|
||||
toast.success($_("admin.queues.remove_success"));
|
||||
jobs = jobs.filter((j) => j.id !== job.id);
|
||||
await refreshCounts();
|
||||
} catch {
|
||||
toast.error($_("admin.queues.remove_error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleQueue(queueName: string, isPaused: boolean) {
|
||||
togglingQueue = queueName;
|
||||
try {
|
||||
if (isPaused) {
|
||||
await adminResumeQueue(queueName);
|
||||
toast.success($_("admin.queues.resume_success"));
|
||||
} else {
|
||||
await adminPauseQueue(queueName);
|
||||
toast.success($_("admin.queues.pause_success"));
|
||||
}
|
||||
await refreshCounts();
|
||||
} catch {
|
||||
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
|
||||
} finally {
|
||||
togglingQueue = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCounts() {
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (selectedQueue) loadJobs();
|
||||
});
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "text-blue-500 border-blue-500/30 bg-blue-500/10";
|
||||
case "completed":
|
||||
return "text-green-500 border-green-500/30 bg-green-500/10";
|
||||
case "failed":
|
||||
return "text-destructive border-destructive/30 bg-destructive/10";
|
||||
case "delayed":
|
||||
return "text-yellow-500 border-yellow-500/30 bg-yellow-500/10";
|
||||
default:
|
||||
return "text-muted-foreground border-border/40 bg-muted/20";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Meta title={$_("admin.queues.title")} description={null} />
|
||||
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Queue cards -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
{#each queues as queue (queue.name)}
|
||||
{@const isSelected = selectedQueue === queue.name}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class={`flex-1 min-w-48 rounded-lg border p-4 text-left transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? "border-primary/50 bg-primary/5"
|
||||
: "border-border/40 bg-card hover:border-border/70"
|
||||
}`}
|
||||
onclick={() => selectQueue(queue.name)}
|
||||
onkeydown={(e) => e.key === "Enter" && selectQueue(queue.name)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold capitalize">{queue.name}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if queue.isPaused}
|
||||
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10"
|
||||
>{$_("admin.queues.paused_badge")}</Badge
|
||||
>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-6 px-2 text-xs"
|
||||
disabled={togglingQueue === queue.name}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleQueue(queue.name, queue.isPaused);
|
||||
}}
|
||||
>
|
||||
{queue.isPaused ? $_("admin.queues.resume") : $_("admin.queues.pause")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
{#if queue.counts.waiting > 0}
|
||||
<span class="text-muted-foreground">{queue.counts.waiting} waiting</span>
|
||||
{/if}
|
||||
{#if queue.counts.active > 0}
|
||||
<span class="text-blue-500">{queue.counts.active} active</span>
|
||||
{/if}
|
||||
{#if queue.counts.completed > 0}
|
||||
<span class="text-green-500">{queue.counts.completed} completed</span>
|
||||
{/if}
|
||||
{#if queue.counts.failed > 0}
|
||||
<span class="text-destructive font-medium">{queue.counts.failed} failed</span>
|
||||
{/if}
|
||||
{#if queue.counts.delayed > 0}
|
||||
<span class="text-yellow-500">{queue.counts.delayed} delayed</span>
|
||||
{/if}
|
||||
{#if Object.values(queue.counts).every((v) => v === 0)}
|
||||
<span class="text-muted-foreground">empty</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedQueue}
|
||||
<!-- Status filter tabs -->
|
||||
<div class="flex gap-1 mb-4 flex-wrap">
|
||||
{#each STATUS_FILTERS as f (f.value ?? "all")}
|
||||
<Button
|
||||
variant={selectedStatus === f.value ? "default" : "outline"}
|
||||
onclick={() => selectStatus(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Jobs table -->
|
||||
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
||||
>{$_("admin.queues.col_id")}</th
|
||||
>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
||||
>{$_("admin.queues.col_name")}</th
|
||||
>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
||||
>{$_("admin.queues.col_status")}</th
|
||||
>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">
|
||||
{$_("admin.queues.col_attempts")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden lg:table-cell">
|
||||
{$_("admin.queues.col_created")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
|
||||
>{$_("admin.queues.col_actions")}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/30">
|
||||
{#if loadingJobs}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
||||
>{$_("common.loading")}</td
|
||||
>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each jobs as job (job.id)}
|
||||
<tr class="hover:bg-muted/10 transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div>
|
||||
<p class="font-medium">{job.name}</p>
|
||||
{#if job.failedReason}
|
||||
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
|
||||
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
|
||||
>{job.attemptsMade}</td
|
||||
>
|
||||
<td class="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"
|
||||
>{formatDate(job.createdAt)}</td
|
||||
>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
{#if job.status === "failed"}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={$_("admin.queues.retry")}
|
||||
onclick={() => retryJob(job)}
|
||||
>
|
||||
<span class="icon-[ri--restart-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={$_("admin.queues.remove")}
|
||||
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onclick={() => removeJob(job)}
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if jobs.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
||||
>{$_("admin.queues.no_jobs")}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -11,6 +11,7 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import type { Recording } from "$lib/types";
|
||||
import TimeAgo from "javascript-time-ago";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const timeAgo = new TimeAgo("en");
|
||||
@@ -18,7 +19,7 @@
|
||||
let deleteTarget: Recording | null = $state(null);
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
let searchValue = $state(data.search ?? "");
|
||||
let searchValue = $derived(data.search ?? "");
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function debounceSearch(value: string) {
|
||||
@@ -63,15 +64,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 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"
|
||||
@@ -83,17 +86,14 @@
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={data.status === undefined ? "default" : "outline"}
|
||||
onclick={() => setFilter("status", null)}>{$_("admin.common.all")}</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={data.status === "published" ? "default" : "outline"}
|
||||
onclick={() => setFilter("status", "published")}>{$_("admin.recordings.published")}</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={data.status === "draft" ? "default" : "outline"}
|
||||
onclick={() => setFilter("status", "draft")}>{$_("admin.recordings.draft")}</Button
|
||||
>
|
||||
@@ -131,10 +131,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"
|
||||
@@ -177,7 +179,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">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
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";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
let searchValue = $state(data.search ?? "");
|
||||
let searchValue = $derived(data.search ?? "");
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let deleteTarget: User | null = $state(null);
|
||||
let deleteOpen = $state(false);
|
||||
@@ -84,8 +85,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 +96,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap 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"
|
||||
@@ -107,7 +110,6 @@
|
||||
<div class="flex gap-1">
|
||||
{#each roles as role (role)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
|
||||
onclick={() => setRole(role)}
|
||||
>
|
||||
@@ -226,7 +228,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">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import {
|
||||
@@ -12,17 +13,29 @@
|
||||
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();
|
||||
|
||||
let firstName = $state(data.user.first_name ?? "");
|
||||
let lastName = $state(data.user.last_name ?? "");
|
||||
let artistName = $state(data.user.artist_name ?? "");
|
||||
let avatarId = $state<string | null>(data.user.avatar ?? null);
|
||||
let bannerId = $state<string | null>(data.user.banner ?? null);
|
||||
let isAdmin = $state(data.user.is_admin ?? false);
|
||||
let firstName = $state(untrack(() => data.user.first_name ?? ""));
|
||||
let lastName = $state(untrack(() => data.user.last_name ?? ""));
|
||||
let artistName = $state(untrack(() => data.user.artist_name ?? ""));
|
||||
let avatarId = $state<string | null>(untrack(() => data.user.avatar ?? null));
|
||||
let bannerId = $state<string | null>(untrack(() => data.user.banner ?? null));
|
||||
let photoId = $state<string | null>(untrack(() => data.user.photo ?? null));
|
||||
let isAdmin = $state(untrack(() => data.user.is_admin ?? false));
|
||||
let saving = $state(false);
|
||||
$effect(() => {
|
||||
firstName = data.user.first_name ?? "";
|
||||
lastName = data.user.last_name ?? "";
|
||||
artistName = data.user.artist_name ?? "";
|
||||
avatarId = data.user.avatar ?? null;
|
||||
bannerId = data.user.banner ?? null;
|
||||
photoId = data.user.photo ?? null;
|
||||
isAdmin = data.user.is_admin ?? false;
|
||||
});
|
||||
|
||||
async function handleAvatarUpload(files: File[]) {
|
||||
const file = files[0];
|
||||
@@ -52,6 +65,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhotoUpload2(files: File[]) {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
const res = await uploadFile(fd);
|
||||
photoId = res.id;
|
||||
toast.success($_("admin.user_edit.model_photo_uploaded"));
|
||||
} catch {
|
||||
toast.error($_("admin.user_edit.model_photo_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhotoUpload(files: File[]) {
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
@@ -88,22 +115,22 @@
|
||||
artistName: artistName || undefined,
|
||||
avatarId: avatarId || undefined,
|
||||
bannerId: bannerId || undefined,
|
||||
photoId: photoId || undefined,
|
||||
isAdmin,
|
||||
});
|
||||
toast.success($_("admin.user_edit.save_success"));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? $_("admin.user_edit.save_error"));
|
||||
} catch (e) {
|
||||
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.user_edit.save_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</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">
|
||||
@@ -114,103 +141,142 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Basic info -->
|
||||
<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} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="lastName">{$_("admin.user_edit.last_name")}</Label>
|
||||
<Input id="lastName" bind:value={lastName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
|
||||
<Input id="artistName" bind:value={artistName} />
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.user_edit.avatar")}</Label>
|
||||
{#if avatarId}
|
||||
<img
|
||||
src={getAssetUrl(avatarId, "thumbnail")}
|
||||
alt=""
|
||||
class="h-20 w-20 rounded-full object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleAvatarUpload} />
|
||||
</div>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.user_edit.banner")}</Label>
|
||||
{#if bannerId}
|
||||
<img
|
||||
src={getAssetUrl(bannerId, "preview")}
|
||||
alt=""
|
||||
class="w-full h-24 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleBannerUpload} />
|
||||
</div>
|
||||
|
||||
<!-- Admin flag -->
|
||||
<label
|
||||
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isAdmin}
|
||||
class="h-4 w-4 rounded accent-primary shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
|
||||
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
onclick={handleSave}
|
||||
disabled={saving}
|
||||
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Photo gallery -->
|
||||
<div class="space-y-3 pt-4 border-t border-border/40">
|
||||
<Label>{$_("admin.user_edit.photos")}</Label>
|
||||
|
||||
{#if data.user.photos && data.user.photos.length > 0}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each data.user.photos as photo (photo.id)}
|
||||
<div class="relative group">
|
||||
<img
|
||||
src={getAssetUrl(photo.id, "thumbnail")}
|
||||
alt=""
|
||||
class="w-full aspect-square object-cover rounded"
|
||||
/>
|
||||
<button
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded"
|
||||
onclick={() => removePhoto(photo.id)}
|
||||
type="button"
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<div 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}
|
||||
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}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
|
||||
{/if}
|
||||
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="artistName">{$_("admin.user_edit.artist_name")}</Label>
|
||||
<Input
|
||||
id="artistName"
|
||||
bind:value={artistName}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.user_edit.avatar")}</Label>
|
||||
{#if avatarId}
|
||||
<img
|
||||
src={getAssetUrl(avatarId, "thumbnail")}
|
||||
alt=""
|
||||
class="h-20 w-20 rounded-full object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
maxFileSize={10 * MEGABYTE}
|
||||
onUpload={handleAvatarUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.user_edit.banner")}</Label>
|
||||
{#if bannerId}
|
||||
<img
|
||||
src={getAssetUrl(bannerId, "preview")}
|
||||
alt=""
|
||||
class="w-full h-24 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
maxFileSize={10 * MEGABYTE}
|
||||
onUpload={handleBannerUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.user_edit.model_photo")}</Label>
|
||||
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.model_photo_hint")}</p>
|
||||
{#if photoId}
|
||||
<img
|
||||
src={getAssetUrl(photoId, "preview")}
|
||||
alt=""
|
||||
class="w-full h-48 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
maxFileSize={10 * MEGABYTE}
|
||||
onUpload={handlePhotoUpload2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="flex items-center gap-3 rounded-lg border border-border/40 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isAdmin}
|
||||
class="h-4 w-4 rounded accent-primary shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium">{$_("admin.user_edit.is_admin")}</span>
|
||||
<p class="text-xs text-muted-foreground">{$_("admin.user_edit.is_admin_hint")}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
onclick={handleSave}
|
||||
disabled={saving}
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
{saving ? $_("admin.common.saving") : $_("admin.common.save_changes")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Photo gallery card -->
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardContent class="space-y-4 pt-6">
|
||||
<Label>{$_("admin.user_edit.photos")}</Label>
|
||||
|
||||
{#if data.user.photos && data.user.photos.length > 0}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each data.user.photos as photo (photo.id)}
|
||||
<div class="relative group">
|
||||
<img
|
||||
src={getAssetUrl(photo.id, "thumbnail")}
|
||||
alt=""
|
||||
class="w-full aspect-square object-cover rounded"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded h-auto p-0"
|
||||
onclick={() => removePhoto(photo.id)}
|
||||
aria-label="Remove photo"
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] h-5 w-5 text-white"></span>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">{$_("admin.user_edit.no_photos")}</p>
|
||||
{/if}
|
||||
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handlePhotoUpload} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
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";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
let deleteTarget: Video | null = $state(null);
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
let searchValue = $state(data.search ?? "");
|
||||
let searchValue = $derived(data.search ?? "");
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function debounceSearch(value: string) {
|
||||
@@ -61,8 +62,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 +81,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap 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"
|
||||
@@ -90,21 +93,18 @@
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={data.featured === undefined ? "default" : "outline"}
|
||||
onclick={() => setFilter("featured", null)}
|
||||
>
|
||||
{$_("admin.common.all")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={data.featured === true ? "default" : "outline"}
|
||||
onclick={() => setFilter("featured", "true")}
|
||||
>
|
||||
{$_("admin.common.featured")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={data.premium === true ? "default" : "outline"}
|
||||
onclick={() => setFilter("premium", data.premium === true ? null : "true")}
|
||||
>
|
||||
@@ -209,7 +209,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">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("admin.users.showing", {
|
||||
values: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
@@ -9,26 +10,44 @@
|
||||
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();
|
||||
|
||||
let title = $state(data.video.title);
|
||||
let slug = $state(data.video.slug);
|
||||
let description = $state(data.video.description ?? "");
|
||||
let tags = $state<string[]>(data.video.tags ?? []);
|
||||
let premium = $state(data.video.premium ?? false);
|
||||
let featured = $state(data.video.featured ?? false);
|
||||
let title = $state(untrack(() => data.video.title));
|
||||
let slug = $state(untrack(() => data.video.slug));
|
||||
let description = $state(untrack(() => data.video.description ?? ""));
|
||||
let tags = $state<string[]>(untrack(() => data.video.tags ?? []));
|
||||
let premium = $state(untrack(() => data.video.premium ?? false));
|
||||
let featured = $state(untrack(() => data.video.featured ?? false));
|
||||
let uploadDate = $state(
|
||||
data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "",
|
||||
untrack(() =>
|
||||
data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "",
|
||||
),
|
||||
);
|
||||
let imageId = $state<string | null>(data.video.image ?? null);
|
||||
let movieId = $state<string | null>(data.video.movie ?? null);
|
||||
let imageId = $state<string | null>(untrack(() => data.video.image ?? null));
|
||||
let movieId = $state<string | null>(untrack(() => data.video.movie ?? null));
|
||||
let selectedModelIds = $state<string[]>(
|
||||
data.video.models?.map((m: { id: string }) => m.id) ?? [],
|
||||
untrack(() => data.video.models?.map((m: { id: string }) => m.id) ?? []),
|
||||
);
|
||||
$effect(() => {
|
||||
title = data.video.title;
|
||||
slug = data.video.slug;
|
||||
description = data.video.description ?? "";
|
||||
tags = data.video.tags ?? [];
|
||||
premium = data.video.premium ?? false;
|
||||
featured = data.video.featured ?? false;
|
||||
uploadDate = data.video.upload_date
|
||||
? new Date(data.video.upload_date).toISOString().slice(0, 16)
|
||||
: "";
|
||||
imageId = data.video.image ?? null;
|
||||
movieId = data.video.movie ?? null;
|
||||
selectedModelIds = data.video.models?.map((m: { id: string }) => m.id) ?? [];
|
||||
});
|
||||
let saving = $state(false);
|
||||
|
||||
async function handleImageUpload(files: File[]) {
|
||||
@@ -77,138 +96,153 @@
|
||||
await setVideoModels(data.video.id, selectedModelIds);
|
||||
toast.success($_("admin.video_form.update_success"));
|
||||
goto("/admin/videos");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? $_("admin.video_form.update_error"));
|
||||
} catch (e) {
|
||||
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.video_form.update_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
placeholder={$_("admin.video_form.title_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
bind:value={slug}
|
||||
placeholder={$_("admin.video_form.slug_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="title">{$_("admin.common.title_field")}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
placeholder={$_("admin.video_form.title_placeholder")}
|
||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder={$_("admin.video_form.description_placeholder")}
|
||||
rows={3}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="slug">{$_("admin.common.slug_field")}</Label>
|
||||
<Input id="slug" bind:value={slug} placeholder={$_("admin.video_form.slug_placeholder")} />
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
{#if imageId}
|
||||
<img
|
||||
src={getAssetUrl(imageId, "thumbnail")}
|
||||
alt=""
|
||||
class="h-24 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder={$_("admin.video_form.description_placeholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||
{#if movieId}
|
||||
<video
|
||||
src={getAssetUrl(movieId)}
|
||||
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
|
||||
controls
|
||||
class="w-full rounded-lg bg-black max-h-72 mb-2"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
{#if imageId}
|
||||
<img
|
||||
src={getAssetUrl(imageId, "thumbnail")}
|
||||
alt=""
|
||||
class="h-24 rounded object-cover mb-2"
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||
{#if movieId}
|
||||
<video
|
||||
src={getAssetUrl(movieId)}
|
||||
poster={imageId ? (getAssetUrl(imageId, "preview") ?? undefined) : undefined}
|
||||
controls
|
||||
class="w-full rounded-lg bg-black max-h-72 mb-2"
|
||||
></video>
|
||||
{/if}
|
||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker
|
||||
bind:value={uploadDate}
|
||||
placeholder={$_("admin.common.publish_date")}
|
||||
showTime={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.premium")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if data.models.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.video_form.models")}</Label>
|
||||
<Select type="multiple" bind:value={selectedModelIds}>
|
||||
<SelectTrigger class="w-full">
|
||||
{#if selectedModelIds.length}
|
||||
{$_("admin.video_form.models_selected", {
|
||||
values: { count: selectedModelIds.length },
|
||||
})}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each data.models as model (model.id)}
|
||||
<SelectItem value={model.id}>
|
||||
{#if model.avatar}
|
||||
<img
|
||||
src={getAssetUrl(model.avatar, "mini")}
|
||||
alt=""
|
||||
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
{/if}
|
||||
{model.artist_name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker
|
||||
bind:value={uploadDate}
|
||||
placeholder={$_("admin.common.publish_date")}
|
||||
showTime={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.premium")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if data.models.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.video_form.models")}</Label>
|
||||
<Select type="multiple" bind:value={selectedModelIds}>
|
||||
<SelectTrigger class="w-full bg-background/50 border-primary/20">
|
||||
{#if selectedModelIds.length}
|
||||
{$_("admin.video_form.models_selected", {
|
||||
values: { count: selectedModelIds.length },
|
||||
})}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">{$_("admin.video_form.no_models")}</span>
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each data.models as model (model.id)}
|
||||
<SelectItem value={model.id}>
|
||||
{#if model.avatar}
|
||||
<img
|
||||
src={getAssetUrl(model.avatar, "mini")}
|
||||
alt=""
|
||||
class="h-5 w-5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
{/if}
|
||||
{model.artist_name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
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();
|
||||
|
||||
@@ -89,122 +91,131 @@
|
||||
}
|
||||
toast.success($_("admin.video_form.create_success"));
|
||||
goto("/admin/videos");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? $_("admin.video_form.create_error"));
|
||||
} catch (e) {
|
||||
toast.error((e instanceof Error ? e.message : null) ?? $_("admin.video_form.create_error"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<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}
|
||||
oninput={() => {
|
||||
if (!slug) slug = generateSlug(title);
|
||||
}}
|
||||
placeholder={$_("admin.video_form.title_placeholder")}
|
||||
/>
|
||||
</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")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder={$_("admin.video_form.description_placeholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
{#if imageId}<p class="text-xs text-green-600 mt-1">
|
||||
{$_("admin.common.image_uploaded")} ✓
|
||||
</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||
{#if movieId}<p class="text-xs text-green-600 mt-1">
|
||||
{$_("admin.video_form.video_uploaded")} ✓
|
||||
</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput bind:value={tags} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker
|
||||
bind:value={uploadDate}
|
||||
placeholder={$_("admin.common.publish_date")}
|
||||
showTime={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.premium")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if data.models.length > 0}
|
||||
<div class="space-y-2">
|
||||
<Label>Models</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.models as model (model.id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
|
||||
selectedModelIds.includes(model.id)
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border/40 text-muted-foreground hover:border-primary/40"
|
||||
}`}
|
||||
onclick={() => toggleModel(model.id)}
|
||||
>
|
||||
{model.artist_name || model.id}
|
||||
</button>
|
||||
{/each}
|
||||
<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>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
oninput={() => {
|
||||
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")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="description">{$_("admin.video_form.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder={$_("admin.video_form.description_placeholder")}
|
||||
rows={3}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.cover_image")}</Label>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
{#if imageId}
|
||||
<p class="text-xs text-green-600 mt-1">{$_("admin.common.image_uploaded")} ✓</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.video_form.video_file")}</Label>
|
||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||
{#if movieId}
|
||||
<p class="text-xs text-green-600 mt-1">{$_("admin.video_form.video_uploaded")} ✓</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.tags")}</Label>
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{$_("admin.common.publish_date")}</Label>
|
||||
<DatePicker
|
||||
bind:value={uploadDate}
|
||||
placeholder={$_("admin.common.publish_date")}
|
||||
showTime={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={premium} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.premium")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={featured} class="rounded" />
|
||||
<span class="text-sm">{$_("admin.common.featured")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if data.models.length > 0}
|
||||
<div class="space-y-2">
|
||||
<Label>{$_("admin.video_form.models")}</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.models as model (model.id)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={`px-3 py-1.5 h-auto rounded-full text-sm border transition-colors ${
|
||||
selectedModelIds.includes(model.id)
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border/40 text-muted-foreground hover:border-primary/40"
|
||||
}`}
|
||||
onclick={() => toggleModel(model.id)}
|
||||
>
|
||||
{model.artist_name || model.id}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
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>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
let searchQuery = $state("");
|
||||
let expandedItems = new SvelteSet<number>();
|
||||
// eslint-disable-next-line svelte/no-unnecessary-state-wrap -- variable is reassigned, $state is required
|
||||
let expandedItems = $state(new SvelteSet<number>());
|
||||
|
||||
const faqCategories = [
|
||||
{
|
||||
|
||||
@@ -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 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
export function load() {
|
||||
throw redirect(301, "/play/leaderboard");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export async function load({ locals }) {
|
||||
if (locals.authStatus?.authenticated) {
|
||||
redirect(302, "/me");
|
||||
}
|
||||
return {
|
||||
authStatus: locals.authStatus,
|
||||
};
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
isLoading = true;
|
||||
await login(email, password);
|
||||
goto("/videos", { invalidateAll: true });
|
||||
} catch (err: any) {
|
||||
const raw = err.response?.errors?.[0]?.message ?? err.message;
|
||||
} catch (err) {
|
||||
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
|
||||
const raw = e.response?.errors?.[0]?.message ?? e.message;
|
||||
error = raw === "Invalid credentials" ? $_("auth.login.error_invalid_credentials") : raw;
|
||||
isError = true;
|
||||
} finally {
|
||||
|
||||
@@ -11,15 +11,18 @@
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { calcReadingTime } from "$lib/utils.js";
|
||||
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";
|
||||
|
||||
const timeAgo = new TimeAgo("en");
|
||||
const { data } = $props();
|
||||
|
||||
let searchValue = $state(data.search ?? "");
|
||||
let searchValue = $derived(data.search ?? "");
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const featuredArticle =
|
||||
data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null;
|
||||
const featuredArticle = $derived(
|
||||
data.page === 1 && !data.search && !data.category ? data.items.find((a) => a.featured) : null,
|
||||
);
|
||||
|
||||
function debounceSearch(value: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
@@ -48,6 +51,21 @@
|
||||
}
|
||||
|
||||
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")} />
|
||||
@@ -55,109 +73,80 @@
|
||||
<div
|
||||
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
||||
>
|
||||
<!-- Global Plasma Background -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
|
||||
></div>
|
||||
</div>
|
||||
<SexyBackground />
|
||||
|
||||
<section class="relative py-20 overflow-hidden">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/10 via-accent/5 to-background"
|
||||
></div>
|
||||
<div class="relative container mx-auto px-4 text-center">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold mb-8 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
||||
>
|
||||
{$_("magazine.title")}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl md:text-2xl text-muted-foreground mb-10 leading-relaxed max-w-4xl mx-auto"
|
||||
>
|
||||
{$_("magazine.description")}
|
||||
</p>
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1">
|
||||
<span
|
||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_("magazine.search_placeholder")}
|
||||
value={searchValue}
|
||||
oninput={(e) => {
|
||||
searchValue = (e.target as HTMLInputElement).value;
|
||||
debounceSearch(searchValue);
|
||||
}}
|
||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<Select
|
||||
type="single"
|
||||
value={data.category ?? "all"}
|
||||
onValueChange={(v) => v && setParam("category", v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||
{!data.category
|
||||
? $_("magazine.categories.all")
|
||||
: data.category === "photography"
|
||||
? $_("magazine.categories.photography")
|
||||
: data.category === "production"
|
||||
? $_("magazine.categories.production")
|
||||
: data.category === "interview"
|
||||
? $_("magazine.categories.interview")
|
||||
: data.category === "psychology"
|
||||
? $_("magazine.categories.psychology")
|
||||
: data.category === "trends"
|
||||
? $_("magazine.categories.trends")
|
||||
: $_("magazine.categories.spotlight")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{$_("magazine.categories.all")}</SelectItem>
|
||||
<SelectItem value="photography">{$_("magazine.categories.photography")}</SelectItem>
|
||||
<SelectItem value="production">{$_("magazine.categories.production")}</SelectItem>
|
||||
<SelectItem value="interview">{$_("magazine.categories.interview")}</SelectItem>
|
||||
<SelectItem value="psychology">{$_("magazine.categories.psychology")}</SelectItem>
|
||||
<SelectItem value="trends">{$_("magazine.categories.trends")}</SelectItem>
|
||||
<SelectItem value="spotlight">{$_("magazine.categories.spotlight")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Sort -->
|
||||
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
{data.sort === "featured"
|
||||
? $_("magazine.sort.featured")
|
||||
: data.sort === "name"
|
||||
? $_("magazine.sort.name")
|
||||
: $_("magazine.sort.recent")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
|
||||
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
|
||||
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<PageHero title={$_("magazine.title")} description={$_("magazine.description")}>
|
||||
<div class="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1">
|
||||
<span
|
||||
class="icon-[ri--search-line] absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
||||
></span>
|
||||
<Input
|
||||
placeholder={$_("magazine.search_placeholder")}
|
||||
value={searchValue}
|
||||
oninput={(e) => {
|
||||
searchValue = (e.target as HTMLInputElement).value;
|
||||
debounceSearch(searchValue);
|
||||
}}
|
||||
class="pl-10 bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<Select
|
||||
type="single"
|
||||
value={data.category ?? "all"}
|
||||
onValueChange={(v) => v && setParam("category", v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
<span class="icon-[ri--filter-line] w-4 h-4 mr-2"></span>
|
||||
{!data.category
|
||||
? $_("magazine.categories.all")
|
||||
: data.category === "photography"
|
||||
? $_("magazine.categories.photography")
|
||||
: data.category === "production"
|
||||
? $_("magazine.categories.production")
|
||||
: data.category === "interview"
|
||||
? $_("magazine.categories.interview")
|
||||
: data.category === "psychology"
|
||||
? $_("magazine.categories.psychology")
|
||||
: data.category === "trends"
|
||||
? $_("magazine.categories.trends")
|
||||
: $_("magazine.categories.spotlight")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{$_("magazine.categories.all")}</SelectItem>
|
||||
<SelectItem value="photography">{$_("magazine.categories.photography")}</SelectItem>
|
||||
<SelectItem value="production">{$_("magazine.categories.production")}</SelectItem>
|
||||
<SelectItem value="interview">{$_("magazine.categories.interview")}</SelectItem>
|
||||
<SelectItem value="psychology">{$_("magazine.categories.psychology")}</SelectItem>
|
||||
<SelectItem value="trends">{$_("magazine.categories.trends")}</SelectItem>
|
||||
<SelectItem value="spotlight">{$_("magazine.categories.spotlight")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Sort -->
|
||||
<Select type="single" value={data.sort} onValueChange={(v) => v && setParam("sort", v)}>
|
||||
<SelectTrigger
|
||||
class="w-full md:w-48 bg-background/50 border-primary/20 focus:border-primary"
|
||||
>
|
||||
{data.sort === "featured"
|
||||
? $_("magazine.sort.featured")
|
||||
: data.sort === "name"
|
||||
? $_("magazine.sort.name")
|
||||
: $_("magazine.sort.recent")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="recent">{$_("magazine.sort.recent")}</SelectItem>
|
||||
<SelectItem value="featured">{$_("magazine.sort.featured")}</SelectItem>
|
||||
<SelectItem value="name">{$_("magazine.sort.name")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</section>
|
||||
</PageHero>
|
||||
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<!-- Featured Article -->
|
||||
@@ -187,9 +176,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-2xl md:text-3xl font-bold mb-4 hover:text-primary transition-colors">
|
||||
<button class="text-left">
|
||||
<a href="/article/{featuredArticle.slug}">{featuredArticle.title}</a>
|
||||
</button>
|
||||
<a href="/magazine/{featuredArticle.slug}">{featuredArticle.title}</a>
|
||||
</h2>
|
||||
<p class="text-muted-foreground mb-6 text-lg leading-relaxed">
|
||||
{featuredArticle.excerpt}
|
||||
@@ -229,100 +216,83 @@
|
||||
<!-- Articles Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{#each data.items as article (article.slug)}
|
||||
<Card
|
||||
class="p-0 group hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(article.image, "preview")}
|
||||
alt={article.title}
|
||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div
|
||||
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
|
||||
></div>
|
||||
|
||||
<!-- Category Badge -->
|
||||
<div
|
||||
class="absolute top-3 left-3 bg-primary/90 text-white text-xs px-2 py-1 rounded-full capitalize"
|
||||
>
|
||||
{article.category}
|
||||
</div>
|
||||
|
||||
<!-- Featured Badge -->
|
||||
{#if article.featured}
|
||||
<a href="/magazine/{article.slug}" class="block group">
|
||||
<Card
|
||||
class="p-0 h-full hover:shadow-2xl hover:shadow-primary/25 transition-all duration-300 hover:-translate-y-3 bg-gradient-to-br from-card/90 via-card/95 to-card/85 backdrop-blur-xl shadow-lg shadow-primary/10 overflow-hidden"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={getAssetUrl(article.image, "preview")}
|
||||
alt={article.title}
|
||||
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300 bg-muted"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-3 right-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full"
|
||||
class="absolute group-hover:scale-105 transition-transform inset-0 bg-gradient-to-t from-black/40 to-transparent duration-300"
|
||||
></div>
|
||||
|
||||
<!-- Category Badge -->
|
||||
<div
|
||||
class="absolute top-3 left-3 bg-primary/90 text-white text-xs px-2 py-1 rounded-full capitalize"
|
||||
>
|
||||
{$_("magazine.featured")}
|
||||
{article.category}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Views -->
|
||||
<!-- <div
|
||||
class="absolute bottom-3 right-3 text-white text-sm flex items-center gap-1"
|
||||
>
|
||||
<TrendingUpIcon class="w-4 h-4" />
|
||||
{article.views}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<CardContent class="p-6">
|
||||
<div class="mb-4">
|
||||
<h3
|
||||
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
|
||||
>
|
||||
<a href="/magazine/{article.slug}">{article.title}</a>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm line-clamp-3 leading-relaxed">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{#each (article.tags ?? []).slice(0, 3) as tag (tag)}
|
||||
<a
|
||||
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
|
||||
href="/tags/{tag}"
|
||||
<!-- Featured Badge -->
|
||||
{#if article.featured}
|
||||
<div
|
||||
class="absolute top-3 right-3 bg-gradient-to-r from-primary to-accent text-white text-xs px-2 py-1 rounded-full"
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
{/each}
|
||||
{$_("magazine.featured")}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Author & Meta -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src={getAssetUrl(article.author?.avatar, "mini")}
|
||||
alt={article.author?.artist_name}
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{article.author?.artist_name}</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
{timeAgo.format(new Date(article.publish_date))}
|
||||
<CardContent class="p-6">
|
||||
<div class="mb-4">
|
||||
<h3
|
||||
class="font-semibold text-lg mb-2 group-hover:text-primary transition-colors line-clamp-2"
|
||||
>
|
||||
{article.title}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm line-clamp-3 leading-relaxed">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{#each (article.tags ?? []).slice(0, 3) as tag (tag)}
|
||||
<span class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full">
|
||||
#{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Author & Meta -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src={getAssetUrl(article.author?.avatar, "mini")}
|
||||
alt={article.author?.artist_name}
|
||||
class="w-8 h-8 rounded-full object-cover bg-muted"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{article.author?.artist_name}</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
|
||||
{timeAgo.format(new Date(article.publish_date))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{$_("magazine.read_time", {
|
||||
values: { time: calcReadingTime(article.content) },
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{$_("magazine.read_time", {
|
||||
values: { time: calcReadingTime(article.content) },
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Read More Button -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full mt-4 border-primary/20 hover:bg-primary/10"
|
||||
href="/magazine/{article.slug}">{$_("magazine.read_article")}</Button
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -339,32 +309,40 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-10">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{$_("common.page_of", { values: { page: data.page, total: totalPages } })}
|
||||
·
|
||||
{$_("common.total_results", { values: { total: data.total } })}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<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"
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.previous")}</Button
|
||||
>
|
||||
{$_("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"
|
||||
class="border-primary/20 hover:bg-primary/10">{$_("common.next")}</Button
|
||||
>
|
||||
{$_("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_("common.total_results", { values: { total: data.total } })}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -151,10 +151,19 @@
|
||||
class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-2">About {author.artist_name}</h3>
|
||||
<h3 class="font-semibold text-lg mb-1">About {author.artist_name}</h3>
|
||||
{#if author.description}
|
||||
<p class="text-sm text-muted-foreground mb-3 leading-relaxed">
|
||||
{author.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if author.slug}
|
||||
<a href="/models/{author.slug}" class="text-sm text-primary hover:underline">
|
||||
<a
|
||||
href="/models/{author.slug}"
|
||||
class="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
View profile
|
||||
<span class="icon-[ri--arrow-right-line] w-3.5 h-3.5"></span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
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,659 +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 } 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 { displaySize, 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(data.recordings);
|
||||
let deleteTarget = $state<string | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
let activeTab = $state("settings");
|
||||
|
||||
let firstName = $state(data.authStatus.user!.first_name);
|
||||
let lastName = $state(data.authStatus.user!.last_name);
|
||||
let artistName = $state(data.authStatus.user!.artist_name);
|
||||
let description = $state(data.authStatus.user!.description);
|
||||
let tags = $state(data.authStatus.user!.tags ?? undefined);
|
||||
|
||||
let email = $state(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 = undefined;
|
||||
|
||||
if (!avatar?.id && data.authStatus.user!.avatar) {
|
||||
await removeFile(data.authStatus.user!.avatar);
|
||||
}
|
||||
|
||||
if (avatar?.file) {
|
||||
const formData = new FormData();
|
||||
formData.append("folder", data.folders.find((f) => f.name === "avatars")!.id);
|
||||
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,
|
||||
});
|
||||
toast.success($_("me.settings.toast_update"));
|
||||
invalidateAll();
|
||||
} catch (err: any) {
|
||||
profileError = err.response?.errors?.[0]?.message ?? err.message;
|
||||
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: any) {
|
||||
securityError = err.response?.errors?.[0]?.message ?? err.message;
|
||||
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, "mini")!,
|
||||
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 for="avatar">{$_("me.settings.avatar")}</Label>
|
||||
<FileDropZone
|
||||
id="avatar"
|
||||
fileCount={0}
|
||||
maxFiles={1}
|
||||
maxFileSize={2 * MEGABYTE}
|
||||
onUpload={handleFilesUpload}
|
||||
accept="image/*"
|
||||
/>
|
||||
{#if avatar}
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<div class="relative size-9 overflow-clip">
|
||||
<img
|
||||
src={avatar.url}
|
||||
alt={avatar.name}
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 overflow-clip"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>{avatar.name}</span>
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>{displaySize(avatar.size)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={handleAvatarRemove}
|
||||
class="cursor-pointer"
|
||||
><span class="icon-[ri--delete-bin-line]"></span></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Name Fields -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="firstName">{$_("me.settings.first_name")}</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder={$_("me.settings.first_name_placeholder")}
|
||||
bind:value={firstName}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="lastName">{$_("me.settings.last_name")}</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
placeholder={$_("me.settings.last_name_placeholder")}
|
||||
bind:value={lastName}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
|
||||
<Input
|
||||
id="artistName"
|
||||
placeholder={$_("me.settings.artist_name_placeholder")}
|
||||
bind:value={artistName}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">{$_("me.settings.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder={$_("me.settings.description_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="tags">{$_("me.settings.tags")}</Label>
|
||||
<TagsInput
|
||||
id="tags"
|
||||
bind:value={tags}
|
||||
placeholder={$_("me.settings.tags_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
{#if isProfileError}
|
||||
<div class="grid w-full items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||
"me.settings.error",
|
||||
)}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{profileError}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
type="submit"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
disabled={isProfileLoading}
|
||||
>
|
||||
{#if isProfileLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("me.settings.updating_profile")}
|
||||
{:else}
|
||||
{$_("me.settings.update_profile")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Privacy Settings -->
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>{$_("me.settings.privacy_title")}</CardTitle>
|
||||
<CardDescription>{$_("me.settings.privacy_subtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<form onsubmit={handleSecuritySubmit} class="space-y-4">
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">{$_("me.settings.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={$_("me.settings.email_placeholder")}
|
||||
bind:value={email}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="password">{$_("me.settings.password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={$_("me.settings.password_placeholder")}
|
||||
bind:value={password}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showPassword}
|
||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||
{:else}
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="confirmPassword">{$_("me.settings.confirm_password")}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder={$_("me.settings.confirm_password_placeholder")}
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
class="cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showConfirmPassword}
|
||||
<span class="icon-[ri--eye-off-line] w-4 h-4"></span>
|
||||
{:else}
|
||||
<span class="icon-[ri--eye-line] w-4 h-4"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if isSecurityError}
|
||||
<div class="grid w-full items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex"
|
||||
><span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>{$_(
|
||||
"me.settings.error",
|
||||
)}</Alert.Title
|
||||
>
|
||||
<Alert.Description>{securityError}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
type="submit"
|
||||
class="cursor-pointer w-full border-primary/20 hover:bg-primary/10"
|
||||
disabled={isSecurityLoading}
|
||||
>
|
||||
{#if isSecurityLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("me.settings.updating_security")}
|
||||
{:else}
|
||||
{$_("me.settings.update_security")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Recordings Tab -->
|
||||
<TabsContent value="recordings" class="space-y-6">
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-card-foreground">
|
||||
{$_("me.recordings.title")}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{$_("me.recordings.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
href="/play"
|
||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
|
||||
{$_("me.recordings.go_to_play")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if recordings.length === 0}
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardContent class="py-12">
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
<div class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30">
|
||||
<span class="icon-[ri--play-list-2-line] w-12 h-12 text-muted-foreground"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">
|
||||
{$_("me.recordings.no_recordings")}
|
||||
</h3>
|
||||
<p class="text-muted-foreground mb-6 max-w-md">
|
||||
{$_("me.recordings.no_recordings_description")}
|
||||
</p>
|
||||
<Button
|
||||
href="/play"
|
||||
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
|
||||
{$_("me.recordings.go_to_play")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each recordings as recording (recording.id)}
|
||||
<RecordingCard
|
||||
{recording}
|
||||
onPlay={handlePlayRecording}
|
||||
onDelete={handleDeleteRecording}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</TabsContent>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
{#if data.analytics}
|
||||
<TabsContent value="analytics" class="space-y-6">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-card-foreground">Analytics Dashboard</h2>
|
||||
<p class="text-muted-foreground">
|
||||
Track your content performance and audience engagement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
|
||||
Total Videos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
|
||||
Total Likes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
|
||||
Total Plays
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Video Performance Table -->
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Video Performance</CardTitle>
|
||||
<CardDescription>Detailed metrics for each video</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left p-3">Title</th>
|
||||
<th class="text-right p-3">Likes</th>
|
||||
<th class="text-right p-3">Plays</th>
|
||||
<th class="text-right p-3">Completion Rate</th>
|
||||
<th class="text-right p-3">Avg Watch Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.analytics.videos as video (video.slug)}
|
||||
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
|
||||
<td class="p-3">
|
||||
<a
|
||||
href="/videos/{video.slug}"
|
||||
class="hover:text-primary transition-colors"
|
||||
>
|
||||
{video.title}
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-right p-3 font-medium">
|
||||
{video.likes}
|
||||
</td>
|
||||
<td class="text-right p-3 font-medium">
|
||||
{video.plays}
|
||||
</td>
|
||||
<td class="text-right p-3">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >=
|
||||
70
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: video.completion_rate >= 40
|
||||
? 'bg-yellow-500/20 text-yellow-500'
|
||||
: 'bg-red-500/20 text-red-500'}"
|
||||
>
|
||||
{video.completion_rate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right p-3 text-muted-foreground">
|
||||
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{/if}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={deleteOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$_("me.recordings.delete_confirm")}</Dialog.Title>
|
||||
<Dialog.Description>This cannot be undone.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||
<Button variant="destructive" disabled={deleting} onclick={confirmDeleteRecording}>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
12
packages/frontend/src/routes/me/analytics/+page.server.ts
Normal file
12
packages/frontend/src/routes/me/analytics/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { isModel } from "$lib/api";
|
||||
import { getAnalytics } from "$lib/services";
|
||||
|
||||
export async function load({ locals, fetch }) {
|
||||
if (!isModel(locals.authStatus.user!)) {
|
||||
throw redirect(302, "/me/profile");
|
||||
}
|
||||
return {
|
||||
analytics: await getAnalytics(fetch).catch(() => null),
|
||||
};
|
||||
}
|
||||
138
packages/frontend/src/routes/me/analytics/+page.svelte
Normal file
138
packages/frontend/src/routes/me/analytics/+page.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
</script>
|
||||
|
||||
<Meta title={$_("me.analytics.title")} />
|
||||
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{$_("me.analytics.title")}</h1>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">{$_("me.analytics.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if data.analytics}
|
||||
<!-- Overview Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--video-line] w-5 h-5 text-primary"></span>
|
||||
{$_("me.analytics.total_videos")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_videos}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--heart-fill] w-5 h-5 text-primary"></span>
|
||||
{$_("me.analytics.total_likes")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_likes.toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<span class="icon-[ri--play-fill] w-5 h-5 text-primary"></span>
|
||||
{$_("me.analytics.total_plays")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-3xl font-bold">{data.analytics.total_plays.toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Video Performance Table -->
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>{$_("me.analytics.video_performance")}</CardTitle>
|
||||
<CardDescription>{$_("me.analytics.video_performance_description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left p-3">Title</th>
|
||||
<th class="text-right p-3">Likes</th>
|
||||
<th class="text-right p-3">Plays</th>
|
||||
<th class="text-right p-3">Completion Rate</th>
|
||||
<th class="text-right p-3">Avg Watch Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.analytics.videos as video (video.slug)}
|
||||
<tr class="border-b border-border/50 hover:bg-primary/5 transition-colors">
|
||||
<td class="p-3">
|
||||
<a href="/videos/{video.slug}" class="hover:text-primary transition-colors">
|
||||
{video.title}
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-right p-3 font-medium">
|
||||
{video.likes}
|
||||
</td>
|
||||
<td class="text-right p-3 font-medium">
|
||||
{video.plays}
|
||||
</td>
|
||||
<td class="text-right p-3">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs {video.completion_rate >=
|
||||
70
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: video.completion_rate >= 40
|
||||
? 'bg-yellow-500/20 text-yellow-500'
|
||||
: 'bg-red-500/20 text-red-500'}"
|
||||
>
|
||||
{video.completion_rate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right p-3 text-muted-foreground">
|
||||
{Math.floor(video.avg_watch_time / 60)}:{(video.avg_watch_time % 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="bg-card/50 border-primary/20">
|
||||
<CardContent class="py-12">
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
<div class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30">
|
||||
<span class="icon-[ri--line-chart-line] w-12 h-12 text-muted-foreground"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">No analytics available</h3>
|
||||
<p class="text-muted-foreground max-w-md">
|
||||
Analytics data will appear here once your content starts getting views.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
284
packages/frontend/src/routes/me/profile/+page.svelte
Normal file
284
packages/frontend/src/routes/me/profile/+page.svelte
Normal file
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { untrack } from "svelte";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { updateProfile, uploadFile, removeFile } from "$lib/services";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||
import { FileDropZone, MEGABYTE } from "$lib/components/ui/file-drop-zone";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import Meta from "$lib/components/meta/meta.svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
let firstName = $state(untrack(() => data.authStatus.user!.first_name));
|
||||
let lastName = $state(untrack(() => data.authStatus.user!.last_name));
|
||||
let artistName = $state(untrack(() => data.authStatus.user!.artist_name));
|
||||
let description = $state(untrack(() => data.authStatus.user!.description));
|
||||
let tags = $state(untrack(() => data.authStatus.user!.tags ?? undefined));
|
||||
|
||||
$effect(() => {
|
||||
firstName = data.authStatus.user!.first_name;
|
||||
lastName = data.authStatus.user!.last_name;
|
||||
artistName = data.authStatus.user!.artist_name;
|
||||
description = data.authStatus.user!.description;
|
||||
tags = data.authStatus.user!.tags ?? undefined;
|
||||
});
|
||||
|
||||
let isProfileLoading = $state(false);
|
||||
let isProfileError = $state(false);
|
||||
let profileError = $state("");
|
||||
|
||||
let avatar = $state<{
|
||||
id?: string;
|
||||
url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
file?: File;
|
||||
}>();
|
||||
|
||||
function setExistingAvatar() {
|
||||
if (data.authStatus.user!.avatar) {
|
||||
avatar = {
|
||||
id: data.authStatus.user!.avatar,
|
||||
url: getAssetUrl(data.authStatus.user!.avatar, "thumbnail")!,
|
||||
name: data.authStatus.user!.artist_name ?? "",
|
||||
size: 0,
|
||||
};
|
||||
} else {
|
||||
avatar = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setExistingAvatar();
|
||||
});
|
||||
|
||||
async function handleFilesUpload(files: File[]) {
|
||||
const file = files[0];
|
||||
avatar = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
url: URL.createObjectURL(file),
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleAvatarRemove() {
|
||||
if (avatar!.id) {
|
||||
avatar = undefined;
|
||||
} else {
|
||||
setExistingAvatar();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
isProfileLoading = true;
|
||||
isProfileError = false;
|
||||
profileError = "";
|
||||
|
||||
let avatarId: string | null | undefined = undefined;
|
||||
|
||||
if (!avatar?.id && data.authStatus.user!.avatar) {
|
||||
await removeFile(data.authStatus.user!.avatar);
|
||||
avatarId = null;
|
||||
} else if (avatar?.id) {
|
||||
avatarId = avatar.id;
|
||||
}
|
||||
|
||||
if (avatar?.file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", avatar.file);
|
||||
const result = await uploadFile(formData);
|
||||
avatarId = result.id;
|
||||
}
|
||||
|
||||
await updateProfile({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
artist_name: artistName,
|
||||
description,
|
||||
tags,
|
||||
avatar: avatarId ?? undefined,
|
||||
});
|
||||
toast.success($_("me.settings.toast_update"));
|
||||
invalidateAll();
|
||||
} catch (err) {
|
||||
const e = err as { response?: { errors?: Array<{ message: string }> }; message?: string };
|
||||
profileError = e.response?.errors?.[0]?.message ?? e.message ?? "Unknown error";
|
||||
isProfileError = true;
|
||||
} finally {
|
||||
isProfileLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Meta title={$_("me.settings.profile_title")} />
|
||||
|
||||
<div class="py-3 sm:py-6 lg:pl-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{$_("me.settings.profile_title")}</h1>
|
||||
</div>
|
||||
|
||||
<Card class="bg-card/50 border-primary/20 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{$_("me.settings.profile_title")}</CardTitle>
|
||||
<CardDescription>{$_("me.settings.profile_subtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<form onsubmit={handleProfileSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{$_("me.settings.avatar")}</Label>
|
||||
<div class="flex items-center gap-5">
|
||||
<FileDropZone
|
||||
id="avatar"
|
||||
fileCount={0}
|
||||
maxFiles={1}
|
||||
maxFileSize={2 * MEGABYTE}
|
||||
onUpload={handleFilesUpload}
|
||||
accept="image/*"
|
||||
class="h-auto w-auto shrink-0 border-none p-0 rounded-full hover:bg-transparent"
|
||||
>
|
||||
<div class="relative group cursor-pointer w-24 h-24">
|
||||
{#if avatar}
|
||||
<img
|
||||
src={avatar.url}
|
||||
alt={avatar.name}
|
||||
class="w-24 h-24 rounded-full object-cover ring-4 ring-primary/20 group-hover:ring-primary/50 transition-all"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<span class="icon-[ri--camera-line] w-7 h-7 text-white"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="w-24 h-24 rounded-full border-2 border-dashed border-primary/30 group-hover:border-primary/60 bg-primary/5 group-hover:bg-primary/10 transition-all flex flex-col items-center justify-center gap-1"
|
||||
>
|
||||
<span
|
||||
class="icon-[ri--camera-line] w-7 h-7 text-primary/50 group-hover:text-primary/80 transition-colors"
|
||||
></span>
|
||||
<span class="text-xs text-muted-foreground">Upload</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</FileDropZone>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-sm text-muted-foreground">JPG, PNG · max 2 MB</p>
|
||||
<p class="text-xs text-muted-foreground/70">
|
||||
Click or drop to {avatar ? "change" : "upload"}
|
||||
</p>
|
||||
{#if avatar}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={handleAvatarRemove}
|
||||
class="cursor-pointer w-fit mt-1 px-2 h-7 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<span class="icon-[ri--delete-bin-line] w-3.5 h-3.5 mr-1"></span>
|
||||
Remove
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="firstName">{$_("me.settings.first_name")}</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder={$_("me.settings.first_name_placeholder")}
|
||||
bind:value={firstName}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="lastName">{$_("me.settings.last_name")}</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
placeholder={$_("me.settings.last_name_placeholder")}
|
||||
bind:value={lastName}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="artistName">{$_("me.settings.artist_name")}</Label>
|
||||
<Input
|
||||
id="artistName"
|
||||
placeholder={$_("me.settings.artist_name_placeholder")}
|
||||
bind:value={artistName}
|
||||
required
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="description">{$_("me.settings.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder={$_("me.settings.description_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="tags">{$_("me.settings.tags")}</Label>
|
||||
<TagsInput
|
||||
id="tags"
|
||||
bind:value={tags}
|
||||
placeholder={$_("me.settings.tags_placeholder")}
|
||||
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isProfileError}
|
||||
<div class="grid w-full items-start gap-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Title class="items-center flex">
|
||||
<span class="icon-[ri--alert-line] inline-block w-4 h-4 mr-1"></span>
|
||||
{$_("me.settings.error")}
|
||||
</Alert.Title>
|
||||
<Alert.Description>{profileError}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
disabled={isProfileLoading}
|
||||
>
|
||||
{#if isProfileLoading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
{$_("me.settings.updating_profile")}
|
||||
{:else}
|
||||
{$_("me.settings.update_profile")}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export function load() {
|
||||
throw redirect(301, "/play/recordings");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user