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>
This commit is contained in:
2026-03-05 11:01:11 +01:00
parent c6126c13e9
commit 97269788ee
31 changed files with 839 additions and 822 deletions

View File

@@ -11,7 +11,7 @@ export const getGraphQLClient = (fetchFn?: typeof globalThis.fetch) =>
});
export const getAssetUrl = (
id: string,
id: string | null | undefined,
transform?: "mini" | "thumbnail" | "preview" | "medium" | "banner",
) => {
if (!id) {

View File

@@ -128,7 +128,7 @@
user={{
name:
authStatus.user!.artist_name || authStatus.user!.email.split("@")[0] || "User",
avatar: getAssetUrl(authStatus.user!.avatar?.id, "mini")!,
avatar: getAssetUrl(authStatus.user!.avatar, "mini")!,
email: authStatus.user!.email,
}}
onLogout={handleLogout}
@@ -171,7 +171,7 @@
<div class="relative flex items-center gap-4">
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
<AvatarImage
src={getAssetUrl(authStatus.user!.avatar?.id, "mini")}
src={getAssetUrl(authStatus.user!.avatar, "mini")}
alt={authStatus.user!.artist_name}
/>
<AvatarFallback

View File

@@ -4,7 +4,7 @@
interface Props {
title: string;
description: string;
description: string | null | undefined;
image?: string;
}

View File

@@ -2,7 +2,7 @@
import { _ } from "svelte-i18n";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import type { Recording } from "$lib/types";
import type { Recording, DeviceInfo } from "$lib/types";
import { cn } from "$lib/utils";
interface Props {
@@ -68,18 +68,18 @@
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
<span class="font-medium text-sm">{recording.events.length}</span>
<span class="font-medium text-sm">{recording.events?.length ?? 0}</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30">
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
<span class="font-medium text-sm">{recording.device_info.length}</span>
<span class="font-medium text-sm">{recording.device_info?.length ?? 0}</span>
</div>
</div>
<!-- Device Info -->
<div class="space-y-1">
{#each recording.device_info.slice(0, 2) as device (device.name)}
{#each ((recording.device_info ?? []) as DeviceInfo[]).slice(0, 2) as device (device.name)}
<div
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
>
@@ -88,9 +88,9 @@
<span class="text-xs opacity-60">{device.capabilities.join(", ")}</span>
</div>
{/each}
{#if recording.device_info.length > 2}
{#if (recording.device_info?.length ?? 0) > 2}
<div class="text-xs text-muted-foreground/60 px-2">
+{recording.device_info.length - 2} more device{recording.device_info.length - 2 > 1
+{(recording.device_info?.length ?? 0) - 2} more device{(recording.device_info?.length ?? 0) - 2 > 1
? "s"
: ""}
</div>

View File

@@ -510,7 +510,7 @@ const UPDATE_PROFILE_MUTATION = gql`
}
`;
export async function updateProfile(user: Partial<User>) {
export async function updateProfile(user: Partial<User> & { password?: string }) {
return loggedApiCall(
"updateProfile",
async () => {
@@ -551,7 +551,7 @@ export async function getStats(fetchFn?: typeof globalThis.fetch) {
// Stub — Directus folder concept dropped
export async function getFolders(_fetchFn?: typeof globalThis.fetch) {
return loggedApiCall("getFolders", async () => []);
return loggedApiCall("getFolders", async () => [] as { id: string; name: string }[]);
}
// ─── Files ───────────────────────────────────────────────────────────────────
@@ -618,6 +618,7 @@ export async function getCommentsForVideo(item: string, fetchFn?: typeof globalT
id: string;
first_name: string | null;
last_name: string | null;
artist_name: string | null;
avatar: string | null;
} | null;
}[];

View File

@@ -1,24 +1,38 @@
import { type ButtplugClientDevice } from "@sexy.pivoine.art/buttplug";
export type {
MediaFile,
User,
CurrentUser,
VideoModel,
VideoFile,
Video,
ModelPhoto,
Model,
ArticleAuthor,
Article,
CommentUser,
Comment,
Stats,
RecordedEvent,
DeviceInfo,
Recording,
VideoLikeStatus,
VideoPlayRecord,
VideoLikeResponse,
VideoPlayResponse,
VideoAnalytics,
Analytics,
LeaderboardEntry,
UserStats,
UserAchievement,
RecentPoint,
UserGamification,
Achievement,
} from "@sexy.pivoine.art/types";
export interface User {
id: string;
first_name: string;
last_name: string;
artist_name: string;
slug: string;
email: string;
description: string;
tags: string[];
avatar: string | File;
password: string;
directus_users_id?: User;
}
import type { CurrentUser } from "@sexy.pivoine.art/types";
import type { ButtplugClientDevice } from "@sexy.pivoine.art/buttplug";
export interface CurrentUser extends User {
avatar: File;
role: "model" | "viewer" | "admin";
policies: string[];
}
// ─── Frontend-only types ─────────────────────────────────────────────────────
export interface AuthStatus {
authenticated: boolean;
@@ -28,78 +42,11 @@ export interface AuthStatus {
};
}
export interface File {
id: string;
filesize: number;
export interface ShareContent {
title: string;
description: string;
duration: number;
directus_files_id?: File;
}
export interface Article {
id: string;
slug: string;
title: string;
excerpt: string;
content: string;
image: string;
tags: string[];
publish_date: Date;
author: {
first_name: string;
last_name: string;
avatar: string;
description?: string;
website?: string;
};
category: string;
featured?: boolean;
}
export interface Model {
id: string;
slug: string;
artist_name: string;
description: string;
avatar: string;
category: string;
tags: string[];
join_date: Date;
featured?: boolean;
photos: File[];
banner?: File;
}
export interface Video {
id: string;
slug: string;
title: string;
description: string;
image: string;
movie: File;
models: User[];
tags: string[];
upload_date: Date;
premium?: boolean;
featured?: boolean;
likes_count?: number;
plays_count?: number;
views_count?: number;
}
export interface Comment {
id: string;
comment: string;
item: string;
user_created: User;
date_created: Date;
}
export interface Stats {
videos_count: number;
models_count: number;
viewers_count: number;
url: string;
type: "video" | "model" | "article" | "link";
}
export interface DeviceActuator {
@@ -120,86 +67,3 @@ export interface BluetoothDevice {
lastSeen: Date;
info: ButtplugClientDevice;
}
export interface ShareContent {
title: string;
description: string;
url: string;
type: "video" | "model" | "article" | "link";
}
export interface RecordedEvent {
timestamp: number;
deviceIndex: number;
deviceName: string;
actuatorIndex: number;
actuatorType: string;
value: number;
}
export interface DeviceInfo {
name: string;
index: number;
capabilities: string[];
}
export interface Recording {
id: string;
title: string;
description?: string;
slug: string;
duration: number;
events: RecordedEvent[];
device_info: DeviceInfo[];
user_created: string | User;
date_created: Date;
date_updated?: Date;
status: "draft" | "published" | "archived";
tags?: string[];
linked_video?: string | Video;
featured?: boolean;
public?: boolean;
}
export interface VideoLikeStatus {
liked: boolean;
}
export interface VideoPlayRecord {
id: string;
video_id: string;
duration_watched?: number;
completed: boolean;
}
export interface VideoLikeResponse {
liked: boolean;
likes_count: number;
}
export interface VideoPlayResponse {
success: boolean;
play_id: string;
plays_count: number;
}
export interface VideoAnalytics {
id: string;
title: string;
slug: string;
upload_date: Date;
likes: number;
plays: number;
completed_plays: number;
completion_rate: number;
avg_watch_time: number;
}
export interface Analytics {
total_videos: number;
total_likes: number;
total_plays: number;
plays_by_date: Record<string, number>;
likes_by_date: Record<string, number>;
videos: VideoAnalytics[];
}

View File

@@ -14,16 +14,16 @@ export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
export const calcReadingTime = (text: string) => {
export const calcReadingTime = (text: string | null | undefined) => {
const wordsPerMinute = 200; // Average case.
const textLength = text.split(" ").length; // Split by words
const textLength = (text ?? "").split(" ").length; // Split by words
if (textLength > 0) {
return Math.ceil(textLength / wordsPerMinute);
}
return 0;
};
export const getUserInitials = (name: string) => {
export const getUserInitials = (name: string | null | undefined) => {
if (!name) return "??";
return name
.split(" ")