feat: add buttplug device recording feature (Phase 1 & 2)
Implemented complete infrastructure for recording, saving, and managing buttplug device patterns with precise event timing. **Phase 1: Backend & Infrastructure** - Added Directus schema for sexy_recordings collection with all fields (id, status, user_created, title, description, slug, duration, events, device_info, tags, linked_video, featured, public) - Created REST API endpoints in bundle extension: * GET /sexy/recordings - list user recordings with filtering * GET /sexy/recordings/:id - get single recording * POST /sexy/recordings - create new recording with validation * PATCH /sexy/recordings/:id - update recording (owner only) * DELETE /sexy/recordings/:id - soft delete by archiving - Added TypeScript types: RecordedEvent, DeviceInfo, Recording - Created frontend services: getRecordings(), deleteRecording() - Built RecordingCard component with stats, device info, and actions - Added Recordings tab to /me dashboard page with grid layout - Added i18n translations for recordings UI **Phase 2: Recording Capture** - Implemented recording state management in /play page - Added Start/Stop Recording buttons with visual indicators - Capture device events with precise timestamps during recording - Normalize actuator values (0-100) for cross-device compatibility - Created RecordingSaveDialog component with: * Recording stats display (duration, events, devices) * Form inputs (title, description, tags) * Device information preview - Integrated save recording API call from play page - Added success/error toast notifications - Automatic event filtering during recording **Technical Details** - Events stored as JSON array with timestamp, deviceIndex, deviceName, actuatorIndex, actuatorType, and normalized value - Device metadata includes name, index, and capability list - Slug auto-generated from title for SEO-friendly URLs - Status workflow: draft → published → archived - Permission checks: users can only access own recordings or public ones - Frontend uses performance.now() for millisecond precision timing **User Flow** 1. User scans and connects devices on /play page 2. Clicks "Start Recording" to begin capturing events 3. Manipulates device sliders - all changes are recorded 4. Clicks "Stop Recording" to end capture 5. Save dialog appears with recording preview and form 6. User enters title, description, tags and saves 7. Recording appears in dashboard /me Recordings tab 8. Can play back, edit, or delete recordings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2817
directus.yaml
Normal file
2817
directus.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,5 +57,193 @@ export default {
|
|||||||
videos_count: videosCount[0].count,
|
videos_count: videosCount[0].count,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /sexy/recordings - List user's recordings
|
||||||
|
router.get("/recordings", async (req, res) => {
|
||||||
|
const { accountability } = context;
|
||||||
|
if (!accountability?.user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingsService = new ItemsService("sexy_recordings", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status, tags, linked_video, limit, page } = req.query;
|
||||||
|
const filter: any = {
|
||||||
|
user_created: {
|
||||||
|
_eq: accountability.user,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status) filter.status = { _eq: status };
|
||||||
|
if (tags) filter.tags = { _contains: tags };
|
||||||
|
if (linked_video) filter.linked_video = { _eq: linked_video };
|
||||||
|
|
||||||
|
const recordings = await recordingsService.readByQuery({
|
||||||
|
filter,
|
||||||
|
limit: limit ? parseInt(limit as string) : 50,
|
||||||
|
page: page ? parseInt(page as string) : 1,
|
||||||
|
sort: ["-date_created"],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(recordings);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /sexy/recordings/:id - Get single recording
|
||||||
|
router.get("/recordings/:id", async (req, res) => {
|
||||||
|
const { accountability } = context;
|
||||||
|
if (!accountability?.user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingsService = new ItemsService("sexy_recordings", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recording = await recordingsService.readOne(req.params.id);
|
||||||
|
|
||||||
|
// Check if user owns the recording or if it's public
|
||||||
|
if (
|
||||||
|
recording.user_created !== accountability.user &&
|
||||||
|
!recording.public
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(recording);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(404).json({ error: "Recording not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /sexy/recordings - Create new recording
|
||||||
|
router.post("/recordings", async (req, res) => {
|
||||||
|
const { accountability } = context;
|
||||||
|
if (!accountability?.user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingsService = new ItemsService("sexy_recordings", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { title, description, duration, events, device_info, tags, linked_video, status } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!title || !duration || !events || !device_info) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Missing required fields: title, duration, events, device_info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate events structure
|
||||||
|
if (!Array.isArray(events) || events.length === 0) {
|
||||||
|
return res.status(400).json({ error: "Events must be a non-empty array" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug from title
|
||||||
|
const slug = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recording = await recordingsService.createOne({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
slug,
|
||||||
|
duration,
|
||||||
|
events,
|
||||||
|
device_info,
|
||||||
|
tags: tags || [],
|
||||||
|
linked_video: linked_video || null,
|
||||||
|
status: status || "draft",
|
||||||
|
public: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(recording);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message || "Failed to create recording" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /sexy/recordings/:id - Update recording
|
||||||
|
router.patch("/recordings/:id", async (req, res) => {
|
||||||
|
const { accountability } = context;
|
||||||
|
if (!accountability?.user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingsService = new ItemsService("sexy_recordings", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await recordingsService.readOne(req.params.id);
|
||||||
|
|
||||||
|
// Only allow owner to update
|
||||||
|
if (existing.user_created !== accountability.user) {
|
||||||
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, tags, status, public: isPublic, linked_video } = req.body;
|
||||||
|
const updates: any = {};
|
||||||
|
|
||||||
|
if (title !== undefined) {
|
||||||
|
updates.title = title;
|
||||||
|
updates.slug = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
if (description !== undefined) updates.description = description;
|
||||||
|
if (tags !== undefined) updates.tags = tags;
|
||||||
|
if (status !== undefined) updates.status = status;
|
||||||
|
if (isPublic !== undefined) updates.public = isPublic;
|
||||||
|
if (linked_video !== undefined) updates.linked_video = linked_video;
|
||||||
|
|
||||||
|
const recording = await recordingsService.updateOne(req.params.id, updates);
|
||||||
|
res.json(recording);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message || "Failed to update recording" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /sexy/recordings/:id - Delete (archive) recording
|
||||||
|
router.delete("/recordings/:id", async (req, res) => {
|
||||||
|
const { accountability } = context;
|
||||||
|
if (!accountability?.user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingsService = new ItemsService("sexy_recordings", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await recordingsService.readOne(req.params.id);
|
||||||
|
|
||||||
|
// Only allow owner to delete
|
||||||
|
if (existing.user_created !== accountability.user) {
|
||||||
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete by setting status to archived
|
||||||
|
await recordingsService.updateOne(req.params.id, {
|
||||||
|
status: "archived",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message || "Failed to delete recording" });
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recording: Recording;
|
||||||
|
onPlay?: (id: string) => void;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { recording, onPlay, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "published":
|
||||||
|
return "text-green-400 bg-green-400/20";
|
||||||
|
case "draft":
|
||||||
|
return "text-yellow-400 bg-yellow-400/20";
|
||||||
|
case "archived":
|
||||||
|
return "text-red-400 bg-red-400/20";
|
||||||
|
default:
|
||||||
|
return "text-gray-400 bg-gray-400/20";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<CardHeader class="pb-3">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<h3
|
||||||
|
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{recording.title}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full",
|
||||||
|
getStatusColor(recording.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{$_(`recording_card.status_${recording.status}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if recording.description}
|
||||||
|
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{recording.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
|
||||||
|
<span class="text-xs text-muted-foreground"
|
||||||
|
>{$_("recording_card.duration")}</span
|
||||||
|
>
|
||||||
|
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Info -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each recording.device_info.slice(0, 2) as device}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--rocket-line] w-3 h-3"></span>
|
||||||
|
<span>{device.name}</span>
|
||||||
|
<span class="text-xs opacity-60">• {device.capabilities.join(", ")}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if recording.device_info.length > 2}
|
||||||
|
<div class="text-xs text-muted-foreground/60 px-2">
|
||||||
|
+{recording.device_info.length - 2} more device{recording.device_info.length -
|
||||||
|
2 >
|
||||||
|
1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
{#if recording.tags && recording.tags.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each recording.tags as tag}
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span>
|
||||||
|
{new Date(recording.date_created).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
{#if recording.public}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="icon-[ri--global-line] w-3 h-3"></span>
|
||||||
|
{$_("recording_card.public")}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="icon-[ri--lock-line] w-3 h-3"></span>
|
||||||
|
{$_("recording_card.private")}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if recording.linked_video}
|
||||||
|
<span class="flex items-center gap-1 text-accent">
|
||||||
|
<span class="icon-[ri--video-line] w-3 h-3"></span>
|
||||||
|
{$_("recording_card.linked_video")}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
{#if onPlay}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => onPlay?.(recording.id)}
|
||||||
|
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
|
||||||
|
{$_("recording_card.play")}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if onDelete}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => onDelete?.(recording.id)}
|
||||||
|
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -118,6 +118,33 @@ export default {
|
|||||||
confirm_password: "Confirm Password",
|
confirm_password: "Confirm Password",
|
||||||
confirm_password_placeholder: "Confirm your password",
|
confirm_password_placeholder: "Confirm your password",
|
||||||
},
|
},
|
||||||
|
recordings: {
|
||||||
|
title: "Recordings",
|
||||||
|
description: "Manage your device recordings",
|
||||||
|
no_recordings: "You haven't created any recordings yet",
|
||||||
|
no_recordings_description:
|
||||||
|
"Start recording device patterns from the Play page to create interactive content",
|
||||||
|
go_to_play: "Go to Play",
|
||||||
|
loading: "Loading recordings...",
|
||||||
|
delete_confirm: "Are you sure you want to delete this recording?",
|
||||||
|
delete_success: "Recording deleted successfully",
|
||||||
|
delete_error: "Failed to delete recording",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recording_card: {
|
||||||
|
duration: "Duration",
|
||||||
|
events: "Events",
|
||||||
|
devices: "Devices",
|
||||||
|
created: "Created",
|
||||||
|
status_draft: "Draft",
|
||||||
|
status_published: "Published",
|
||||||
|
status_archived: "Archived",
|
||||||
|
play: "Play",
|
||||||
|
edit: "Edit",
|
||||||
|
delete: "Delete",
|
||||||
|
public: "Public",
|
||||||
|
private: "Private",
|
||||||
|
linked_video: "Linked to video",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
readComments,
|
readComments,
|
||||||
aggregate,
|
aggregate,
|
||||||
} from "@directus/sdk";
|
} from "@directus/sdk";
|
||||||
import type { Article, Model, Stats, User, Video } from "$lib/types";
|
import type { Article, Model, Recording, Stats, User, Video } from "$lib/types";
|
||||||
import { PUBLIC_URL } from "$env/static/public";
|
import { PUBLIC_URL } from "$env/static/public";
|
||||||
import { logger } from "$lib/logger";
|
import { logger } from "$lib/logger";
|
||||||
|
|
||||||
@@ -548,3 +548,36 @@ export async function getItemsByTag(
|
|||||||
{ category, tag },
|
{ category, tag },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRecordings(fetch?: typeof globalThis.fetch) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"getRecordings",
|
||||||
|
async () => {
|
||||||
|
const directus = getDirectusInstance(fetch);
|
||||||
|
const response = await directus.request<Recording[]>(
|
||||||
|
customEndpoint({
|
||||||
|
method: "GET",
|
||||||
|
path: "/sexy/recordings",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecording(id: string) {
|
||||||
|
return loggedApiCall(
|
||||||
|
"deleteRecording",
|
||||||
|
async () => {
|
||||||
|
const directus = getDirectusInstance();
|
||||||
|
await directus.request(
|
||||||
|
customEndpoint({
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/sexy/recordings/${id}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,3 +122,36 @@ export interface ShareContent {
|
|||||||
url: string;
|
url: string;
|
||||||
type: "video" | "model" | "article" | "link";
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { getFolders } from "$lib/services";
|
import { getFolders, getRecordings } from "$lib/services";
|
||||||
|
|
||||||
export async function load({ locals, fetch }) {
|
export async function load({ locals, fetch }) {
|
||||||
|
const recordings = locals.authStatus.authenticated
|
||||||
|
? await getRecordings(fetch).catch(() => [])
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authStatus: locals.authStatus,
|
authStatus: locals.authStatus,
|
||||||
folders: await getFolders(fetch),
|
folders: await getFolders(fetch),
|
||||||
|
recordings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { goto, invalidateAll } from "$app/navigation";
|
|||||||
import { getAssetUrl, isModel } from "$lib/directus";
|
import { getAssetUrl, isModel } from "$lib/directus";
|
||||||
import * as Alert from "$lib/components/ui/alert";
|
import * as Alert from "$lib/components/ui/alert";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { removeFile, updateProfile, uploadFile } from "$lib/services";
|
import { deleteRecording, removeFile, updateProfile, uploadFile } from "$lib/services";
|
||||||
import { Textarea } from "$lib/components/ui/textarea";
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
import Meta from "$lib/components/meta/meta.svelte";
|
import Meta from "$lib/components/meta/meta.svelte";
|
||||||
import { TagsInput } from "$lib/components/ui/tags-input";
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
@@ -32,9 +32,12 @@ import {
|
|||||||
MEGABYTE,
|
MEGABYTE,
|
||||||
} from "$lib/components/ui/file-drop-zone";
|
} from "$lib/components/ui/file-drop-zone";
|
||||||
import * as Avatar from "$lib/components/ui/avatar";
|
import * as Avatar from "$lib/components/ui/avatar";
|
||||||
|
import RecordingCard from "$lib/components/recording-card/recording-card.svelte";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
|
let recordings = $state(data.recordings);
|
||||||
|
|
||||||
let activeTab = $state("settings");
|
let activeTab = $state("settings");
|
||||||
|
|
||||||
let firstName = $state(data.authStatus.user!.first_name);
|
let firstName = $state(data.authStatus.user!.first_name);
|
||||||
@@ -163,6 +166,25 @@ function setExistingAvatar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRecording(id: string) {
|
||||||
|
if (!confirm($_("me.recordings.delete_confirm"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteRecording(id);
|
||||||
|
recordings = recordings.filter((r) => r.id !== id);
|
||||||
|
toast.success($_("me.recordings.delete_success"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error($_("me.recordings.delete_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayRecording(id: string) {
|
||||||
|
// Navigate to play page with recording ID
|
||||||
|
goto(`/play?recording=${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (data.authStatus.authenticated) {
|
if (data.authStatus.authenticated) {
|
||||||
setExistingAvatar();
|
setExistingAvatar();
|
||||||
@@ -212,11 +234,15 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- Dashboard Tabs -->
|
<!-- Dashboard Tabs -->
|
||||||
<Tabs bind:value={activeTab} class="w-full">
|
<Tabs bind:value={activeTab} class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-4 max-w-2xl mb-8">
|
<TabsList class="grid w-full grid-cols-2 max-w-2xl mb-8">
|
||||||
<TabsTrigger value="settings" class="flex items-center gap-2">
|
<TabsTrigger value="settings" class="flex items-center gap-2">
|
||||||
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
|
<span class="icon-[ri--settings-4-line] w-4 h-4"></span>
|
||||||
{$_("me.settings.title")}
|
{$_("me.settings.title")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="recordings" class="flex items-center gap-2">
|
||||||
|
<span class="icon-[ri--play-list-2-line] w-4 h-4"></span>
|
||||||
|
{$_("me.recordings.title")}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
@@ -464,6 +490,66 @@ onMount(() => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- Recordings Tab -->
|
||||||
|
<TabsContent value="recordings" class="space-y-6">
|
||||||
|
<div class="mb-6 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-card-foreground">
|
||||||
|
{$_("me.recordings.title")}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{$_("me.recordings.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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--play-line] w-4 h-4 mr-2"></span>
|
||||||
|
{$_("me.recordings.go_to_play")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recordings.length === 0}
|
||||||
|
<Card class="bg-card/50 border-primary/20">
|
||||||
|
<CardContent class="py-12">
|
||||||
|
<div class="flex flex-col items-center justify-center text-center">
|
||||||
|
<div
|
||||||
|
class="mb-4 p-4 rounded-full bg-muted/30 border border-border/30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--play-list-2-line] w-12 h-12 text-muted-foreground"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">
|
||||||
|
{$_("me.recordings.no_recordings")}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground mb-6 max-w-md">
|
||||||
|
{$_("me.recordings.no_recordings_description")}
|
||||||
|
</p>
|
||||||
|
<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--play-line] w-4 h-4 mr-2"></span>
|
||||||
|
{$_("me.recordings.go_to_play")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{#each recordings as recording (recording.id)}
|
||||||
|
<RecordingCard
|
||||||
|
{recording}
|
||||||
|
onPlay={handlePlayRecording}
|
||||||
|
onDelete={handleDeleteRecording}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,13 +19,24 @@ import Button from "$lib/components/ui/button/button.svelte";
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import DeviceCard from "$lib/components/device-card/device-card.svelte";
|
import DeviceCard from "$lib/components/device-card/device-card.svelte";
|
||||||
import type { BluetoothDevice } from "$lib/types";
|
import RecordingSaveDialog from "./components/recording-save-dialog.svelte";
|
||||||
|
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import { customEndpoint } from "@directus/sdk";
|
||||||
|
import { getDirectusInstance } from "$lib/directus";
|
||||||
|
|
||||||
const client = new ButtplugClient("Sexy.Art");
|
const client = new ButtplugClient("Sexy.Art");
|
||||||
let connected = $state(client.connected);
|
let connected = $state(client.connected);
|
||||||
let scanning = $state(false);
|
let scanning = $state(false);
|
||||||
let devices = $state<BluetoothDevice[]>([]);
|
let devices = $state<BluetoothDevice[]>([]);
|
||||||
|
|
||||||
|
// Recording state
|
||||||
|
let isRecording = $state(false);
|
||||||
|
let recordingStartTime = $state<number | null>(null);
|
||||||
|
let recordedEvents = $state<RecordedEvent[]>([]);
|
||||||
|
let showSaveDialog = $state(false);
|
||||||
|
let recordingDuration = $state(0);
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const connector = new ButtplugWasmClientConnector();
|
const connector = new ButtplugWasmClientConnector();
|
||||||
// await ButtplugWasmClientConnector.activateLogging("info");
|
// await ButtplugWasmClientConnector.activateLogging("info");
|
||||||
@@ -99,6 +110,49 @@ async function handleChange(
|
|||||||
device.info.index,
|
device.info.index,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Capture event if recording
|
||||||
|
if (isRecording && recordingStartTime) {
|
||||||
|
captureEvent(device, scalarIndex, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isRecording = true;
|
||||||
|
recordingStartTime = performance.now();
|
||||||
|
recordedEvents = [];
|
||||||
|
recordingDuration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
isRecording = false;
|
||||||
|
if (recordedEvents.length > 0) {
|
||||||
|
recordingDuration = recordedEvents[recordedEvents.length - 1].timestamp;
|
||||||
|
showSaveDialog = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureEvent(
|
||||||
|
device: BluetoothDevice,
|
||||||
|
scalarIndex: number,
|
||||||
|
value: number,
|
||||||
|
) {
|
||||||
|
if (!recordingStartTime) return;
|
||||||
|
|
||||||
|
const timestamp = performance.now() - recordingStartTime;
|
||||||
|
const scalarCmd = device.info.messageAttributes.ScalarCmd[scalarIndex];
|
||||||
|
|
||||||
|
recordedEvents.push({
|
||||||
|
timestamp,
|
||||||
|
deviceIndex: device.info.index,
|
||||||
|
deviceName: device.name,
|
||||||
|
actuatorIndex: scalarIndex,
|
||||||
|
actuatorType: scalarCmd.ActuatorType,
|
||||||
|
value: (value / scalarCmd.StepCount) * 100, // Normalize to 0-100
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStop(device: BluetoothDevice) {
|
async function handleStop(device: BluetoothDevice) {
|
||||||
@@ -125,6 +179,57 @@ function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveRecording(data: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
}) {
|
||||||
|
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
index: d.info.index,
|
||||||
|
capabilities: d.info.messageAttributes.ScalarCmd.map((cmd) => cmd.ActuatorType),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const directus = getDirectusInstance();
|
||||||
|
await directus.request(
|
||||||
|
customEndpoint({
|
||||||
|
method: "POST",
|
||||||
|
path: "/sexy/recordings",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
duration: recordingDuration,
|
||||||
|
events: recordedEvents,
|
||||||
|
device_info: deviceInfo,
|
||||||
|
tags: data.tags,
|
||||||
|
status: "draft",
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success("Recording saved successfully!");
|
||||||
|
showSaveDialog = false;
|
||||||
|
recordedEvents = [];
|
||||||
|
recordingDuration = 0;
|
||||||
|
|
||||||
|
// Optionally navigate to dashboard
|
||||||
|
// goto("/me?tab=recordings");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save recording:", error);
|
||||||
|
toast.error("Failed to save recording. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelSave() {
|
||||||
|
showSaveDialog = false;
|
||||||
|
recordedEvents = [];
|
||||||
|
recordingDuration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -166,7 +271,7 @@ onMount(() => {
|
|||||||
<p class="text-lg text-muted-foreground mb-10">
|
<p class="text-lg text-muted-foreground mb-10">
|
||||||
{$_("play.description")}
|
{$_("play.description")}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center gap-4 items-center">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!connected || scanning}
|
disabled={!connected || scanning}
|
||||||
@@ -182,6 +287,29 @@ onMount(() => {
|
|||||||
{$_("play.scan")}
|
{$_("play.scan")}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{#if devices.length > 0}
|
||||||
|
{#if !isRecording}
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
onclick={startRecording}
|
||||||
|
class="cursor-pointer border-primary/30 hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--record-circle-line] w-5 h-5 mr-2"></span>
|
||||||
|
Start Recording
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onclick={stopRecording}
|
||||||
|
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--stop-circle-fill] w-5 h-5 mr-2 animate-pulse"></span>
|
||||||
|
Stop Recording ({recordedEvents.length} events)
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,4 +335,18 @@ onMount(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording Save Dialog -->
|
||||||
|
<RecordingSaveDialog
|
||||||
|
open={showSaveDialog}
|
||||||
|
events={recordedEvents}
|
||||||
|
deviceInfo={devices.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
index: d.info.index,
|
||||||
|
capabilities: d.info.messageAttributes.ScalarCmd.map((cmd) => cmd.ActuatorType),
|
||||||
|
}))}
|
||||||
|
duration={recordingDuration}
|
||||||
|
onSave={handleSaveRecording}
|
||||||
|
onCancel={handleCancelSave}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
|
import { TagsInput } from "$lib/components/ui/tags-input";
|
||||||
|
import type { RecordedEvent, DeviceInfo } from "$lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
events: RecordedEvent[];
|
||||||
|
deviceInfo: DeviceInfo[];
|
||||||
|
duration: number;
|
||||||
|
onSave: (data: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
}) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, events, deviceInfo, duration, onSave, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
let title = $state("");
|
||||||
|
let description = $state("");
|
||||||
|
let tags = $state<string[]>([]);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
await onSave({ title: title.trim(), description: description.trim(), tags });
|
||||||
|
// Reset form
|
||||||
|
title = "";
|
||||||
|
description = "";
|
||||||
|
tags = [];
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
title = "";
|
||||||
|
description = "";
|
||||||
|
tags = [];
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root {open} onOpenChange={(isOpen) => !isOpen && handleCancel()}>
|
||||||
|
<Dialog.Content class="max-w-2xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Save Recording</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Save your recording to view and play it later from your dashboard
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="space-y-6 py-4">
|
||||||
|
<!-- Recording Stats -->
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--time-line] w-5 h-5 text-primary mb-2"></span>
|
||||||
|
<span class="text-xs text-muted-foreground mb-1">Duration</span>
|
||||||
|
<span class="font-semibold">{formatDuration(duration)}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--pulse-line] w-5 h-5 text-accent mb-2"></span>
|
||||||
|
<span class="text-xs text-muted-foreground mb-1">Events</span>
|
||||||
|
<span class="font-semibold">{events.length}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--gamepad-line] w-5 h-5 text-primary mb-2"></span>
|
||||||
|
<span class="text-xs text-muted-foreground mb-1">Devices</span>
|
||||||
|
<span class="font-semibold">{deviceInfo.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Info -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Devices Used</Label>
|
||||||
|
{#each deviceInfo as device}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-sm bg-muted/20 rounded px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--rocket-line] w-4 h-4"></span>
|
||||||
|
<span class="font-medium">{device.name}</span>
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
• {device.capabilities.join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Fields -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="title">Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
bind:value={title}
|
||||||
|
placeholder="My awesome pattern"
|
||||||
|
required
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder="Describe your recording..."
|
||||||
|
rows={3}
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tags">Tags</Label>
|
||||||
|
<TagsInput
|
||||||
|
id="tags"
|
||||||
|
bind:value={tags}
|
||||||
|
placeholder="Add tags..."
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={handleCancel}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={!title.trim() || isSaving}
|
||||||
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>
|
||||||
|
{#if isSaving}
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||||
|
></div>
|
||||||
|
Saving...
|
||||||
|
{:else}
|
||||||
|
<span class="icon-[ri--save-line] w-4 h-4 mr-2"></span>
|
||||||
|
Save Recording
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
Reference in New Issue
Block a user