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

@@ -10,8 +10,8 @@
const { data } = $props();
// Format points with comma separator
function formatPoints(points: number): string {
return Math.round(points).toLocaleString($locale || "en");
function formatPoints(points: number | null | undefined): string {
return Math.round(points ?? 0).toLocaleString($locale || "en");
}
// Get medal emoji for top 3
@@ -29,7 +29,7 @@
}
// Get user initials
function getUserInitials(name: string): string {
function getUserInitials(name: string | null | undefined): string {
if (!name) return "?";
const parts = name.split(" ");
if (parts.length >= 2) {

View File

@@ -26,8 +26,8 @@
.filter((article) => {
const matchesSearch =
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.author.first_name.toLowerCase().includes(searchQuery.toLowerCase());
article.excerpt?.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.author?.first_name?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === "all" || article.category === categoryFilter;
return matchesSearch && matchesCategory;
})
@@ -189,12 +189,12 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<img
src={getAssetUrl(featuredArticle.author.avatar, "mini")}
alt={featuredArticle.author.first_name}
src={getAssetUrl(featuredArticle.author?.avatar, "mini")}
alt={featuredArticle.author?.first_name}
class="w-10 h-10 rounded-full object-cover"
/>
<div>
<p class="font-medium">{featuredArticle.author.first_name}</p>
<p class="font-medium">{featuredArticle.author?.first_name}</p>
<div class="flex items-center gap-3 text-sm text-muted-foreground">
<span>{timeAgo.format(new Date(featuredArticle.publish_date))}</span>
<span></span>
@@ -273,7 +273,7 @@
<!-- Tags -->
<div class="flex flex-wrap gap-2 mb-4">
{#each article.tags.slice(0, 3) as tag (tag)}
{#each (article.tags ?? []).slice(0, 3) as tag (tag)}
<a
class="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full"
href="/tags/{tag}"
@@ -287,12 +287,12 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<img
src={getAssetUrl(article.author.avatar, "mini")}
alt={article.author.first_name}
src={getAssetUrl(article.author?.avatar, "mini")}
alt={article.author?.first_name}
class="w-8 h-8 rounded-full object-cover"
/>
<div>
<p class="text-sm font-medium">{article.author.first_name}</p>
<p class="text-sm font-medium">{article.author?.first_name}</p>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="icon-[ri--calendar-line] w-4 h-4"></span>
{timeAgo.format(new Date(article.publish_date))}

View File

@@ -139,6 +139,7 @@
</div>
<!-- Author Bio -->
{#if data.article.author}
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
<CardContent class="p-6">
<div class="flex items-start gap-4">
@@ -164,15 +165,13 @@
>
{data.article.author.website}
</a>
<!-- <a href="https://{data.article.author.social.website}" class="text-primary hover:underline">
{data.article.author.social.website}
</a> -->
</div>
{/if}
</div>
</div>
</CardContent>
</Card>
{/if}
</article>
<!-- Sidebar -->

View File

@@ -10,7 +10,7 @@ export async function load({ locals, fetch }) {
const recordings = await getRecordings(fetch).catch(() => []);
const analytics = isModel(locals.authStatus.user)
const analytics = isModel(locals.authStatus.user!)
? await getAnalytics(fetch).catch(() => null)
: null;

View File

@@ -34,7 +34,7 @@
let lastName = $state(data.authStatus.user!.last_name);
let artistName = $state(data.authStatus.user!.artist_name);
let description = $state(data.authStatus.user!.description);
let tags = $state(data.authStatus.user!.tags);
let tags = $state(data.authStatus.user!.tags ?? undefined);
let email = $state(data.authStatus.user!.email);
let password = $state("");
@@ -60,8 +60,8 @@
let avatarId = undefined;
if (!avatar?.id && data.authStatus.user!.avatar?.id) {
await removeFile(data.authStatus.user!.avatar.id);
if (!avatar?.id && data.authStatus.user!.avatar) {
await removeFile(data.authStatus.user!.avatar);
}
if (avatar?.file) {
@@ -143,10 +143,10 @@
function setExistingAvatar() {
if (data.authStatus.user!.avatar) {
avatar = {
id: data.authStatus.user!.avatar.id,
url: getAssetUrl(data.authStatus.user!.avatar.id, "mini")!,
name: data.authStatus.user!.artist_name,
size: data.authStatus.user!.avatar.filesize,
id: data.authStatus.user!.avatar,
url: getAssetUrl(data.authStatus.user!.avatar, "mini")!,
name: data.authStatus.user!.artist_name ?? "",
size: 0,
};
} else {
avatar = undefined;

View File

@@ -18,9 +18,9 @@
.filter((model) => {
const matchesSearch =
searchQuery === "" ||
model.artist_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesCategory = categoryFilter === "all" || model.category === categoryFilter;
model.artist_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesCategory = categoryFilter === "all";
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
@@ -31,7 +31,7 @@
// }
// if (sortBy === "rating") return b.rating - a.rating;
// if (sortBy === "videos") return b.videos - a.videos;
return a.artist_name.localeCompare(b.artist_name);
return (a.artist_name ?? "").localeCompare(b.artist_name ?? "");
});
});
</script>
@@ -205,7 +205,7 @@
<!-- Stats -->
<div class="flex items-center justify-between text-sm text-muted-foreground mb-4">
<!-- <span>{model.videos} videos</span> -->
<span class="capitalize">{model.category}</span>
<!-- category not available -->
</div>
<!-- Action Buttons -->

View File

@@ -16,7 +16,7 @@
const { data } = $props();
let images = $derived(
data.model.photos.map((p) => ({
(data.model.photos ?? []).map((p) => ({
...p,
url: getAssetUrl(p.id),
thumbnail: getAssetUrl(p.id, "thumbnail"),
@@ -29,7 +29,7 @@
</script>
<Meta
title={data.model.artist_name}
title={data.model.artist_name ?? ""}
description={data.model.description}
image={getAssetUrl(data.model.avatar, "medium")!}
/>
@@ -44,7 +44,7 @@
{#if data.model.banner}
<img
src={getAssetUrl(data.model.banner, "banner")}
alt={$_(data.model.artist_name)}
alt={data.model.artist_name ?? ""}
class="w-full h-full object-cover"
/>
<div

View File

@@ -1,9 +1,10 @@
import { getRecording } from "$lib/services";
import type { Recording } from "$lib/types";
export async function load({ locals, url, fetch }) {
const recordingId = url.searchParams.get("recording");
let recording = null;
let recording: Recording | null = null;
if (recordingId && locals.authStatus.authenticated) {
try {
recording = await getRecording(recordingId, fetch);

View File

@@ -9,7 +9,6 @@
InputType,
DeviceOutputValueConstructor,
} from "@sexy.pivoine.art/buttplug";
import type { ButtplugMessage } from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
@@ -74,7 +73,8 @@
}
}
function handleInputReading(msg: ButtplugMessage) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleInputReading(msg: any) {
if (msg.InputReading === undefined) return;
const reading = msg.InputReading;
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
@@ -92,7 +92,7 @@
if (!feature) return;
actuator.value = value;
const outputType = actuator.outputType as OutputType;
const outputType = actuator.outputType as typeof OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
// Capture event if recording
@@ -225,7 +225,7 @@
}
// Check if we need to map devices
if (deviceMappings.size === 0 && data.recording.device_info.length > 0) {
if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) {
showMappingDialog = true;
return;
}
@@ -284,7 +284,7 @@
function scheduleNextEvent() {
if (!data.recording || !isPlaying || !playbackStartTime) return;
const events = data.recording.events;
const events = (data.recording.events ?? []) as RecordedEvent[];
if (currentEventIndex >= events.length) {
stopPlayback();
toast.success("Playback finished");
@@ -332,7 +332,7 @@
// Send command to device via feature
const feature = device.info.features.get(actuator.featureIndex);
if (feature) {
const outputType = actuator.outputType as OutputType;
const outputType = actuator.outputType as typeof OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
}
@@ -347,9 +347,10 @@
playbackProgress = targetTime;
// Find the event index at this time
currentEventIndex = data.recording.events.findIndex((e) => e.timestamp >= targetTime);
const seekEvents = (data.recording.events ?? []) as RecordedEvent[];
currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime);
if (currentEventIndex === -1) {
currentEventIndex = data.recording.events.length;
currentEventIndex = seekEvents.length;
}
if (isPlaying) {
@@ -548,11 +549,11 @@
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-muted-foreground">Events</p>
<p class="text-sm font-medium">{data.recording.events.length}</p>
<p class="text-sm font-medium">{data.recording.events?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Devices</p>
<p class="text-sm font-medium">{data.recording.device_info.length}</p>
<p class="text-sm font-medium">{data.recording.device_info?.length ?? 0}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Status</p>
@@ -603,7 +604,7 @@
{#if data.recording}
<DeviceMappingDialog
open={showMappingDialog}
recordedDevices={data.recording.device_info}
recordedDevices={(data.recording.device_info ?? []) as DeviceInfo[]}
connectedDevices={devices}
onConfirm={handleMappingConfirm}
onCancel={handleMappingCancel}

View File

@@ -7,7 +7,7 @@ export const GET = async () => {
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
paramValues: {
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug),
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug).filter((s): s is string => s !== null),
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
},
defaultChangefreq: "always",

View File

@@ -4,7 +4,7 @@ import { getItemsByTag } from "$lib/services";
const getItems = (category, tag: string, fetch) => {
return getItemsByTag(category, fetch).then((items) =>
items
?.filter((i) => i.tags.includes(tag))
?.filter((i) => i.tags?.includes(tag))
.map((i) => ({ ...i, category, title: i["artist_name"] || i["title"] })),
);
};

View File

@@ -77,8 +77,23 @@ export const load: PageServerLoad = async ({ params, locals, fetch }) => {
achievements_count: number | null;
rank: number;
} | null;
achievements: unknown[];
recent_points: unknown[];
achievements: {
id: string;
code: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
date_unlocked: string;
progress: number | null;
required_count: number;
}[];
recent_points: {
action: string;
points: number;
date_created: string;
recording_id: string | null;
}[];
} | null;
}>(USER_PROFILE_QUERY, { id });

View File

@@ -27,7 +27,7 @@
<Meta
title={displayName}
description={data.user.description || `${displayName}'s profile`}
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") : undefined}
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") ?? undefined : undefined}
/>
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
@@ -91,12 +91,7 @@
>
</div>
{#if data.user.location}
<div class="flex items-center gap-2 text-muted-foreground mb-4">
<span class="icon-[ri--map-pin-line] w-4 h-4"></span>
<span>{data.user.location}</span>
</div>
{/if}
{#if data.user.description}
<p class="text-muted-foreground mb-4">
@@ -148,7 +143,7 @@
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="text-center p-4 rounded-lg bg-accent/10">
<div class="text-3xl font-bold text-primary">
{Math.round(data.gamification.stats.total_weighted_points)}
{Math.round(data.gamification.stats.total_weighted_points ?? 0)}
</div>
<div class="text-sm text-muted-foreground mt-1">
{$_("gamification.points")}
@@ -188,7 +183,7 @@
{$_("gamification.achievements")} ({data.gamification.achievements.length})
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{#each data.gamification.achievements as achievement (achievement.id)}
{#each (data.gamification?.achievements ?? []) as achievement (achievement.id)}
<div
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors"
title={achievement.description}
@@ -199,7 +194,7 @@
</span>
{#if achievement.date_unlocked}
<span class="text-xs text-muted-foreground">
{new Date(achievement.date_unlocked).toLocaleDateString($locale)}
{new Date(achievement.date_unlocked).toLocaleDateString($locale ?? undefined)}
</span>
{/if}
</div>

View File

@@ -377,7 +377,7 @@
<div class="flex gap-3 mb-6">
<Avatar class="h-8 w-8 ring-2 ring-accent/20 transition-all duration-200">
<AvatarImage
src={getAssetUrl(data.authStatus.user!.avatar.id, "mini")}
src={getAssetUrl(data.authStatus.user!.avatar, "mini")}
alt={data.authStatus.user!.artist_name}
/>
<AvatarFallback
@@ -432,27 +432,27 @@
<div class="space-y-4">
{#each data.comments as comment (comment.id)}
<div class="flex gap-3">
<a href="/users/{comment.user_created.id}" class="flex-shrink-0">
<a href="/users/{comment.user?.id}" class="flex-shrink-0">
<Avatar
class="h-8 w-8 ring-2 ring-accent/20 hover:ring-primary/40 transition-all duration-200 cursor-pointer"
>
<AvatarImage
src={getAssetUrl(comment.user_created.avatar as string, "mini")}
alt={comment.user_created.artist_name}
src={getAssetUrl(comment.user?.avatar, "mini")}
alt={comment.user?.artist_name}
/>
<AvatarFallback
class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200"
>
{getUserInitials(comment.user_created.artist_name)}
{getUserInitials(comment.user?.artist_name)}
</AvatarFallback>
</Avatar>
</a>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<a
href="/users/{comment.user_created.id}"
href="/users/{comment.user?.id}"
class="font-medium text-sm hover:text-primary transition-colors"
>{comment.user_created.artist_name}</a
>{comment.user?.artist_name}</a
>
<span class="text-xs text-muted-foreground"
>{timeAgo.format(new Date(comment.date_created))}</span