feat: fix recording save and add publish/unpublish support

- Fix broken fetch("/api/sexy/recordings") → use createRecording GraphQL service
- Round duration to integer before sending (GraphQL Int type)
- Add updateRecording mutation to services
- Add publish/unpublish buttons to RecordingCard (draft ↔ published)
- Remove "Go to Play" button from recordings page header
- Add publish/unpublish i18n keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 18:26:42 +01:00
parent d9a60f0572
commit fddc3f15d0
5 changed files with 91 additions and 28 deletions

View File

@@ -8,10 +8,12 @@
interface Props { interface Props {
recording: Recording; recording: Recording;
onPlay?: (id: string) => void; onPlay?: (id: string) => void;
onPublish?: (id: string) => void;
onUnpublish?: (id: string) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
} }
let { recording, onPlay, onDelete }: Props = $props(); let { recording, onPlay, onPublish, onUnpublish, onDelete }: Props = $props();
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000); const totalSeconds = Math.floor(ms / 1000);
@@ -149,12 +151,35 @@
{$_("recording_card.play")} {$_("recording_card.play")}
</Button> </Button>
{/if} {/if}
{#if onPublish && recording.status === "draft"}
<Button
size="sm"
variant="outline"
onclick={() => onPublish?.(recording.id)}
class="cursor-pointer border-primary/20 hover:bg-primary/10 hover:text-primary"
title={$_("recording_card.publish")}
>
<span class="icon-[ri--send-plane-line] w-4 h-4"></span>
</Button>
{/if}
{#if onUnpublish && recording.status === "published"}
<Button
size="sm"
variant="outline"
onclick={() => onUnpublish?.(recording.id)}
class="cursor-pointer border-muted-foreground/20 hover:bg-muted/50 hover:text-muted-foreground"
title={$_("recording_card.unpublish")}
>
<span class="icon-[ri--arrow-go-back-line] w-4 h-4"></span>
</Button>
{/if}
{#if onDelete} {#if onDelete}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onclick={() => onDelete?.(recording.id)} onclick={() => onDelete?.(recording.id)}
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive" class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
title={$_("common.delete")}
> >
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span> <span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
</Button> </Button>

View File

@@ -151,6 +151,10 @@ export default {
delete_confirm: "Are you sure you want to delete this recording?", delete_confirm: "Are you sure you want to delete this recording?",
delete_success: "Recording deleted successfully", delete_success: "Recording deleted successfully",
delete_error: "Failed to delete recording", delete_error: "Failed to delete recording",
publish_success: "Recording published successfully",
publish_error: "Failed to publish recording",
unpublish_success: "Recording unpublished",
unpublish_error: "Failed to unpublish recording",
}, },
}, },
recording_card: { recording_card: {
@@ -161,6 +165,8 @@ export default {
status_draft: "Draft", status_draft: "Draft",
status_published: "Published", status_published: "Published",
play: "Play", play: "Play",
publish: "Publish",
unpublish: "Unpublish",
edit: "Edit", edit: "Edit",
delete: "Delete", delete: "Delete",
public: "Public", public: "Public",

View File

@@ -902,6 +902,32 @@ export async function createRecording(
); );
} }
const UPDATE_RECORDING_MUTATION = gql`
mutation UpdateRecording($id: String!, $status: String, $public: Boolean) {
updateRecording(id: $id, status: $status, public: $public) {
id
status
public
}
}
`;
export async function updateRecording(
id: string,
fields: { status?: string; public?: boolean },
) {
return loggedApiCall(
"updateRecording",
async () => {
const data = await getGraphQLClient().request<{ updateRecording: Recording }>(
UPDATE_RECORDING_MUTATION,
{ id, ...fields },
);
return data.updateRecording;
},
);
}
const DELETE_RECORDING_MUTATION = gql` const DELETE_RECORDING_MUTATION = gql`
mutation DeleteRecording($id: String!) { mutation DeleteRecording($id: String!) {
deleteRecording(id: $id) deleteRecording(id: $id)

View File

@@ -3,7 +3,7 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { deleteRecording } from "$lib/services"; import { deleteRecording, updateRecording } from "$lib/services";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import * as Empty from "$lib/components/ui/empty"; import * as Empty from "$lib/components/ui/empty";
import * as Dialog from "$lib/components/ui/dialog"; import * as Dialog from "$lib/components/ui/dialog";
@@ -38,6 +38,26 @@
} }
} }
async function handlePublishRecording(id: string) {
try {
await updateRecording(id, { status: "published" });
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "published" } : r));
toast.success($_("me.recordings.publish_success"));
} catch {
toast.error($_("me.recordings.publish_error"));
}
}
async function handleUnpublishRecording(id: string) {
try {
await updateRecording(id, { status: "draft" });
recordings = recordings.map((r) => (r.id === id ? { ...r, status: "draft" } : r));
toast.success($_("me.recordings.unpublish_success"));
} catch {
toast.error($_("me.recordings.unpublish_error"));
}
}
function handlePlayRecording(id: string) { function handlePlayRecording(id: string) {
goto(`/play?recording=${id}`); goto(`/play?recording=${id}`);
} }
@@ -46,15 +66,8 @@
<Meta title={$_("me.recordings.title")} /> <Meta title={$_("me.recordings.title")} />
<div class="py-3 sm:py-6 lg:pl-6"> <div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1> <h1 class="text-2xl font-bold">{$_("me.recordings.title")}</h1>
<Button
href="/play"
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--rocket-line] w-4 h-4 mr-2"></span>
{$_("me.recordings.go_to_play")}
</Button>
</div> </div>
{#if recordings.length === 0} {#if recordings.length === 0}
@@ -82,6 +95,8 @@
<RecordingCard <RecordingCard
{recording} {recording}
onPlay={handlePlayRecording} onPlay={handlePlayRecording}
onPublish={handlePublishRecording}
onUnpublish={handleUnpublishRecording}
onDelete={handleDeleteRecording} onDelete={handleDeleteRecording}
/> />
{/each} {/each}

View File

@@ -10,6 +10,7 @@
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte"; import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types"; import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { createRecording } from "$lib/services";
import SexyBackground from "$lib/components/background/background.svelte"; import SexyBackground from "$lib/components/background/background.svelte";
// Runtime buttplug values — loaded dynamically from the buttplug nginx container // Runtime buttplug values — loaded dynamically from the buttplug nginx container
@@ -173,26 +174,16 @@
})); }));
try { try {
const response = await fetch("/api/sexy/recordings", { await createRecording({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: data.title, title: data.title,
description: data.description, description: data.description,
duration: recordingDuration, duration: Math.round(recordingDuration),
events: recordedEvents, events: recordedEvents,
device_info: deviceInfo, device_info: deviceInfo,
tags: data.tags, tags: data.tags,
status: "draft", status: "draft",
}),
}); });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
toast.success("Recording saved successfully!"); toast.success("Recording saved successfully!");
showSaveDialog = false; showSaveDialog = false;
recordedEvents = []; recordedEvents = [];