2026-03-04 18:07:18 +01:00
|
|
|
import { gql, GraphQLClient } from "graphql-request";
|
|
|
|
|
import { apiUrl, getGraphQLClient } from "$lib/api";
|
|
|
|
|
import type {
|
2026-03-04 22:27:54 +01:00
|
|
|
Analytics,
|
|
|
|
|
Article,
|
|
|
|
|
CurrentUser,
|
|
|
|
|
Model,
|
|
|
|
|
Recording,
|
|
|
|
|
Stats,
|
|
|
|
|
User,
|
|
|
|
|
Video,
|
|
|
|
|
VideoLikeStatus,
|
|
|
|
|
VideoLikeResponse,
|
|
|
|
|
VideoPlayResponse,
|
2026-03-04 18:07:18 +01:00
|
|
|
} 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>(
|
2026-03-04 22:27:54 +01:00
|
|
|
operationName: string,
|
|
|
|
|
operation: () => Promise<T>,
|
|
|
|
|
context?: Record<string, unknown>,
|
2025-10-26 14:48:30 +01:00
|
|
|
): Promise<T> {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// For server-side auth checks: forward cookie header manually
|
|
|
|
|
function getAuthClient(token: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return new GraphQLClient(`${apiUrl}/graphql`, {
|
|
|
|
|
fetch: fetchFn || globalThis.fetch,
|
|
|
|
|
headers: { cookie: `session_token=${token}` },
|
|
|
|
|
});
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
2025-10-25 22:04:41 +02:00
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const ME_QUERY = gql`
|
|
|
|
|
query Me {
|
|
|
|
|
me {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
email
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
artist_name
|
|
|
|
|
slug
|
|
|
|
|
description
|
|
|
|
|
tags
|
|
|
|
|
role
|
|
|
|
|
avatar
|
|
|
|
|
banner
|
|
|
|
|
email_verified
|
|
|
|
|
date_created
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function isAuthenticated(token: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const LOGIN_MUTATION = gql`
|
|
|
|
|
mutation Login($email: String!, $password: String!) {
|
|
|
|
|
login(email: $email, password: $password) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
email
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
artist_name
|
|
|
|
|
slug
|
|
|
|
|
description
|
|
|
|
|
tags
|
|
|
|
|
role
|
|
|
|
|
avatar
|
|
|
|
|
banner
|
|
|
|
|
email_verified
|
|
|
|
|
date_created
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function login(email: string, password: string) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
`;
|
2026-03-04 18:07:18 +01:00
|
|
|
|
|
|
|
|
export async function logout() {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall("logout", async () => {
|
|
|
|
|
await getGraphQLClient().request(LOGOUT_MUTATION);
|
|
|
|
|
});
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(
|
2026-03-04 22:27:54 +01:00
|
|
|
email: string,
|
|
|
|
|
password: string,
|
|
|
|
|
firstName: string,
|
|
|
|
|
lastName: string,
|
2025-10-25 22:04:41 +02:00
|
|
|
) {
|
2026-03-04 22:27:54 +01: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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const VERIFY_EMAIL_MUTATION = gql`
|
|
|
|
|
mutation VerifyEmail($token: String!) {
|
|
|
|
|
verifyEmail(token: $token)
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function verify(token: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"verify",
|
|
|
|
|
async () => {
|
|
|
|
|
await getGraphQLClient(fetchFn).request(VERIFY_EMAIL_MUTATION, { token });
|
|
|
|
|
},
|
|
|
|
|
{ hasToken: !!token },
|
|
|
|
|
);
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01: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) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"requestPassword",
|
|
|
|
|
async () => {
|
|
|
|
|
await getGraphQLClient().request(REQUEST_PASSWORD_MUTATION, { email });
|
|
|
|
|
},
|
|
|
|
|
{ email },
|
|
|
|
|
);
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01: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) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"resetPassword",
|
|
|
|
|
async () => {
|
|
|
|
|
await getGraphQLClient().request(RESET_PASSWORD_MUTATION, {
|
|
|
|
|
token,
|
|
|
|
|
newPassword: password,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
{ hasToken: !!token },
|
|
|
|
|
);
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Articles ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const ARTICLES_QUERY = gql`
|
|
|
|
|
query GetArticles {
|
|
|
|
|
articles {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
slug
|
|
|
|
|
title
|
|
|
|
|
excerpt
|
|
|
|
|
content
|
|
|
|
|
image
|
|
|
|
|
tags
|
|
|
|
|
publish_date
|
|
|
|
|
category
|
|
|
|
|
featured
|
|
|
|
|
author {
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
avatar
|
|
|
|
|
description
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getArticles(fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall("getArticles", async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ articles: Article[] }>(ARTICLES_QUERY);
|
|
|
|
|
return data.articles;
|
|
|
|
|
});
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const ARTICLE_BY_SLUG_QUERY = gql`
|
|
|
|
|
query GetArticleBySlug($slug: String!) {
|
|
|
|
|
article(slug: $slug) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
slug
|
|
|
|
|
title
|
|
|
|
|
excerpt
|
|
|
|
|
content
|
|
|
|
|
image
|
|
|
|
|
tags
|
|
|
|
|
publish_date
|
|
|
|
|
category
|
|
|
|
|
featured
|
|
|
|
|
author {
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
avatar
|
|
|
|
|
description
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getArticleBySlug(slug: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Videos ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const VIDEOS_QUERY = gql`
|
|
|
|
|
query GetVideos($modelId: String, $featured: Boolean, $limit: Int) {
|
|
|
|
|
videos(modelId: $modelId, featured: $featured, limit: $limit) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getVideos(fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall("getVideos", async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ videos: Video[] }>(VIDEOS_QUERY);
|
|
|
|
|
return data.videos;
|
|
|
|
|
});
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
export async function getVideosForModel(id: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"getVideosForModel",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ videos: Video[] }>(VIDEOS_QUERY, {
|
|
|
|
|
modelId: id,
|
|
|
|
|
});
|
|
|
|
|
return data.videos;
|
|
|
|
|
},
|
|
|
|
|
{ modelId: id },
|
|
|
|
|
);
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getFeaturedVideos(
|
2026-03-04 22:27:54 +01:00
|
|
|
limit: number,
|
|
|
|
|
fetchFn: typeof globalThis.fetch = globalThis.fetch,
|
2025-10-25 22:04:41 +02:00
|
|
|
) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"getFeaturedVideos",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ videos: Video[] }>(VIDEOS_QUERY, {
|
|
|
|
|
featured: true,
|
|
|
|
|
limit,
|
|
|
|
|
});
|
|
|
|
|
return data.videos;
|
|
|
|
|
},
|
|
|
|
|
{ limit },
|
|
|
|
|
);
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const VIDEO_BY_SLUG_QUERY = gql`
|
|
|
|
|
query GetVideoBySlug($slug: String!) {
|
|
|
|
|
video(slug: $slug) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getVideoBySlug(slug: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Models ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const MODELS_QUERY = gql`
|
|
|
|
|
query GetModels($featured: Boolean, $limit: Int) {
|
|
|
|
|
models(featured: $featured, limit: $limit) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
slug
|
|
|
|
|
artist_name
|
|
|
|
|
description
|
|
|
|
|
avatar
|
|
|
|
|
banner
|
|
|
|
|
tags
|
|
|
|
|
date_created
|
|
|
|
|
photos {
|
|
|
|
|
id
|
|
|
|
|
filename
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getModels(fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall("getModels", async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ models: Model[] }>(MODELS_QUERY);
|
|
|
|
|
return data.models;
|
|
|
|
|
});
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getFeaturedModels(
|
2026-03-04 22:27:54 +01:00
|
|
|
limit = 3,
|
|
|
|
|
fetchFn: typeof globalThis.fetch = globalThis.fetch,
|
2025-10-25 22:04:41 +02:00
|
|
|
) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"getFeaturedModels",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ models: Model[] }>(MODELS_QUERY, {
|
|
|
|
|
featured: true,
|
|
|
|
|
limit,
|
|
|
|
|
});
|
|
|
|
|
return data.models;
|
|
|
|
|
},
|
|
|
|
|
{ limit },
|
|
|
|
|
);
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const MODEL_BY_SLUG_QUERY = gql`
|
|
|
|
|
query GetModelBySlug($slug: String!) {
|
|
|
|
|
model(slug: $slug) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
slug
|
|
|
|
|
artist_name
|
|
|
|
|
description
|
|
|
|
|
avatar
|
|
|
|
|
banner
|
|
|
|
|
tags
|
|
|
|
|
date_created
|
|
|
|
|
photos {
|
|
|
|
|
id
|
|
|
|
|
filename
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getModelBySlug(slug: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01: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
|
|
|
|
|
) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
email
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
artist_name
|
|
|
|
|
slug
|
|
|
|
|
description
|
|
|
|
|
tags
|
|
|
|
|
role
|
|
|
|
|
avatar
|
|
|
|
|
banner
|
|
|
|
|
date_created
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2025-10-25 22:04:41 +02:00
|
|
|
export async function updateProfile(user: Partial<User>) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Stats ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const STATS_QUERY = gql`
|
|
|
|
|
query GetStats {
|
2026-03-04 22:27:54 +01:00
|
|
|
stats {
|
|
|
|
|
videos_count
|
|
|
|
|
models_count
|
|
|
|
|
viewers_count
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getStats(fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// Stub — Directus folder concept dropped
|
|
|
|
|
export async function getFolders(_fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall("getFolders", async () => []);
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Files ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2025-10-25 22:04:41 +02:00
|
|
|
export async function removeFile(id: string) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Comments ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const COMMENTS_FOR_VIDEO_QUERY = gql`
|
|
|
|
|
query CommentsForVideo($videoId: String!) {
|
|
|
|
|
commentsForVideo(videoId: $videoId) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
comment
|
|
|
|
|
item_id
|
|
|
|
|
user_id
|
|
|
|
|
date_created
|
|
|
|
|
user {
|
|
|
|
|
id
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
avatar
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getCommentsForVideo(item: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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;
|
|
|
|
|
avatar: string | null;
|
|
|
|
|
} | null;
|
|
|
|
|
}[];
|
|
|
|
|
}>(COMMENTS_FOR_VIDEO_QUERY, { videoId: item });
|
|
|
|
|
return data.commentsForVideo;
|
|
|
|
|
},
|
|
|
|
|
{ videoId: item },
|
|
|
|
|
);
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const CREATE_COMMENT_MUTATION = gql`
|
|
|
|
|
mutation CreateCommentForVideo($videoId: String!, $comment: String!) {
|
|
|
|
|
createCommentForVideo(videoId: $videoId, comment: $comment) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
comment
|
|
|
|
|
item_id
|
|
|
|
|
user_id
|
|
|
|
|
date_created
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function createCommentForVideo(item: string, comment: string) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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(
|
2026-03-04 22:27:54 +01:00
|
|
|
_user_created: string,
|
|
|
|
|
_fetchFn?: typeof globalThis.fetch,
|
2025-10-25 22:04:41 +02:00
|
|
|
) {
|
2026-03-04 22:27:54 +01:00
|
|
|
// Not directly available in new API, return 0
|
|
|
|
|
return 0;
|
2025-10-25 22:04:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Tags ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2025-10-25 22:04:41 +02:00
|
|
|
export async function getItemsByTag(
|
2026-03-04 22:27:54 +01:00
|
|
|
category: "video" | "article" | "model",
|
|
|
|
|
_tag: string,
|
|
|
|
|
fetchFn?: typeof globalThis.fetch,
|
2025-10-25 22:04:41 +02:00
|
|
|
) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"getItemsByTag",
|
|
|
|
|
async () => {
|
|
|
|
|
switch (category) {
|
|
|
|
|
case "video":
|
|
|
|
|
return getVideos(fetchFn);
|
|
|
|
|
case "model":
|
|
|
|
|
return getModels(fetchFn);
|
|
|
|
|
case "article":
|
|
|
|
|
return getArticles(fetchFn);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ category },
|
|
|
|
|
);
|
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
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Recordings ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const RECORDINGS_QUERY = gql`
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getRecordings(fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +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
|
|
|
|
|
) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
title
|
|
|
|
|
description
|
|
|
|
|
slug
|
|
|
|
|
duration
|
|
|
|
|
events
|
|
|
|
|
device_info
|
|
|
|
|
user_id
|
|
|
|
|
status
|
|
|
|
|
tags
|
|
|
|
|
linked_video
|
|
|
|
|
featured
|
|
|
|
|
public
|
|
|
|
|
date_created
|
|
|
|
|
date_updated
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2025-10-28 05:16:36 +01:00
|
|
|
export async function createRecording(
|
2026-03-04 22:27:54 +01:00
|
|
|
recording: {
|
|
|
|
|
title: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
duration: number;
|
|
|
|
|
events: unknown[];
|
|
|
|
|
device_info: unknown[];
|
|
|
|
|
tags?: string[];
|
|
|
|
|
status?: string;
|
|
|
|
|
},
|
|
|
|
|
fetchFn?: typeof globalThis.fetch,
|
2025-10-28 05:16:36 +01:00
|
|
|
) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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 },
|
|
|
|
|
);
|
2025-10-28 05:16:36 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
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) {
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
2025-10-28 05:28:04 +01:00
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const RECORDING_QUERY = gql`
|
|
|
|
|
query GetRecording($id: String!) {
|
|
|
|
|
recording(id: $id) {
|
2026-03-04 22:27:54 +01:00
|
|
|
id
|
|
|
|
|
title
|
|
|
|
|
description
|
|
|
|
|
slug
|
|
|
|
|
duration
|
|
|
|
|
events
|
|
|
|
|
device_info
|
|
|
|
|
user_id
|
|
|
|
|
status
|
|
|
|
|
tags
|
|
|
|
|
linked_video
|
|
|
|
|
featured
|
|
|
|
|
public
|
|
|
|
|
date_created
|
|
|
|
|
date_updated
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getRecording(id: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"getRecording",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ recording: Recording | null }>(
|
|
|
|
|
RECORDING_QUERY,
|
|
|
|
|
{ id },
|
|
|
|
|
);
|
|
|
|
|
return data.recording;
|
|
|
|
|
},
|
|
|
|
|
{ id },
|
|
|
|
|
);
|
2025-10-28 05:28:04 +01:00
|
|
|
}
|
2025-10-28 10:29:02 +01:00
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Video likes & plays ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const LIKE_VIDEO_MUTATION = gql`
|
|
|
|
|
mutation LikeVideo($videoId: String!) {
|
2026-03-04 22:27:54 +01:00
|
|
|
likeVideo(videoId: $videoId) {
|
|
|
|
|
liked
|
|
|
|
|
likes_count
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2025-10-28 10:29:02 +01:00
|
|
|
export async function likeVideo(videoId: string) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"likeVideo",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient().request<{ likeVideo: VideoLikeResponse }>(
|
|
|
|
|
LIKE_VIDEO_MUTATION,
|
|
|
|
|
{ videoId },
|
|
|
|
|
);
|
|
|
|
|
return data.likeVideo;
|
|
|
|
|
},
|
|
|
|
|
{ videoId },
|
|
|
|
|
);
|
2025-10-28 10:29:02 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const UNLIKE_VIDEO_MUTATION = gql`
|
|
|
|
|
mutation UnlikeVideo($videoId: String!) {
|
2026-03-04 22:27:54 +01:00
|
|
|
unlikeVideo(videoId: $videoId) {
|
|
|
|
|
liked
|
|
|
|
|
likes_count
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2025-10-28 10:29:02 +01:00
|
|
|
export async function unlikeVideo(videoId: string) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"unlikeVideo",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient().request<{ unlikeVideo: VideoLikeResponse }>(
|
|
|
|
|
UNLIKE_VIDEO_MUTATION,
|
|
|
|
|
{ videoId },
|
|
|
|
|
);
|
|
|
|
|
return data.unlikeVideo;
|
|
|
|
|
},
|
|
|
|
|
{ videoId },
|
|
|
|
|
);
|
2025-10-28 10:29:02 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const VIDEO_LIKE_STATUS_QUERY = gql`
|
|
|
|
|
query VideoLikeStatus($videoId: String!) {
|
2026-03-04 22:27:54 +01:00
|
|
|
videoLikeStatus(videoId: $videoId) {
|
|
|
|
|
liked
|
|
|
|
|
}
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getVideoLikeStatus(videoId: string, fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"getVideoLikeStatus",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ videoLikeStatus: VideoLikeStatus }>(
|
|
|
|
|
VIDEO_LIKE_STATUS_QUERY,
|
|
|
|
|
{ videoId },
|
|
|
|
|
);
|
|
|
|
|
return data.videoLikeStatus;
|
|
|
|
|
},
|
|
|
|
|
{ videoId },
|
|
|
|
|
);
|
2025-10-28 10:29:02 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const RECORD_VIDEO_PLAY_MUTATION = gql`
|
|
|
|
|
mutation RecordVideoPlay($videoId: String!, $sessionId: String) {
|
|
|
|
|
recordVideoPlay(videoId: $videoId, sessionId: $sessionId) {
|
2026-03-04 22:27:54 +01:00
|
|
|
success
|
|
|
|
|
play_id
|
|
|
|
|
plays_count
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2025-10-28 10:29:02 +01:00
|
|
|
export async function recordVideoPlay(videoId: string, sessionId?: string) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"recordVideoPlay",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient().request<{ recordVideoPlay: VideoPlayResponse }>(
|
|
|
|
|
RECORD_VIDEO_PLAY_MUTATION,
|
|
|
|
|
{ videoId, sessionId },
|
|
|
|
|
);
|
|
|
|
|
return data.recordVideoPlay;
|
|
|
|
|
},
|
|
|
|
|
{ videoId },
|
|
|
|
|
);
|
2025-10-28 10:29:02 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
const UPDATE_VIDEO_PLAY_MUTATION = gql`
|
2026-03-04 22:27:54 +01:00
|
|
|
mutation UpdateVideoPlay(
|
|
|
|
|
$videoId: String!
|
|
|
|
|
$playId: String!
|
|
|
|
|
$durationWatched: Int!
|
|
|
|
|
$completed: Boolean!
|
|
|
|
|
) {
|
|
|
|
|
updateVideoPlay(
|
|
|
|
|
videoId: $videoId
|
|
|
|
|
playId: $playId
|
|
|
|
|
durationWatched: $durationWatched
|
|
|
|
|
completed: $completed
|
|
|
|
|
)
|
2026-03-04 18:07:18 +01:00
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2025-10-28 10:29:02 +01:00
|
|
|
export async function updateVideoPlay(
|
2026-03-04 22:27:54 +01:00
|
|
|
videoId: string,
|
|
|
|
|
playId: string,
|
|
|
|
|
durationWatched: number,
|
|
|
|
|
completed: boolean,
|
2025-10-28 10:29:02 +01:00
|
|
|
) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"updateVideoPlay",
|
|
|
|
|
async () => {
|
|
|
|
|
await getGraphQLClient().request(UPDATE_VIDEO_PLAY_MUTATION, {
|
|
|
|
|
videoId,
|
|
|
|
|
playId,
|
|
|
|
|
durationWatched,
|
|
|
|
|
completed,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
{ videoId, playId, durationWatched, completed },
|
|
|
|
|
);
|
2025-10-28 10:29:02 +01:00
|
|
|
}
|
2025-10-28 10:42:06 +01:00
|
|
|
|
2026-03-04 18:07:18 +01:00
|
|
|
// ─── Analytics ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const ANALYTICS_QUERY = gql`
|
|
|
|
|
query GetAnalytics {
|
|
|
|
|
analytics
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function getAnalytics(fetchFn?: typeof globalThis.fetch) {
|
2026-03-04 22:27:54 +01:00
|
|
|
return loggedApiCall(
|
|
|
|
|
"getAnalytics",
|
|
|
|
|
async () => {
|
|
|
|
|
const data = await getGraphQLClient(fetchFn).request<{ analytics: Analytics | null }>(
|
|
|
|
|
ANALYTICS_QUERY,
|
|
|
|
|
);
|
|
|
|
|
return data.analytics;
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
);
|
2025-10-28 10:42:06 +01:00
|
|
|
}
|