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
|
2026-03-06 16:14:00 +01:00
|
|
|
is_admin
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
feat: add shared @sexy.pivoine.art/types package and fix type safety across frontend/backend
- Create packages/types with shared TypeScript domain model interfaces (User, Video, Model, Article, Comment, Recording, etc.)
- Wire both frontend and backend packages to use @sexy.pivoine.art/types via workspace:*
- Update backend Pothos objectRef types to use shared interfaces instead of inline types
- Update frontend $lib/types.ts to re-export from shared package
- Fix all type errors introduced by more accurate nullable types (avatar/banner as string|null UUIDs, author nullable, events/device_info as object[])
- Add artist_name to comment user select in backend resolver
- Widen utility function signatures (getAssetUrl, getUserInitials, calcReadingTime) to accept null/undefined
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 11:01:11 +01:00
|
|
|
export async function updateProfile(user: Partial<User> & { password?: string }) {
|
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) {
|
feat: add shared @sexy.pivoine.art/types package and fix type safety across frontend/backend
- Create packages/types with shared TypeScript domain model interfaces (User, Video, Model, Article, Comment, Recording, etc.)
- Wire both frontend and backend packages to use @sexy.pivoine.art/types via workspace:*
- Update backend Pothos objectRef types to use shared interfaces instead of inline types
- Update frontend $lib/types.ts to re-export from shared package
- Fix all type errors introduced by more accurate nullable types (avatar/banner as string|null UUIDs, author nullable, events/device_info as object[])
- Add artist_name to comment user select in backend resolver
- Widen utility function signatures (getAssetUrl, getUserInitials, calcReadingTime) to accept null/undefined
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 11:01:11 +01:00
|
|
|
return loggedApiCall("getFolders", async () => [] as { id: string; name: string }[]);
|
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;
|
feat: add shared @sexy.pivoine.art/types package and fix type safety across frontend/backend
- Create packages/types with shared TypeScript domain model interfaces (User, Video, Model, Article, Comment, Recording, etc.)
- Wire both frontend and backend packages to use @sexy.pivoine.art/types via workspace:*
- Update backend Pothos objectRef types to use shared interfaces instead of inline types
- Update frontend $lib/types.ts to re-export from shared package
- Fix all type errors introduced by more accurate nullable types (avatar/banner as string|null UUIDs, author nullable, events/device_info as object[])
- Add artist_name to comment user select in backend resolver
- Widen utility function signatures (getAssetUrl, getUserInitials, calcReadingTime) to accept null/undefined
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 11:01:11 +01:00
|
|
|
artist_name: string | null;
|
2026-03-04 22:27:54 +01:00
|
|
|
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
|
|
|
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
// ─── 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
|
2026-03-06 16:14:00 +01:00
|
|
|
is_admin
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
avatar
|
|
|
|
|
email_verified
|
|
|
|
|
date_created
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function adminListUsers(
|
|
|
|
|
opts: { role?: string; search?: string; limit?: number; offset?: number } = {},
|
|
|
|
|
fetchFn?: typeof globalThis.fetch,
|
2026-03-06 12:56:47 +01:00
|
|
|
token?: string,
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
) {
|
|
|
|
|
return loggedApiCall(
|
|
|
|
|
"adminListUsers",
|
|
|
|
|
async () => {
|
2026-03-06 13:11:54 +01:00
|
|
|
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
|
2026-03-06 12:56:47 +01:00
|
|
|
const data = await client.request<{
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
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
|
2026-03-06 16:14:00 +01:00
|
|
|
$isAdmin: Boolean
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
$firstName: String
|
|
|
|
|
$lastName: String
|
|
|
|
|
$artistName: String
|
2026-03-06 13:18:43 +01:00
|
|
|
$avatarId: String
|
|
|
|
|
$bannerId: String
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
) {
|
|
|
|
|
adminUpdateUser(
|
|
|
|
|
userId: $userId
|
|
|
|
|
role: $role
|
2026-03-06 16:14:00 +01:00
|
|
|
isAdmin: $isAdmin
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
firstName: $firstName
|
|
|
|
|
lastName: $lastName
|
|
|
|
|
artistName: $artistName
|
2026-03-06 13:18:43 +01:00
|
|
|
avatarId: $avatarId
|
|
|
|
|
bannerId: $bannerId
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
) {
|
|
|
|
|
id
|
|
|
|
|
email
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
artist_name
|
|
|
|
|
role
|
2026-03-06 16:14:00 +01:00
|
|
|
is_admin
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
avatar
|
2026-03-06 13:18:43 +01:00
|
|
|
banner
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
date_created
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export async function adminUpdateUser(input: {
|
|
|
|
|
userId: string;
|
|
|
|
|
role?: string;
|
2026-03-06 16:14:00 +01:00
|
|
|
isAdmin?: boolean;
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
firstName?: string;
|
|
|
|
|
lastName?: string;
|
|
|
|
|
artistName?: string;
|
2026-03-06 13:18:43 +01:00
|
|
|
avatarId?: string;
|
|
|
|
|
bannerId?: string;
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
}) {
|
|
|
|
|
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 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 13:18:43 +01:00
|
|
|
const ADMIN_GET_USER_QUERY = gql`
|
|
|
|
|
query AdminGetUser($userId: String!) {
|
|
|
|
|
adminGetUser(userId: $userId) {
|
|
|
|
|
id
|
|
|
|
|
email
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
artist_name
|
|
|
|
|
slug
|
|
|
|
|
role
|
2026-03-06 16:14:00 +01:00
|
|
|
is_admin
|
2026-03-06 13:18:43 +01:00
|
|
|
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 });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
// ─── Admin: Videos ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const ADMIN_LIST_VIDEOS_QUERY = gql`
|
|
|
|
|
query AdminListVideos {
|
|
|
|
|
adminListVideos {
|
|
|
|
|
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-06 12:56:47 +01:00
|
|
|
export async function adminListVideos(fetchFn?: typeof globalThis.fetch, token?: string) {
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
return loggedApiCall("adminListVideos", async () => {
|
2026-03-06 13:11:54 +01:00
|
|
|
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
|
2026-03-06 12:56:47 +01:00
|
|
|
const data = await client.request<{ adminListVideos: Video[] }>(
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
ADMIN_LIST_VIDEOS_QUERY,
|
|
|
|
|
);
|
|
|
|
|
return data.adminListVideos;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
adminListArticles {
|
|
|
|
|
id
|
|
|
|
|
slug
|
|
|
|
|
title
|
|
|
|
|
excerpt
|
|
|
|
|
image
|
|
|
|
|
tags
|
|
|
|
|
publish_date
|
|
|
|
|
category
|
|
|
|
|
featured
|
|
|
|
|
content
|
|
|
|
|
author {
|
|
|
|
|
first_name
|
|
|
|
|
last_name
|
|
|
|
|
avatar
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2026-03-06 12:56:47 +01:00
|
|
|
export async function adminListArticles(fetchFn?: typeof globalThis.fetch, token?: string) {
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
return loggedApiCall("adminListArticles", async () => {
|
2026-03-06 13:11:54 +01:00
|
|
|
const client = token ? getAuthClient(token) : getGraphQLClient(fetchFn);
|
2026-03-06 12:56:47 +01:00
|
|
|
const data = await client.request<{ adminListArticles: Article[] }>(
|
feat: role-based ACL + admin management UI
Backend:
- Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers
- Gate premium videos from unauthenticated users in videos query/resolver
- Fix updateVideoPlay to verify ownership before updating
- Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser
- Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos
- Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles
- Add deleteComment mutation (owner or admin only)
- Add AdminUserListType to GraphQL types
- Fix featured filter on articles query
Frontend:
- Install marked for markdown rendering
- Add /admin/* section with sidebar layout and admin-only guard
- Admin users page: paginated table with search, role filter, inline role change, delete
- Admin videos pages: list, create form, edit form with file upload and model assignment
- Admin articles pages: list, create form, edit form with split-pane markdown editor
- Add admin nav link in header (desktop + mobile) for admin users
- Render article content through marked in magazine detail page
- Add all admin GraphQL service functions to services.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:33 +01:00
|
|
|
ADMIN_LIST_ARTICLES_QUERY,
|
|
|
|
|
);
|
|
|
|
|
return data.adminListArticles;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
$tags: [String!]
|
|
|
|
|
$category: String
|
|
|
|
|
$featured: Boolean
|
|
|
|
|
$publishDate: String
|
|
|
|
|
) {
|
|
|
|
|
updateArticle(
|
|
|
|
|
id: $id
|
|
|
|
|
title: $title
|
|
|
|
|
slug: $slug
|
|
|
|
|
excerpt: $excerpt
|
|
|
|
|
content: $content
|
|
|
|
|
imageId: $imageId
|
|
|
|
|
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;
|
|
|
|
|
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 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
}
|