feat: replace Directus with custom Node.js GraphQL backend
Removes Directus 11 and replaces it with a lean, purpose-built backend: - packages/backend/: Fastify v5 + GraphQL Yoga v5 + Pothos (code-first) with Drizzle ORM, Redis sessions (session_token cookie), argon2 auth, Nodemailer, fluent-ffmpeg, and full gamification system ported from bundle - Frontend: @directus/sdk replaced by graphql-request v7; services.ts fully rewritten with identical signatures; directus.ts now re-exports from api.ts - Cookie renamed directus_session_token → session_token - Dev proxy target updated 8055 → 4000 - compose.yml: Directus service removed, backend service added (port 4000) - Dockerfile.backend: new multi-stage image with ffmpeg - Dockerfile: bundle build step and ffmpeg removed from frontend image - data-migration.ts: one-time script to migrate all Directus/sexy_ tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,8 +40,9 @@
|
||||
"vite-plugin-wasm": "3.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.1.0",
|
||||
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-request": "^7.1.2",
|
||||
"javascript-time-ago": "^2.6.4",
|
||||
"media-chrome": "^4.18.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
|
||||
@@ -30,7 +30,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
});
|
||||
|
||||
// Handle authentication
|
||||
const token = cookies.get("directus_session_token");
|
||||
const token = cookies.get("session_token");
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
@@ -42,7 +42,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
userId: locals.authStatus.user?.id,
|
||||
context: {
|
||||
email: locals.authStatus.user?.email,
|
||||
role: locals.authStatus.user?.role?.name,
|
||||
role: locals.authStatus.user?.role,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
25
packages/frontend/src/lib/api.ts
Normal file
25
packages/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { GraphQLClient } from "graphql-request";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import type { CurrentUser } from "./types";
|
||||
|
||||
export const apiUrl = env.PUBLIC_API_URL || "http://localhost:3000/api";
|
||||
|
||||
export const getGraphQLClient = (fetchFn?: typeof globalThis.fetch) =>
|
||||
new GraphQLClient(`${apiUrl}/graphql`, {
|
||||
credentials: "include",
|
||||
fetch: fetchFn || globalThis.fetch,
|
||||
});
|
||||
|
||||
export const getAssetUrl = (
|
||||
id: string,
|
||||
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
|
||||
) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return `${apiUrl}/assets/${id}${transform ? "?transform=" + transform : ""}`;
|
||||
};
|
||||
|
||||
export const isModel = (user: CurrentUser) => {
|
||||
return user.role === "model";
|
||||
};
|
||||
@@ -1,35 +1,3 @@
|
||||
import { authentication, createDirectus, rest } from "@directus/sdk";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import type { CurrentUser } from "./types";
|
||||
|
||||
export const directusApiUrl = env.PUBLIC_API_URL || "http://localhost:3000/api";
|
||||
|
||||
export const getDirectusInstance = (fetch?: typeof globalThis.fetch) => {
|
||||
const options: { globals?: { fetch: typeof globalThis.fetch } } = fetch
|
||||
? { globals: { fetch } }
|
||||
: {};
|
||||
const directus = createDirectus(directusApiUrl, options)
|
||||
.with(rest())
|
||||
.with(authentication("session"));
|
||||
return directus;
|
||||
};
|
||||
|
||||
export const getAssetUrl = (
|
||||
id: string,
|
||||
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
|
||||
) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return `${directusApiUrl}/assets/${id}${transform ? "?key=" + transform : ""}`;
|
||||
};
|
||||
|
||||
export const isModel = (user: CurrentUser) => {
|
||||
if (user.role.name === "Model") {
|
||||
return true;
|
||||
}
|
||||
if (user.policies.find((p) => p.policy.name === "Model")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// Re-export from api.ts for backwards compatibility
|
||||
// All components that import from $lib/directus continue to work
|
||||
export { apiUrl as directusApiUrl, getAssetUrl, isModel, getGraphQLClient as getDirectusInstance } from "./api.js";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,14 +16,8 @@ export interface User {
|
||||
|
||||
export interface CurrentUser extends User {
|
||||
avatar: File;
|
||||
role: {
|
||||
name: string;
|
||||
};
|
||||
policies: {
|
||||
policy: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
role: "model" | "viewer" | "admin";
|
||||
policies: string[];
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { gql } from "graphql-request";
|
||||
import { getGraphQLClient } from "$lib/api";
|
||||
|
||||
const LEADERBOARD_QUERY = gql`
|
||||
query Leaderboard($limit: Int, $offset: Int) {
|
||||
leaderboard(limit: $limit, offset: $offset) {
|
||||
user_id display_name avatar
|
||||
total_weighted_points total_raw_points
|
||||
recordings_count playbacks_count achievements_count rank
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||
// Guard: Redirect to login if not authenticated
|
||||
@@ -11,22 +23,27 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||
const limit = parseInt(url.searchParams.get("limit") || "100");
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
|
||||
const response = await fetch(
|
||||
`/api/sexy/gamification/leaderboard?limit=${limit}&offset=${offset}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch leaderboard");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const client = getGraphQLClient(fetch);
|
||||
const data = await client.request<{
|
||||
leaderboard: {
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
avatar: string | null;
|
||||
total_weighted_points: number | null;
|
||||
total_raw_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
}[];
|
||||
}>(LEADERBOARD_QUERY, { limit, offset });
|
||||
|
||||
return {
|
||||
leaderboard: data.data || [],
|
||||
leaderboard: data.leaderboard || [],
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore: data.data?.length === limit,
|
||||
hasMore: data.leaderboard?.length === limit,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { gql } from "graphql-request";
|
||||
import { getGraphQLClient } from "$lib/api";
|
||||
|
||||
const USER_PROFILE_QUERY = gql`
|
||||
query UserProfile($id: String!) {
|
||||
userProfile(id: $id) {
|
||||
id first_name last_name email description avatar date_created
|
||||
}
|
||||
userGamification(userId: $id) {
|
||||
stats {
|
||||
user_id total_raw_points total_weighted_points
|
||||
recordings_count playbacks_count comments_count achievements_count rank
|
||||
}
|
||||
achievements {
|
||||
id code name description icon category date_unlocked progress required_count
|
||||
}
|
||||
recent_points { action points date_created recording_id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||
// Guard: Redirect to login if not authenticated
|
||||
@@ -10,38 +30,44 @@ export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
// Fetch user profile data from Directus
|
||||
const userResponse = await fetch(`/api/users/${id}?fields=id,first_name,last_name,email,description,avatar,date_created,location`);
|
||||
const client = getGraphQLClient(fetch);
|
||||
const data = await client.request<{
|
||||
userProfile: {
|
||||
id: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email: string;
|
||||
description: string | null;
|
||||
avatar: string | null;
|
||||
date_created: string;
|
||||
} | null;
|
||||
userGamification: {
|
||||
stats: {
|
||||
user_id: string;
|
||||
total_raw_points: number | null;
|
||||
total_weighted_points: number | null;
|
||||
recordings_count: number | null;
|
||||
playbacks_count: number | null;
|
||||
comments_count: number | null;
|
||||
achievements_count: number | null;
|
||||
rank: number;
|
||||
} | null;
|
||||
achievements: unknown[];
|
||||
recent_points: unknown[];
|
||||
} | null;
|
||||
}>(USER_PROFILE_QUERY, { id });
|
||||
|
||||
if (!userResponse.ok) {
|
||||
if (!data.userProfile) {
|
||||
throw redirect(404, "/");
|
||||
}
|
||||
|
||||
const userData = await userResponse.json();
|
||||
const user = userData.data;
|
||||
|
||||
// Fetch user's comments count
|
||||
const commentsResponse = await fetch(`/api/comments?filter[user_created][_eq]=${id}&aggregate[count]=*`);
|
||||
const commentsData = await commentsResponse.json();
|
||||
const commentsCount = commentsData.data?.[0]?.count || 0;
|
||||
|
||||
// Fetch user's video likes count
|
||||
const likesResponse = await fetch(`/api/items/sexy_video_likes?filter[user_id][_eq]=${id}&aggregate[count]=*`);
|
||||
const likesData = await likesResponse.json();
|
||||
const likesCount = likesData.data?.[0]?.count || 0;
|
||||
|
||||
// Fetch gamification data
|
||||
const gamificationResponse = await fetch(`/api/sexy/gamification/user/${id}`);
|
||||
let gamification = null;
|
||||
if (gamificationResponse.ok) {
|
||||
gamification = await gamificationResponse.json();
|
||||
}
|
||||
const gamification = data.userGamification;
|
||||
|
||||
return {
|
||||
user,
|
||||
user: data.userProfile,
|
||||
stats: {
|
||||
comments_count: commentsCount,
|
||||
likes_count: likesCount,
|
||||
comments_count: gamification?.stats?.comments_count || 0,
|
||||
likes_count: 0,
|
||||
},
|
||||
gamification,
|
||||
isOwnProfile: locals.authStatus.user?.id === id,
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
||||
proxy: {
|
||||
"/api": {
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
target: "http://localhost:8055",
|
||||
target: "http://localhost:4000",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
|
||||
Reference in New Issue
Block a user