Files
sexy/packages/frontend/src/lib/services.ts

1779 lines
40 KiB
TypeScript
Raw Normal View History

import { gql, GraphQLClient } from "graphql-request";
import { apiUrl, getGraphQLClient } from "$lib/api";
import type {
Analytics,
Article,
CurrentUser,
Model,
Recording,
Stats,
User,
Video,
VideoLikeStatus,
VideoLikeResponse,
VideoPlayResponse,
} from "$lib/types";
2025-10-26 14:48:30 +01:00
import { logger } from "$lib/logger";
// Helper to log API calls
async function loggedApiCall<T>(
operationName: string,
operation: () => Promise<T>,
context?: Record<string, unknown>,
2025-10-26 14:48:30 +01:00
): Promise<T> {
const startTime = Date.now();
try {
logger.debug(`🔄 API: ${operationName}`, { context });
const result = await operation();
const duration = Date.now() - startTime;
logger.info(`✅ API: ${operationName} succeeded`, { duration, context });
return result;
} catch (error) {
const duration = Date.now() - startTime;
logger.error(`❌ API: ${operationName} failed`, {
duration,
context,
error: error instanceof Error ? error : new Error(String(error)),
});
throw error;
}
2025-10-26 14:48:30 +01:00
}
2025-10-25 22:04:41 +02:00
// For server-side auth checks: forward cookie header manually
function getAuthClient(token: string, fetchFn?: typeof globalThis.fetch) {
return new GraphQLClient(`${apiUrl}/graphql`, {
fetch: fetchFn || globalThis.fetch,
headers: { cookie: `session_token=${token}` },
});
}
// ─── Auth ────────────────────────────────────────────────────────────────────
2025-10-25 22:04:41 +02:00
const ME_QUERY = gql`
query Me {
me {
id
email
first_name
last_name
artist_name
slug
description
tags
role
is_admin
avatar
banner
email_verified
date_created
}
}
`;
export async function isAuthenticated(token: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"isAuthenticated",
async () => {
try {
const client = getAuthClient(token, fetchFn);
const data = await client.request<{ me: CurrentUser | null }>(ME_QUERY);
if (data.me) {
return { authenticated: true, user: data.me };
}
return { authenticated: false };
} catch {
return { authenticated: false };
}
},
{ hasToken: !!token },
);
2025-10-25 22:04:41 +02:00
}
const LOGIN_MUTATION = gql`
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
id
email
first_name
last_name
artist_name
slug
description
tags
role
avatar
banner
email_verified
date_created
}
}
`;
export async function login(email: string, password: string) {
return loggedApiCall(
"login",
async () => {
const data = await getGraphQLClient().request<{ login: CurrentUser }>(LOGIN_MUTATION, {
email,
password,
});
return data.login;
},
{ email },
);
}
const LOGOUT_MUTATION = gql`
mutation Logout {
logout
}
`;
export async function logout() {
return loggedApiCall("logout", async () => {
await getGraphQLClient().request(LOGOUT_MUTATION);
});
}
const REGISTER_MUTATION = gql`
mutation Register($email: String!, $password: String!, $firstName: String!, $lastName: String!) {
register(email: $email, password: $password, firstName: $firstName, lastName: $lastName)
}
`;
2025-10-25 22:04:41 +02:00
export async function register(
email: string,
password: string,
firstName: string,
lastName: string,
2025-10-25 22:04:41 +02:00
) {
return loggedApiCall(
"register",
async () => {
await getGraphQLClient().request(REGISTER_MUTATION, {
email,
password,
firstName,
lastName,
});
},
{ email, firstName, lastName },
);
2025-10-25 22:04:41 +02:00
}
const VERIFY_EMAIL_MUTATION = gql`
mutation VerifyEmail($token: String!) {
verifyEmail(token: $token)
}
`;
export async function verify(token: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"verify",
async () => {
await getGraphQLClient(fetchFn).request(VERIFY_EMAIL_MUTATION, { token });
},
{ hasToken: !!token },
);
2025-10-25 22:04:41 +02:00
}
const REQUEST_PASSWORD_MUTATION = gql`
mutation RequestPasswordReset($email: String!) {
requestPasswordReset(email: $email)
}
`;
2025-10-25 22:04:41 +02:00
export async function requestPassword(email: string) {
return loggedApiCall(
"requestPassword",
async () => {
await getGraphQLClient().request(REQUEST_PASSWORD_MUTATION, { email });
},
{ email },
);
2025-10-25 22:04:41 +02:00
}
const RESET_PASSWORD_MUTATION = gql`
mutation ResetPassword($token: String!, $newPassword: String!) {
resetPassword(token: $token, newPassword: $newPassword)
}
`;
2025-10-25 22:04:41 +02:00
export async function resetPassword(token: string, password: string) {
return loggedApiCall(
"resetPassword",
async () => {
await getGraphQLClient().request(RESET_PASSWORD_MUTATION, {
token,
newPassword: password,
});
},
{ hasToken: !!token },
);
2025-10-25 22:04:41 +02:00
}
// ─── Articles ────────────────────────────────────────────────────────────────
const ARTICLES_QUERY = gql`
query GetArticles(
$search: String
$category: String
$sortBy: String
$offset: Int
$limit: Int
$featured: Boolean
$tag: String
) {
articles(
search: $search
category: $category
sortBy: $sortBy
offset: $offset
limit: $limit
featured: $featured
tag: $tag
) {
items {
id
slug
title
excerpt
content
image
tags
publish_date
category
featured
author {
id
artist_name
slug
avatar
}
}
total
}
}
`;
export async function getArticles(
params: {
search?: string;
category?: string;
sortBy?: string;
offset?: number;
limit?: number;
featured?: boolean;
tag?: string;
} = {},
fetchFn?: typeof globalThis.fetch,
): Promise<{ items: Article[]; total: number }> {
return loggedApiCall("getArticles", async () => {
const data = await getGraphQLClient(fetchFn).request<{
articles: { items: Article[]; total: number };
}>(ARTICLES_QUERY, params);
return data.articles;
});
2025-10-25 22:04:41 +02:00
}
const ARTICLE_BY_SLUG_QUERY = gql`
query GetArticleBySlug($slug: String!) {
article(slug: $slug) {
id
slug
title
excerpt
content
image
tags
publish_date
category
featured
author {
id
artist_name
slug
avatar
}
}
}
`;
export async function getArticleBySlug(slug: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getArticleBySlug",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ article: Article | null }>(
ARTICLE_BY_SLUG_QUERY,
{ slug },
);
if (!data.article) throw new Error("Article not found");
return data.article;
},
{ slug },
);
2025-10-25 22:04:41 +02:00
}
// ─── Videos ──────────────────────────────────────────────────────────────────
const VIDEOS_QUERY = gql`
query GetVideos(
$modelId: String
$featured: Boolean
$limit: Int
$search: String
$offset: Int
$sortBy: String
$duration: String
$tag: String
) {
videos(
modelId: $modelId
featured: $featured
limit: $limit
search: $search
offset: $offset
sortBy: $sortBy
duration: $duration
tag: $tag
) {
items {
id
slug
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
}
}
total
}
}
`;
export async function getVideos(
params: {
search?: string;
sortBy?: string;
duration?: string;
offset?: number;
limit?: number;
tag?: string;
} = {},
fetchFn?: typeof globalThis.fetch,
): Promise<{ items: Video[]; total: number }> {
return loggedApiCall("getVideos", async () => {
const data = await getGraphQLClient(fetchFn).request<{
videos: { items: Video[]; total: number };
}>(VIDEOS_QUERY, params);
return data.videos;
});
2025-10-25 22:04:41 +02:00
}
export async function getVideosForModel(id: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getVideosForModel",
async () => {
const data = await getGraphQLClient(fetchFn).request<{
videos: { items: Video[]; total: number };
}>(VIDEOS_QUERY, { modelId: id, limit: 10000 });
return data.videos.items;
},
{ modelId: id },
);
2025-10-25 22:04:41 +02:00
}
export async function getFeaturedVideos(
limit: number,
fetchFn: typeof globalThis.fetch = globalThis.fetch,
2025-10-25 22:04:41 +02:00
) {
return loggedApiCall(
"getFeaturedVideos",
async () => {
const data = await getGraphQLClient(fetchFn).request<{
videos: { items: Video[]; total: number };
}>(VIDEOS_QUERY, { featured: true, limit });
return data.videos.items;
},
{ limit },
);
2025-10-25 22:04:41 +02:00
}
const VIDEO_BY_SLUG_QUERY = gql`
query GetVideoBySlug($slug: String!) {
video(slug: $slug) {
id
slug
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
}
}
}
`;
export async function getVideoBySlug(slug: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getVideoBySlug",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ video: Video | null }>(
VIDEO_BY_SLUG_QUERY,
{ slug },
);
if (!data.video) throw new Error("Video not found");
return data.video;
},
{ slug },
);
2025-10-25 22:04:41 +02:00
}
// ─── Models ──────────────────────────────────────────────────────────────────
const MODELS_QUERY = gql`
query GetModels(
$featured: Boolean
$limit: Int
$search: String
$offset: Int
$sortBy: String
$tag: String
) {
models(
featured: $featured
limit: $limit
search: $search
offset: $offset
sortBy: $sortBy
tag: $tag
) {
items {
id
slug
artist_name
description
avatar
banner
tags
date_created
photos {
id
filename
}
}
total
}
}
`;
export async function getModels(
params: { search?: string; sortBy?: string; offset?: number; limit?: number; tag?: string } = {},
fetchFn?: typeof globalThis.fetch,
): Promise<{ items: Model[]; total: number }> {
return loggedApiCall("getModels", async () => {
const data = await getGraphQLClient(fetchFn).request<{
models: { items: Model[]; total: number };
}>(MODELS_QUERY, params);
return data.models;
});
2025-10-25 22:04:41 +02:00
}
export async function getFeaturedModels(
limit = 3,
fetchFn: typeof globalThis.fetch = globalThis.fetch,
2025-10-25 22:04:41 +02:00
) {
return loggedApiCall(
"getFeaturedModels",
async () => {
const data = await getGraphQLClient(fetchFn).request<{
models: { items: Model[]; total: number };
}>(MODELS_QUERY, { featured: true, limit });
return data.models.items;
},
{ limit },
);
2025-10-25 22:04:41 +02:00
}
const MODEL_BY_SLUG_QUERY = gql`
query GetModelBySlug($slug: String!) {
model(slug: $slug) {
id
slug
artist_name
description
avatar
banner
tags
date_created
photos {
id
filename
}
}
}
`;
export async function getModelBySlug(slug: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getModelBySlug",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ model: Model | null }>(
MODEL_BY_SLUG_QUERY,
{ slug },
);
if (!data.model) throw new Error("Model not found");
return data.model;
},
{ slug },
);
2025-10-25 22:04:41 +02:00
}
// ─── Profile ─────────────────────────────────────────────────────────────────
const UPDATE_PROFILE_MUTATION = gql`
mutation UpdateProfile(
$firstName: String
$lastName: String
$artistName: String
$description: String
$tags: [String!]
) {
updateProfile(
firstName: $firstName
lastName: $lastName
artistName: $artistName
description: $description
tags: $tags
) {
id
email
first_name
last_name
artist_name
slug
description
tags
role
avatar
banner
date_created
}
}
`;
export async function updateProfile(user: Partial<User> & { password?: string }) {
return loggedApiCall(
"updateProfile",
async () => {
const data = await getGraphQLClient().request<{ updateProfile: User }>(
UPDATE_PROFILE_MUTATION,
{
firstName: user.first_name,
lastName: user.last_name,
artistName: user.artist_name,
description: user.description,
tags: user.tags,
},
);
return data.updateProfile;
},
{ userId: user.id },
);
2025-10-25 22:04:41 +02:00
}
// ─── Stats ───────────────────────────────────────────────────────────────────
const STATS_QUERY = gql`
query GetStats {
stats {
videos_count
models_count
viewers_count
}
}
`;
export async function getStats(fetchFn?: typeof globalThis.fetch) {
return loggedApiCall("getStats", async () => {
const data = await getGraphQLClient(fetchFn).request<{ stats: Stats }>(STATS_QUERY);
return data.stats;
});
2025-10-25 22:04:41 +02:00
}
// Stub — Directus folder concept dropped
export async function getFolders(_fetchFn?: typeof globalThis.fetch) {
return loggedApiCall("getFolders", async () => [] as { id: string; name: string }[]);
2025-10-25 22:04:41 +02:00
}
// ─── Files ───────────────────────────────────────────────────────────────────
2025-10-25 22:04:41 +02:00
export async function removeFile(id: string) {
return loggedApiCall(
"removeFile",
async () => {
// File deletion via REST DELETE /assets/:id (backend handles it)
const response = await fetch(`${apiUrl}/assets/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!response.ok) throw new Error(`Failed to delete file: ${response.statusText}`);
},
{ fileId: id },
);
2025-10-25 22:04:41 +02:00
}
export async function uploadFile(data: FormData) {
return loggedApiCall("uploadFile", async () => {
const response = await fetch(`${apiUrl}/upload`, {
method: "POST",
body: data,
credentials: "include",
});
if (!response.ok) throw new Error(`Upload failed: ${response.statusText}`);
return response.json();
});
2025-10-25 22:04:41 +02:00
}
// ─── Comments ────────────────────────────────────────────────────────────────
const COMMENTS_FOR_VIDEO_QUERY = gql`
query CommentsForVideo($videoId: String!) {
commentsForVideo(videoId: $videoId) {
id
comment
item_id
user_id
date_created
user {
id
first_name
last_name
avatar
}
}
}
`;
export async function getCommentsForVideo(item: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getCommentsForVideo",
async () => {
const data = await getGraphQLClient(fetchFn).request<{
commentsForVideo: {
id: number;
comment: string;
item_id: string;
user_id: string;
date_created: string;
user: {
id: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
avatar: string | null;
} | null;
}[];
}>(COMMENTS_FOR_VIDEO_QUERY, { videoId: item });
return data.commentsForVideo;
},
{ videoId: item },
);
2025-10-25 22:04:41 +02:00
}
const CREATE_COMMENT_MUTATION = gql`
mutation CreateCommentForVideo($videoId: String!, $comment: String!) {
createCommentForVideo(videoId: $videoId, comment: $comment) {
id
comment
item_id
user_id
date_created
}
}
`;
export async function createCommentForVideo(item: string, comment: string) {
return loggedApiCall(
"createCommentForVideo",
async () => {
const data = await getGraphQLClient().request(CREATE_COMMENT_MUTATION, {
videoId: item,
comment,
});
return data;
},
{ videoId: item, commentLength: comment.length },
);
2025-10-25 22:04:41 +02:00
}
export async function countCommentsForModel(
_user_created: string,
_fetchFn?: typeof globalThis.fetch,
2025-10-25 22:04:41 +02:00
) {
// Not directly available in new API, return 0
return 0;
2025-10-25 22:04:41 +02:00
}
// ─── Tags ────────────────────────────────────────────────────────────────────
2025-10-25 22:04:41 +02:00
export async function getItemsByTag(
category: "video" | "article" | "model",
tag: string,
fetchFn?: typeof globalThis.fetch,
2025-10-25 22:04:41 +02:00
) {
return loggedApiCall(
"getItemsByTag",
async () => {
switch (category) {
case "video":
return getVideos({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
case "model":
return getModels({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
case "article":
return getArticles({ tag, limit: 1000 }, fetchFn).then((r) => r.items);
}
},
{ category, tag },
);
2025-10-25 22:04:41 +02:00
}
feat: add buttplug device recording feature (Phase 1 & 2) Implemented complete infrastructure for recording, saving, and managing buttplug device patterns with precise event timing. **Phase 1: Backend & Infrastructure** - Added Directus schema for sexy_recordings collection with all fields (id, status, user_created, title, description, slug, duration, events, device_info, tags, linked_video, featured, public) - Created REST API endpoints in bundle extension: * GET /sexy/recordings - list user recordings with filtering * GET /sexy/recordings/:id - get single recording * POST /sexy/recordings - create new recording with validation * PATCH /sexy/recordings/:id - update recording (owner only) * DELETE /sexy/recordings/:id - soft delete by archiving - Added TypeScript types: RecordedEvent, DeviceInfo, Recording - Created frontend services: getRecordings(), deleteRecording() - Built RecordingCard component with stats, device info, and actions - Added Recordings tab to /me dashboard page with grid layout - Added i18n translations for recordings UI **Phase 2: Recording Capture** - Implemented recording state management in /play page - Added Start/Stop Recording buttons with visual indicators - Capture device events with precise timestamps during recording - Normalize actuator values (0-100) for cross-device compatibility - Created RecordingSaveDialog component with: * Recording stats display (duration, events, devices) * Form inputs (title, description, tags) * Device information preview - Integrated save recording API call from play page - Added success/error toast notifications - Automatic event filtering during recording **Technical Details** - Events stored as JSON array with timestamp, deviceIndex, deviceName, actuatorIndex, actuatorType, and normalized value - Device metadata includes name, index, and capability list - Slug auto-generated from title for SEO-friendly URLs - Status workflow: draft → published → archived - Permission checks: users can only access own recordings or public ones - Frontend uses performance.now() for millisecond precision timing **User Flow** 1. User scans and connects devices on /play page 2. Clicks "Start Recording" to begin capturing events 3. Manipulates device sliders - all changes are recorded 4. Clicks "Stop Recording" to end capture 5. Save dialog appears with recording preview and form 6. User enters title, description, tags and saves 7. Recording appears in dashboard /me Recordings tab 8. Can play back, edit, or delete recordings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
// ─── Recordings ──────────────────────────────────────────────────────────────
const RECORDINGS_QUERY = gql`
query GetRecordings(
$status: String
$tags: String
$linkedVideoId: String
$limit: Int
$page: Int
) {
recordings(
status: $status
tags: $tags
linkedVideoId: $linkedVideoId
limit: $limit
page: $page
) {
id
title
description
slug
duration
events
device_info
user_id
status
tags
linked_video
featured
public
date_created
date_updated
}
}
`;
export async function getRecordings(fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getRecordings",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ recordings: Recording[] }>(
RECORDINGS_QUERY,
);
return data.recordings;
},
{},
);
feat: add buttplug device recording feature (Phase 1 & 2) Implemented complete infrastructure for recording, saving, and managing buttplug device patterns with precise event timing. **Phase 1: Backend & Infrastructure** - Added Directus schema for sexy_recordings collection with all fields (id, status, user_created, title, description, slug, duration, events, device_info, tags, linked_video, featured, public) - Created REST API endpoints in bundle extension: * GET /sexy/recordings - list user recordings with filtering * GET /sexy/recordings/:id - get single recording * POST /sexy/recordings - create new recording with validation * PATCH /sexy/recordings/:id - update recording (owner only) * DELETE /sexy/recordings/:id - soft delete by archiving - Added TypeScript types: RecordedEvent, DeviceInfo, Recording - Created frontend services: getRecordings(), deleteRecording() - Built RecordingCard component with stats, device info, and actions - Added Recordings tab to /me dashboard page with grid layout - Added i18n translations for recordings UI **Phase 2: Recording Capture** - Implemented recording state management in /play page - Added Start/Stop Recording buttons with visual indicators - Capture device events with precise timestamps during recording - Normalize actuator values (0-100) for cross-device compatibility - Created RecordingSaveDialog component with: * Recording stats display (duration, events, devices) * Form inputs (title, description, tags) * Device information preview - Integrated save recording API call from play page - Added success/error toast notifications - Automatic event filtering during recording **Technical Details** - Events stored as JSON array with timestamp, deviceIndex, deviceName, actuatorIndex, actuatorType, and normalized value - Device metadata includes name, index, and capability list - Slug auto-generated from title for SEO-friendly URLs - Status workflow: draft → published → archived - Permission checks: users can only access own recordings or public ones - Frontend uses performance.now() for millisecond precision timing **User Flow** 1. User scans and connects devices on /play page 2. Clicks "Start Recording" to begin capturing events 3. Manipulates device sliders - all changes are recorded 4. Clicks "Stop Recording" to end capture 5. Save dialog appears with recording preview and form 6. User enters title, description, tags and saves 7. Recording appears in dashboard /me Recordings tab 8. Can play back, edit, or delete recordings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
}
const CREATE_RECORDING_MUTATION = gql`
mutation CreateRecording(
$title: String!
$description: String
$duration: Int!
$events: JSON!
$deviceInfo: JSON!
$tags: [String!]
$status: String
$linkedVideoId: String
) {
createRecording(
title: $title
description: $description
duration: $duration
events: $events
deviceInfo: $deviceInfo
tags: $tags
status: $status
linkedVideoId: $linkedVideoId
) {
id
title
description
slug
duration
events
device_info
user_id
status
tags
linked_video
featured
public
date_created
date_updated
}
}
`;
export async function createRecording(
recording: {
title: string;
description?: string;
duration: number;
events: unknown[];
device_info: unknown[];
tags?: string[];
status?: string;
},
fetchFn?: typeof globalThis.fetch,
) {
return loggedApiCall(
"createRecording",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ createRecording: Recording }>(
CREATE_RECORDING_MUTATION,
{
title: recording.title,
description: recording.description,
duration: recording.duration,
events: recording.events,
deviceInfo: recording.device_info,
tags: recording.tags,
status: recording.status,
},
);
return data.createRecording;
},
{ title: recording.title, eventCount: recording.events.length },
);
}
const DELETE_RECORDING_MUTATION = gql`
mutation DeleteRecording($id: String!) {
deleteRecording(id: $id)
}
`;
feat: add buttplug device recording feature (Phase 1 & 2) Implemented complete infrastructure for recording, saving, and managing buttplug device patterns with precise event timing. **Phase 1: Backend & Infrastructure** - Added Directus schema for sexy_recordings collection with all fields (id, status, user_created, title, description, slug, duration, events, device_info, tags, linked_video, featured, public) - Created REST API endpoints in bundle extension: * GET /sexy/recordings - list user recordings with filtering * GET /sexy/recordings/:id - get single recording * POST /sexy/recordings - create new recording with validation * PATCH /sexy/recordings/:id - update recording (owner only) * DELETE /sexy/recordings/:id - soft delete by archiving - Added TypeScript types: RecordedEvent, DeviceInfo, Recording - Created frontend services: getRecordings(), deleteRecording() - Built RecordingCard component with stats, device info, and actions - Added Recordings tab to /me dashboard page with grid layout - Added i18n translations for recordings UI **Phase 2: Recording Capture** - Implemented recording state management in /play page - Added Start/Stop Recording buttons with visual indicators - Capture device events with precise timestamps during recording - Normalize actuator values (0-100) for cross-device compatibility - Created RecordingSaveDialog component with: * Recording stats display (duration, events, devices) * Form inputs (title, description, tags) * Device information preview - Integrated save recording API call from play page - Added success/error toast notifications - Automatic event filtering during recording **Technical Details** - Events stored as JSON array with timestamp, deviceIndex, deviceName, actuatorIndex, actuatorType, and normalized value - Device metadata includes name, index, and capability list - Slug auto-generated from title for SEO-friendly URLs - Status workflow: draft → published → archived - Permission checks: users can only access own recordings or public ones - Frontend uses performance.now() for millisecond precision timing **User Flow** 1. User scans and connects devices on /play page 2. Clicks "Start Recording" to begin capturing events 3. Manipulates device sliders - all changes are recorded 4. Clicks "Stop Recording" to end capture 5. Save dialog appears with recording preview and form 6. User enters title, description, tags and saves 7. Recording appears in dashboard /me Recordings tab 8. Can play back, edit, or delete recordings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
export async function deleteRecording(id: string) {
return loggedApiCall(
"deleteRecording",
async () => {
await getGraphQLClient().request(DELETE_RECORDING_MUTATION, { id });
},
{ id },
);
feat: add buttplug device recording feature (Phase 1 & 2) Implemented complete infrastructure for recording, saving, and managing buttplug device patterns with precise event timing. **Phase 1: Backend & Infrastructure** - Added Directus schema for sexy_recordings collection with all fields (id, status, user_created, title, description, slug, duration, events, device_info, tags, linked_video, featured, public) - Created REST API endpoints in bundle extension: * GET /sexy/recordings - list user recordings with filtering * GET /sexy/recordings/:id - get single recording * POST /sexy/recordings - create new recording with validation * PATCH /sexy/recordings/:id - update recording (owner only) * DELETE /sexy/recordings/:id - soft delete by archiving - Added TypeScript types: RecordedEvent, DeviceInfo, Recording - Created frontend services: getRecordings(), deleteRecording() - Built RecordingCard component with stats, device info, and actions - Added Recordings tab to /me dashboard page with grid layout - Added i18n translations for recordings UI **Phase 2: Recording Capture** - Implemented recording state management in /play page - Added Start/Stop Recording buttons with visual indicators - Capture device events with precise timestamps during recording - Normalize actuator values (0-100) for cross-device compatibility - Created RecordingSaveDialog component with: * Recording stats display (duration, events, devices) * Form inputs (title, description, tags) * Device information preview - Integrated save recording API call from play page - Added success/error toast notifications - Automatic event filtering during recording **Technical Details** - Events stored as JSON array with timestamp, deviceIndex, deviceName, actuatorIndex, actuatorType, and normalized value - Device metadata includes name, index, and capability list - Slug auto-generated from title for SEO-friendly URLs - Status workflow: draft → published → archived - Permission checks: users can only access own recordings or public ones - Frontend uses performance.now() for millisecond precision timing **User Flow** 1. User scans and connects devices on /play page 2. Clicks "Start Recording" to begin capturing events 3. Manipulates device sliders - all changes are recorded 4. Clicks "Stop Recording" to end capture 5. Save dialog appears with recording preview and form 6. User enters title, description, tags and saves 7. Recording appears in dashboard /me Recordings tab 8. Can play back, edit, or delete recordings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 04:05:09 +01:00
}
const RECORDING_QUERY = gql`
query GetRecording($id: String!) {
recording(id: $id) {
id
title
description
slug
duration
events
device_info
user_id
status
tags
linked_video
featured
public
date_created
date_updated
}
}
`;
export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getRecording",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ recording: Recording | null }>(
RECORDING_QUERY,
{ id },
);
return data.recording;
},
{ id },
);
}
// ─── Video likes & plays ─────────────────────────────────────────────────────
const LIKE_VIDEO_MUTATION = gql`
mutation LikeVideo($videoId: String!) {
likeVideo(videoId: $videoId) {
liked
likes_count
}
}
`;
export async function likeVideo(videoId: string) {
return loggedApiCall(
"likeVideo",
async () => {
const data = await getGraphQLClient().request<{ likeVideo: VideoLikeResponse }>(
LIKE_VIDEO_MUTATION,
{ videoId },
);
return data.likeVideo;
},
{ videoId },
);
}
const UNLIKE_VIDEO_MUTATION = gql`
mutation UnlikeVideo($videoId: String!) {
unlikeVideo(videoId: $videoId) {
liked
likes_count
}
}
`;
export async function unlikeVideo(videoId: string) {
return loggedApiCall(
"unlikeVideo",
async () => {
const data = await getGraphQLClient().request<{ unlikeVideo: VideoLikeResponse }>(
UNLIKE_VIDEO_MUTATION,
{ videoId },
);
return data.unlikeVideo;
},
{ videoId },
);
}
const VIDEO_LIKE_STATUS_QUERY = gql`
query VideoLikeStatus($videoId: String!) {
videoLikeStatus(videoId: $videoId) {
liked
}
}
`;
export async function getVideoLikeStatus(videoId: string, fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getVideoLikeStatus",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ videoLikeStatus: VideoLikeStatus }>(
VIDEO_LIKE_STATUS_QUERY,
{ videoId },
);
return data.videoLikeStatus;
},
{ videoId },
);
}
const RECORD_VIDEO_PLAY_MUTATION = gql`
mutation RecordVideoPlay($videoId: String!, $sessionId: String) {
recordVideoPlay(videoId: $videoId, sessionId: $sessionId) {
success
play_id
plays_count
}
}
`;
export async function recordVideoPlay(videoId: string, sessionId?: string) {
return loggedApiCall(
"recordVideoPlay",
async () => {
const data = await getGraphQLClient().request<{ recordVideoPlay: VideoPlayResponse }>(
RECORD_VIDEO_PLAY_MUTATION,
{ videoId, sessionId },
);
return data.recordVideoPlay;
},
{ videoId },
);
}
const UPDATE_VIDEO_PLAY_MUTATION = gql`
mutation UpdateVideoPlay(
$videoId: String!
$playId: String!
$durationWatched: Int!
$completed: Boolean!
) {
updateVideoPlay(
videoId: $videoId
playId: $playId
durationWatched: $durationWatched
completed: $completed
)
}
`;
export async function updateVideoPlay(
videoId: string,
playId: string,
durationWatched: number,
completed: boolean,
) {
return loggedApiCall(
"updateVideoPlay",
async () => {
await getGraphQLClient().request(UPDATE_VIDEO_PLAY_MUTATION, {
videoId,
playId,
durationWatched,
completed,
});
},
{ videoId, playId, durationWatched, completed },
);
}
// ─── Delete comment ──────────────────────────────────────────────────────────
const DELETE_COMMENT_MUTATION = gql`
mutation DeleteComment($id: Int!) {
deleteComment(id: $id)
}
`;
export async function deleteComment(id: number) {
return loggedApiCall(
"deleteComment",
async () => {
await getGraphQLClient().request(DELETE_COMMENT_MUTATION, { id });
},
{ id },
);
}
// ─── Admin: Users ─────────────────────────────────────────────────────────────
const ADMIN_LIST_USERS_QUERY = gql`
query AdminListUsers($role: String, $search: String, $limit: Int, $offset: Int) {
adminListUsers(role: $role, search: $search, limit: $limit, offset: $offset) {
total
items {
id
email
first_name
last_name
artist_name
slug
role
is_admin
avatar
email_verified
date_created
}
}
}
`;
export async function adminListUsers(
opts: { role?: string; search?: string; limit?: number; offset?: number } = {},
fetchFn?: typeof globalThis.fetch,
token?: string,
) {
return loggedApiCall(
"adminListUsers",
async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{
adminListUsers: { total: number; items: User[] };
}>(ADMIN_LIST_USERS_QUERY, opts);
return data.adminListUsers;
},
opts,
);
}
const ADMIN_UPDATE_USER_MUTATION = gql`
mutation AdminUpdateUser(
$userId: String!
$role: String
$isAdmin: Boolean
$firstName: String
$lastName: String
$artistName: String
$avatarId: String
$bannerId: String
) {
adminUpdateUser(
userId: $userId
role: $role
isAdmin: $isAdmin
firstName: $firstName
lastName: $lastName
artistName: $artistName
avatarId: $avatarId
bannerId: $bannerId
) {
id
email
first_name
last_name
artist_name
role
is_admin
avatar
banner
date_created
}
}
`;
export async function adminUpdateUser(input: {
userId: string;
role?: string;
isAdmin?: boolean;
firstName?: string;
lastName?: string;
artistName?: string;
avatarId?: string;
bannerId?: string;
}) {
return loggedApiCall(
"adminUpdateUser",
async () => {
const data = await getGraphQLClient().request<{ adminUpdateUser: User | null }>(
ADMIN_UPDATE_USER_MUTATION,
input,
);
return data.adminUpdateUser;
},
{ userId: input.userId },
);
}
const ADMIN_DELETE_USER_MUTATION = gql`
mutation AdminDeleteUser($userId: String!) {
adminDeleteUser(userId: $userId)
}
`;
export async function adminDeleteUser(userId: string) {
return loggedApiCall(
"adminDeleteUser",
async () => {
await getGraphQLClient().request(ADMIN_DELETE_USER_MUTATION, { userId });
},
{ userId },
);
}
const ADMIN_GET_USER_QUERY = gql`
query AdminGetUser($userId: String!) {
adminGetUser(userId: $userId) {
id
email
first_name
last_name
artist_name
slug
role
is_admin
avatar
banner
description
tags
email_verified
date_created
photos {
id
filename
}
}
}
`;
export async function adminGetUser(userId: string, token?: string) {
return loggedApiCall(
"adminGetUser",
async () => {
const client = token ? getAuthClient(token) : getGraphQLClient();
const data = await client.request<{ adminGetUser: any }>(ADMIN_GET_USER_QUERY, { userId });
return data.adminGetUser;
},
{ userId },
);
}
const ADMIN_ADD_USER_PHOTO_MUTATION = gql`
mutation AdminAddUserPhoto($userId: String!, $fileId: String!) {
adminAddUserPhoto(userId: $userId, fileId: $fileId)
}
`;
export async function adminAddUserPhoto(userId: string, fileId: string) {
return loggedApiCall("adminAddUserPhoto", async () => {
await getGraphQLClient().request(ADMIN_ADD_USER_PHOTO_MUTATION, { userId, fileId });
});
}
const ADMIN_REMOVE_USER_PHOTO_MUTATION = gql`
mutation AdminRemoveUserPhoto($userId: String!, $fileId: String!) {
adminRemoveUserPhoto(userId: $userId, fileId: $fileId)
}
`;
export async function adminRemoveUserPhoto(userId: string, fileId: string) {
return loggedApiCall("adminRemoveUserPhoto", async () => {
await getGraphQLClient().request(ADMIN_REMOVE_USER_PHOTO_MUTATION, { userId, fileId });
});
}
// ─── Admin: Videos ────────────────────────────────────────────────────────────
const ADMIN_LIST_VIDEOS_QUERY = gql`
query AdminListVideos(
$search: String
$premium: Boolean
$featured: Boolean
$limit: Int
$offset: Int
) {
adminListVideos(
search: $search
premium: $premium
featured: $featured
limit: $limit
offset: $offset
) {
items {
id
slug
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
}
}
total
}
}
`;
export async function adminListVideos(
opts: {
search?: string;
premium?: boolean;
featured?: boolean;
limit?: number;
offset?: number;
} = {},
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<{ items: Video[]; total: number }> {
return loggedApiCall("adminListVideos", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminListVideos: { items: Video[]; total: number } }>(
ADMIN_LIST_VIDEOS_QUERY,
opts,
);
return data.adminListVideos;
});
}
const ADMIN_GET_VIDEO_QUERY = gql`
query AdminGetVideo($id: String!) {
adminGetVideo(id: $id) {
id
slug
title
description
image
movie
tags
upload_date
premium
featured
likes_count
plays_count
models {
id
artist_name
slug
avatar
}
movie_file {
id
filename
mime_type
duration
}
}
}
`;
export async function adminGetVideo(
id: string,
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<Video | null> {
return loggedApiCall("adminGetVideo", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminGetVideo: Video | null }>(ADMIN_GET_VIDEO_QUERY, {
id,
});
return data.adminGetVideo;
});
}
const CREATE_VIDEO_MUTATION = gql`
mutation CreateVideo(
$title: String!
$slug: String!
$description: String
$imageId: String
$movieId: String
$tags: [String!]
$premium: Boolean
$featured: Boolean
$uploadDate: String
) {
createVideo(
title: $title
slug: $slug
description: $description
imageId: $imageId
movieId: $movieId
tags: $tags
premium: $premium
featured: $featured
uploadDate: $uploadDate
) {
id
slug
title
}
}
`;
export async function createVideo(input: {
title: string;
slug: string;
description?: string;
imageId?: string;
movieId?: string;
tags?: string[];
premium?: boolean;
featured?: boolean;
uploadDate?: string;
}) {
return loggedApiCall(
"createVideo",
async () => {
const data = await getGraphQLClient().request<{ createVideo: Video }>(
CREATE_VIDEO_MUTATION,
input,
);
return data.createVideo;
},
{ title: input.title },
);
}
const UPDATE_VIDEO_MUTATION = gql`
mutation UpdateVideo(
$id: String!
$title: String
$slug: String
$description: String
$imageId: String
$movieId: String
$tags: [String!]
$premium: Boolean
$featured: Boolean
$uploadDate: String
) {
updateVideo(
id: $id
title: $title
slug: $slug
description: $description
imageId: $imageId
movieId: $movieId
tags: $tags
premium: $premium
featured: $featured
uploadDate: $uploadDate
) {
id
slug
title
}
}
`;
export async function updateVideo(input: {
id: string;
title?: string;
slug?: string;
description?: string;
imageId?: string;
movieId?: string;
tags?: string[];
premium?: boolean;
featured?: boolean;
uploadDate?: string;
}) {
return loggedApiCall(
"updateVideo",
async () => {
const data = await getGraphQLClient().request<{ updateVideo: Video | null }>(
UPDATE_VIDEO_MUTATION,
input,
);
return data.updateVideo;
},
{ id: input.id },
);
}
const DELETE_VIDEO_MUTATION = gql`
mutation DeleteVideo($id: String!) {
deleteVideo(id: $id)
}
`;
export async function deleteVideo(id: string) {
return loggedApiCall(
"deleteVideo",
async () => {
await getGraphQLClient().request(DELETE_VIDEO_MUTATION, { id });
},
{ id },
);
}
const SET_VIDEO_MODELS_MUTATION = gql`
mutation SetVideoModels($videoId: String!, $userIds: [String!]!) {
setVideoModels(videoId: $videoId, userIds: $userIds)
}
`;
export async function setVideoModels(videoId: string, userIds: string[]) {
return loggedApiCall(
"setVideoModels",
async () => {
await getGraphQLClient().request(SET_VIDEO_MODELS_MUTATION, { videoId, userIds });
},
{ videoId, count: userIds.length },
);
}
// ─── Admin: Articles ──────────────────────────────────────────────────────────
const ADMIN_LIST_ARTICLES_QUERY = gql`
query AdminListArticles(
$search: String
$category: String
$featured: Boolean
$limit: Int
$offset: Int
) {
adminListArticles(
search: $search
category: $category
featured: $featured
limit: $limit
offset: $offset
) {
items {
id
slug
title
excerpt
image
tags
publish_date
category
featured
content
author {
id
artist_name
slug
avatar
}
}
total
}
}
`;
export async function adminListArticles(
opts: {
search?: string;
category?: string;
featured?: boolean;
limit?: number;
offset?: number;
} = {},
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<{ items: Article[]; total: number }> {
return loggedApiCall("adminListArticles", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminListArticles: { items: Article[]; total: number } }>(
ADMIN_LIST_ARTICLES_QUERY,
opts,
);
return data.adminListArticles;
});
}
const ADMIN_GET_ARTICLE_QUERY = gql`
query AdminGetArticle($id: String!) {
adminGetArticle(id: $id) {
id
slug
title
excerpt
content
image
tags
publish_date
category
featured
author {
id
artist_name
slug
avatar
}
}
}
`;
export async function adminGetArticle(
id: string,
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<Article | null> {
return loggedApiCall("adminGetArticle", async () => {
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
const data = await client.request<{ adminGetArticle: Article | null }>(
ADMIN_GET_ARTICLE_QUERY,
{ id },
);
return data.adminGetArticle;
});
}
const CREATE_ARTICLE_MUTATION = gql`
mutation CreateArticle(
$title: String!
$slug: String!
$excerpt: String
$content: String
$imageId: String
$tags: [String!]
$category: String
$featured: Boolean
$publishDate: String
) {
createArticle(
title: $title
slug: $slug
excerpt: $excerpt
content: $content
imageId: $imageId
tags: $tags
category: $category
featured: $featured
publishDate: $publishDate
) {
id
slug
title
}
}
`;
export async function createArticle(input: {
title: string;
slug: string;
excerpt?: string;
content?: string;
imageId?: string;
tags?: string[];
category?: string;
featured?: boolean;
publishDate?: string;
}) {
return loggedApiCall(
"createArticle",
async () => {
const data = await getGraphQLClient().request<{ createArticle: Article }>(
CREATE_ARTICLE_MUTATION,
input,
);
return data.createArticle;
},
{ title: input.title },
);
}
const UPDATE_ARTICLE_MUTATION = gql`
mutation UpdateArticle(
$id: String!
$title: String
$slug: String
$excerpt: String
$content: String
$imageId: String
$authorId: String
$tags: [String!]
$category: String
$featured: Boolean
$publishDate: String
) {
updateArticle(
id: $id
title: $title
slug: $slug
excerpt: $excerpt
content: $content
imageId: $imageId
authorId: $authorId
tags: $tags
category: $category
featured: $featured
publishDate: $publishDate
) {
id
slug
title
}
}
`;
export async function updateArticle(input: {
id: string;
title?: string;
slug?: string;
excerpt?: string;
content?: string;
imageId?: string;
authorId?: string | null;
tags?: string[];
category?: string;
featured?: boolean;
publishDate?: string;
}) {
return loggedApiCall(
"updateArticle",
async () => {
const data = await getGraphQLClient().request<{ updateArticle: Article | null }>(
UPDATE_ARTICLE_MUTATION,
input,
);
return data.updateArticle;
},
{ id: input.id },
);
}
const DELETE_ARTICLE_MUTATION = gql`
mutation DeleteArticle($id: String!) {
deleteArticle(id: $id)
}
`;
export async function deleteArticle(id: string) {
return loggedApiCall(
"deleteArticle",
async () => {
await getGraphQLClient().request(DELETE_ARTICLE_MUTATION, { id });
},
{ id },
);
}
// ─── Analytics ───────────────────────────────────────────────────────────────
const ANALYTICS_QUERY = gql`
query GetAnalytics {
analytics
}
`;
export async function getAnalytics(fetchFn?: typeof globalThis.fetch) {
return loggedApiCall(
"getAnalytics",
async () => {
const data = await getGraphQLClient(fetchFn).request<{ analytics: Analytics | null }>(
ANALYTICS_QUERY,
);
return data.analytics;
},
{},
);
}