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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | null | undefined;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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(" ")
|
||||
|
||||
Reference in New Issue
Block a user