From aa4e37649068edfed510984f1a423c18658f28b4 Mon Sep 17 00:00:00 2001 From: Valknar XXX Date: Tue, 28 Oct 2025 04:05:09 +0100 Subject: [PATCH] feat: add buttplug device recording feature (Phase 1 & 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- directus.yaml | 2817 +++++++++++++++++ packages/bundle/src/endpoint/index.ts | 188 ++ .../recording-card/recording-card.svelte | 180 ++ packages/frontend/src/lib/i18n/locales/en.ts | 27 + packages/frontend/src/lib/services.ts | 35 +- packages/frontend/src/lib/types.ts | 33 + .../frontend/src/routes/me/+page.server.ts | 7 +- packages/frontend/src/routes/me/+page.svelte | 90 +- .../frontend/src/routes/play/+page.svelte | 146 +- .../components/recording-save-dialog.svelte | 174 + 10 files changed, 3691 insertions(+), 6 deletions(-) create mode 100644 directus.yaml create mode 100644 packages/frontend/src/lib/components/recording-card/recording-card.svelte create mode 100644 packages/frontend/src/routes/play/components/recording-save-dialog.svelte diff --git a/directus.yaml b/directus.yaml new file mode 100644 index 0000000..6d7dc0f --- /dev/null +++ b/directus.yaml @@ -0,0 +1,2817 @@ +version: 1 +directus: 11.12.0 +vendor: postgres +collections: + - collection: junction_directus_users_files + meta: + accountability: all + archive_app_filter: true + archive_field: null + archive_value: null + collapse: open + collection: junction_directus_users_files + color: null + display_template: null + group: null + hidden: true + icon: import_export + item_duplication_fields: null + note: null + preview_url: null + singleton: false + sort: null + sort_field: null + translations: null + unarchive_value: null + versioning: false + schema: + name: junction_directus_users_files + - collection: sexy_articles + meta: + accountability: all + archive_app_filter: true + archive_field: status + archive_value: archived + collapse: open + collection: sexy_articles + color: null + display_template: null + group: null + hidden: false + icon: newsmode + item_duplication_fields: null + note: null + preview_url: null + singleton: false + sort: null + sort_field: null + translations: + - language: en-US + plural: Articles + singular: Article + translation: Sexy Articles + unarchive_value: draft + versioning: true + schema: + name: sexy_articles + - collection: sexy_videos + meta: + accountability: all + archive_app_filter: true + archive_field: status + archive_value: archived + collapse: open + collection: sexy_videos + color: null + display_template: null + group: null + hidden: false + icon: videocam + item_duplication_fields: null + note: null + preview_url: null + singleton: false + sort: null + sort_field: null + translations: null + unarchive_value: draft + versioning: false + schema: + name: sexy_videos + - collection: sexy_videos_directus_users + meta: + accountability: all + archive_app_filter: true + archive_field: null + archive_value: null + collapse: open + collection: sexy_videos_directus_users + color: null + display_template: null + group: null + hidden: true + icon: import_export + item_duplication_fields: null + note: null + preview_url: null + singleton: false + sort: null + sort_field: null + translations: null + unarchive_value: null + versioning: false + schema: + name: sexy_videos_directus_users + - collection: sexy_recordings + meta: + accountability: all + archive_app_filter: true + archive_field: status + archive_value: archived + collapse: open + collection: sexy_recordings + color: null + display_template: null + group: null + hidden: false + icon: fiber_manual_record + item_duplication_fields: null + note: null + preview_url: null + singleton: false + sort: null + sort_field: null + translations: + - language: en-US + plural: Recordings + singular: Recording + translation: Sexy Recordings + unarchive_value: draft + versioning: false + schema: + name: sexy_recordings +fields: + - collection: directus_users + field: website + type: string + meta: + collection: directus_users + conditions: null + display: null + display_options: null + field: website + group: null + hidden: false + interface: input + note: null + options: null + readonly: false + required: false + sort: 2 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: website + table: directus_users + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: directus_users + field: slug + type: string + meta: + collection: directus_users + conditions: + - name: Enable for role "Administrator" + readonly: false + rule: + _and: + - role: + _eq: ea3a9127-2b65-462c-85a8-dbafe9b4fe24 + display: null + display_options: null + field: slug + group: null + hidden: false + interface: input + note: null + options: null + readonly: false + required: true + sort: 3 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: slug + table: directus_users + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: true + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: directus_users + field: join_date + type: dateTime + meta: + collection: directus_users + conditions: null + display: null + display_options: null + field: join_date + group: null + hidden: false + interface: datetime + note: null + options: + format: short + readonly: false + required: true + sort: 4 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: join_date + table: directus_users + data_type: timestamp without time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: directus_users + field: featured + type: boolean + meta: + collection: directus_users + conditions: null + display: null + display_options: null + field: featured + group: null + hidden: false + interface: boolean + note: null + options: + label: Featured + readonly: false + required: false + sort: 5 + special: + - cast-boolean + translations: null + validation: null + validation_message: null + width: full + schema: + name: featured + table: directus_users + data_type: boolean + default_value: false + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: directus_users + field: artist_name + type: string + meta: + collection: directus_users + conditions: null + display: null + display_options: null + field: artist_name + group: null + hidden: false + interface: input + note: null + options: null + readonly: false + required: true + sort: 1 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: artist_name + table: directus_users + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: directus_users + field: photos + type: alias + meta: + collection: directus_users + conditions: null + display: null + display_options: null + field: photos + group: null + hidden: false + interface: files + note: null + options: + filter: + _and: + - type: + _starts_with: image + folder: 4cb93083-f3f7-4a61-a80f-d56fd9e6ee62 + readonly: false + required: false + sort: 6 + special: + - files + translations: null + validation: null + validation_message: null + width: full + - collection: directus_users + field: banner + type: uuid + meta: + collection: directus_users + conditions: null + display: null + display_options: null + field: banner + group: null + hidden: false + interface: file-image + note: null + options: + folder: 9fd092ff-9e7b-48f0-b26c-bcead509ba9e + readonly: false + required: false + sort: 7 + special: + - file + translations: null + validation: null + validation_message: null + width: full + schema: + name: banner + table: directus_users + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_files + foreign_key_column: id + - collection: junction_directus_users_files + field: id + type: integer + meta: + collection: junction_directus_users_files + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: null + note: null + options: null + readonly: false + required: false + sort: 1 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: junction_directus_users_files + data_type: integer + default_value: nextval('junction_directus_users_files_id_seq'::regclass) + max_length: null + numeric_precision: 32 + numeric_scale: 0 + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: true + foreign_key_table: null + foreign_key_column: null + - collection: junction_directus_users_files + field: directus_users_id + type: uuid + meta: + collection: junction_directus_users_files + conditions: null + display: null + display_options: null + field: directus_users_id + group: null + hidden: true + interface: null + note: null + options: null + readonly: false + required: false + sort: 2 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: directus_users_id + table: junction_directus_users_files + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id + - collection: junction_directus_users_files + field: directus_files_id + type: uuid + meta: + collection: junction_directus_users_files + conditions: null + display: null + display_options: null + field: directus_files_id + group: null + hidden: true + interface: null + note: null + options: null + readonly: false + required: false + sort: 3 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: directus_files_id + table: junction_directus_users_files + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_files + foreign_key_column: id + - collection: sexy_articles + field: id + type: uuid + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + sort: 1 + special: + - uuid + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: sexy_articles + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: status + type: string + meta: + collection: sexy_articles + conditions: null + display: labels + display_options: + choices: + - background: var(--theme--primary-background) + color: var(--theme--primary) + foreground: var(--theme--primary) + text: $t:published + value: published + - background: var(--theme--background-normal) + color: var(--theme--foreground) + foreground: var(--theme--foreground) + text: $t:draft + value: draft + - background: var(--theme--warning-background) + color: var(--theme--warning) + foreground: var(--theme--warning) + text: $t:archived + value: archived + showAsDot: true + field: status + group: null + hidden: false + interface: select-dropdown + note: null + options: + choices: + - color: var(--theme--primary) + text: $t:published + value: published + - color: var(--theme--foreground) + text: $t:draft + value: draft + - color: var(--theme--warning) + text: $t:archived + value: archived + readonly: false + required: false + sort: 2 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: status + table: sexy_articles + data_type: character varying + default_value: draft + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: user_created + type: uuid + meta: + collection: sexy_articles + conditions: null + display: user + display_options: null + field: user_created + group: null + hidden: true + interface: select-dropdown-m2o + note: null + options: + template: '{{avatar}} {{first_name}} {{last_name}}' + readonly: true + required: false + sort: 3 + special: + - user-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: user_created + table: sexy_articles + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id + - collection: sexy_articles + field: date_created + type: timestamp + meta: + collection: sexy_articles + conditions: null + display: datetime + display_options: + relative: true + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + sort: 4 + special: + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: sexy_articles + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: date_updated + type: timestamp + meta: + collection: sexy_articles + conditions: null + display: datetime + display_options: + relative: true + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + sort: 5 + special: + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: sexy_articles + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: slug + type: string + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: slug + group: null + hidden: false + interface: input + note: null + options: + slug: true + readonly: false + required: true + sort: 6 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: slug + table: sexy_articles + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: true + is_indexed: true + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: title + type: string + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: title + group: null + hidden: false + interface: input + note: null + options: null + readonly: false + required: true + sort: 7 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: title + table: sexy_articles + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: excerpt + type: text + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: excerpt + group: null + hidden: false + interface: input-multiline + note: null + options: + trim: true + readonly: false + required: true + sort: 9 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: excerpt + table: sexy_articles + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: content + type: text + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: content + group: null + hidden: false + interface: input-rich-text-html + note: null + options: + folder: c214c905-885b-4d66-a6a1-6527b0606200 + toolbar: + - bold + - italic + - underline + - h2 + - h3 + - numlist + - bullist + - removeformat + - blockquote + - customLink + - hr + - fullscreen + - code + readonly: false + required: true + sort: 10 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: content + table: sexy_articles + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: image + type: uuid + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: image + group: null + hidden: false + interface: file-image + note: null + options: + folder: 452680cc-8e19-4352-a943-21520d3f3621 + readonly: false + required: true + sort: 11 + special: + - file + translations: null + validation: null + validation_message: null + width: full + schema: + name: image + table: sexy_articles + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_files + foreign_key_column: id + - collection: sexy_articles + field: tags + type: json + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: tags + group: null + hidden: false + interface: tags + note: null + options: + capitalization: auto-format + whitespace: _ + readonly: false + required: false + sort: 12 + special: + - cast-json + translations: null + validation: null + validation_message: null + width: full + schema: + name: tags + table: sexy_articles + data_type: json + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: publish_date + type: dateTime + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: publish_date + group: null + hidden: false + interface: datetime + note: null + options: null + readonly: false + required: true + sort: 13 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: publish_date + table: sexy_articles + data_type: timestamp without time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: category + type: string + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: category + group: null + hidden: false + interface: input + note: null + options: null + readonly: false + required: true + sort: 14 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: category + table: sexy_articles + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: featured + type: boolean + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: featured + group: null + hidden: false + interface: boolean + note: null + options: + label: Featured + readonly: false + required: false + sort: 15 + special: + - cast-boolean + translations: null + validation: null + validation_message: null + width: full + schema: + name: featured + table: sexy_articles + data_type: boolean + default_value: false + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_articles + field: author + type: uuid + meta: + collection: sexy_articles + conditions: null + display: null + display_options: null + field: author + group: null + hidden: false + interface: select-dropdown-m2o + note: null + options: + enableLink: true + filter: + _and: + - policies: + policy: + name: + _eq: Editor + readonly: false + required: true + sort: 8 + special: + - m2o + translations: null + validation: null + validation_message: null + width: full + schema: + name: author + table: sexy_articles + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id + - collection: sexy_videos + field: id + type: uuid + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + sort: 1 + special: + - uuid + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: sexy_videos + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: status + type: string + meta: + collection: sexy_videos + conditions: null + display: labels + display_options: + choices: + - background: var(--theme--primary-background) + color: var(--theme--primary) + foreground: var(--theme--primary) + text: $t:published + value: published + - background: var(--theme--background-normal) + color: var(--theme--foreground) + foreground: var(--theme--foreground) + text: $t:draft + value: draft + - background: var(--theme--warning-background) + color: var(--theme--warning) + foreground: var(--theme--warning) + text: $t:archived + value: archived + showAsDot: true + field: status + group: null + hidden: false + interface: select-dropdown + note: null + options: + choices: + - color: var(--theme--primary) + text: $t:published + value: published + - color: var(--theme--foreground) + text: $t:draft + value: draft + - color: var(--theme--warning) + text: $t:archived + value: archived + readonly: false + required: false + sort: 2 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: status + table: sexy_videos + data_type: character varying + default_value: draft + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: user_created + type: uuid + meta: + collection: sexy_videos + conditions: null + display: user + display_options: null + field: user_created + group: null + hidden: true + interface: select-dropdown-m2o + note: null + options: + template: '{{avatar}} {{first_name}} {{last_name}}' + readonly: true + required: false + sort: 3 + special: + - user-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: user_created + table: sexy_videos + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id + - collection: sexy_videos + field: date_created + type: timestamp + meta: + collection: sexy_videos + conditions: null + display: datetime + display_options: + relative: true + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + sort: 4 + special: + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: sexy_videos + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: date_updated + type: timestamp + meta: + collection: sexy_videos + conditions: null + display: datetime + display_options: + relative: true + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + sort: 5 + special: + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: sexy_videos + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: slug + type: string + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: slug + group: null + hidden: false + interface: input + note: null + options: + slug: true + trim: true + readonly: false + required: true + sort: 6 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: slug + table: sexy_videos + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: title + type: string + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: title + group: null + hidden: false + interface: input + note: null + options: null + readonly: false + required: true + sort: 7 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: title + table: sexy_videos + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: image + type: uuid + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: image + group: null + hidden: false + interface: file-image + note: null + options: + folder: 26657630-d9cd-47a3-9e45-9831f3674f97 + readonly: false + required: true + sort: 9 + special: + - file + translations: null + validation: null + validation_message: null + width: full + schema: + name: image + table: sexy_videos + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_files + foreign_key_column: id + - collection: sexy_videos + field: upload_date + type: dateTime + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: upload_date + group: null + hidden: false + interface: datetime + note: null + options: null + readonly: false + required: true + sort: 12 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: upload_date + table: sexy_videos + data_type: timestamp without time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: premium + type: boolean + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: premium + group: null + hidden: false + interface: boolean + note: null + options: + label: Premium + readonly: false + required: false + sort: 13 + special: + - cast-boolean + translations: null + validation: null + validation_message: null + width: full + schema: + name: premium + table: sexy_videos + data_type: boolean + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: featured + type: boolean + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: featured + group: null + hidden: false + interface: boolean + note: null + options: + label: Featured + readonly: false + required: false + sort: 14 + special: + - cast-boolean + translations: null + validation: null + validation_message: null + width: full + schema: + name: featured + table: sexy_videos + data_type: boolean + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: tags + type: json + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: tags + group: null + hidden: false + interface: tags + note: null + options: null + readonly: false + required: false + sort: 15 + special: + - cast-json + translations: null + validation: null + validation_message: null + width: full + schema: + name: tags + table: sexy_videos + data_type: json + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos + field: models + type: alias + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: models + group: null + hidden: false + interface: list-m2m + note: null + options: null + readonly: false + required: true + sort: 11 + special: + - m2m + translations: null + validation: null + validation_message: null + width: full + - collection: sexy_videos + field: movie + type: uuid + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: movie + group: null + hidden: false + interface: file + note: null + options: + filter: + _and: + - type: + _eq: video/mp4 + folder: 3f83c727-9c90-4e0d-871f-ab81c295043a + readonly: false + required: true + sort: 10 + special: + - file + translations: null + validation: null + validation_message: null + width: full + schema: + name: movie + table: sexy_videos + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_files + foreign_key_column: id + - collection: sexy_videos + field: description + type: text + meta: + collection: sexy_videos + conditions: null + display: null + display_options: null + field: description + group: null + hidden: false + interface: input-multiline + note: null + options: + trim: true + readonly: false + required: true + sort: 8 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: description + table: sexy_videos + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos_directus_users + field: id + type: integer + meta: + collection: sexy_videos_directus_users + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: null + note: null + options: null + readonly: false + required: false + sort: 1 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: sexy_videos_directus_users + data_type: integer + default_value: nextval('sexy_videos_directus_users_id_seq'::regclass) + max_length: null + numeric_precision: 32 + numeric_scale: 0 + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: true + foreign_key_table: null + foreign_key_column: null + - collection: sexy_videos_directus_users + field: sexy_videos_id + type: uuid + meta: + collection: sexy_videos_directus_users + conditions: null + display: null + display_options: null + field: sexy_videos_id + group: null + hidden: true + interface: null + note: null + options: null + readonly: false + required: false + sort: 2 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: sexy_videos_id + table: sexy_videos_directus_users + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: sexy_videos + foreign_key_column: id + - collection: sexy_videos_directus_users + field: directus_users_id + type: uuid + meta: + collection: sexy_videos_directus_users + conditions: null + display: null + display_options: null + field: directus_users_id + group: null + hidden: true + interface: null + note: null + options: null + readonly: false + required: false + sort: 3 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: directus_users_id + table: sexy_videos_directus_users + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id + - collection: sexy_recordings + field: id + type: uuid + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + sort: 1 + special: + - uuid + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: sexy_recordings + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: status + type: string + meta: + collection: sexy_recordings + conditions: null + display: labels + display_options: + choices: + - background: var(--theme--primary-background) + color: var(--theme--primary) + foreground: var(--theme--primary) + text: $t:published + value: published + - background: var(--theme--background-normal) + color: var(--theme--foreground) + foreground: var(--theme--foreground) + text: $t:draft + value: draft + - background: var(--theme--warning-background) + color: var(--theme--warning) + foreground: var(--theme--warning) + text: $t:archived + value: archived + showAsDot: true + field: status + group: null + hidden: false + interface: select-dropdown + note: null + options: + choices: + - color: var(--theme--primary) + text: $t:published + value: published + - color: var(--theme--foreground) + text: $t:draft + value: draft + - color: var(--theme--warning) + text: $t:archived + value: archived + readonly: false + required: false + sort: 2 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: status + table: sexy_recordings + data_type: character varying + default_value: draft + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: user_created + type: uuid + meta: + collection: sexy_recordings + conditions: null + display: user + display_options: null + field: user_created + group: null + hidden: true + interface: select-dropdown-m2o + note: null + options: + template: '{{avatar}} {{first_name}} {{last_name}}' + readonly: true + required: false + sort: 3 + special: + - user-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: user_created + table: sexy_recordings + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id + - collection: sexy_recordings + field: date_created + type: timestamp + meta: + collection: sexy_recordings + conditions: null + display: datetime + display_options: + relative: true + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + sort: 4 + special: + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: sexy_recordings + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: date_updated + type: timestamp + meta: + collection: sexy_recordings + conditions: null + display: datetime + display_options: + relative: true + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + sort: 5 + special: + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: sexy_recordings + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: title + type: string + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: title + group: null + hidden: false + interface: input + note: null + options: null + readonly: false + required: true + sort: 6 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: title + table: sexy_recordings + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: description + type: text + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: description + group: null + hidden: false + interface: input-multiline + note: null + options: + trim: true + readonly: false + required: false + sort: 7 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: description + table: sexy_recordings + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: slug + type: string + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: slug + group: null + hidden: false + interface: input + note: null + options: + slug: true + trim: true + readonly: false + required: true + sort: 8 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: slug + table: sexy_recordings + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: true + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: duration + type: integer + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: duration + group: null + hidden: false + interface: input + note: Duration in milliseconds + options: null + readonly: false + required: true + sort: 9 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: duration + table: sexy_recordings + data_type: integer + default_value: null + max_length: null + numeric_precision: 32 + numeric_scale: 0 + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: events + type: json + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: events + group: null + hidden: false + interface: input-code + note: Array of recorded events with timestamps + options: + language: json + readonly: false + required: true + sort: 10 + special: + - cast-json + translations: null + validation: null + validation_message: null + width: full + schema: + name: events + table: sexy_recordings + data_type: json + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: device_info + type: json + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: device_info + group: null + hidden: false + interface: input-code + note: Array of device metadata + options: + language: json + readonly: false + required: true + sort: 11 + special: + - cast-json + translations: null + validation: null + validation_message: null + width: full + schema: + name: device_info + table: sexy_recordings + data_type: json + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: tags + type: json + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: tags + group: null + hidden: false + interface: tags + note: null + options: null + readonly: false + required: false + sort: 12 + special: + - cast-json + translations: null + validation: null + validation_message: null + width: full + schema: + name: tags + table: sexy_recordings + data_type: json + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: linked_video + type: uuid + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: linked_video + group: null + hidden: false + interface: select-dropdown-m2o + note: null + options: + enableLink: true + readonly: false + required: false + sort: 13 + special: + - m2o + translations: null + validation: null + validation_message: null + width: full + schema: + name: linked_video + table: sexy_recordings + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: sexy_videos + foreign_key_column: id + - collection: sexy_recordings + field: featured + type: boolean + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: featured + group: null + hidden: false + interface: boolean + note: null + options: + label: Featured + readonly: false + required: false + sort: 14 + special: + - cast-boolean + translations: null + validation: null + validation_message: null + width: full + schema: + name: featured + table: sexy_recordings + data_type: boolean + default_value: false + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: sexy_recordings + field: public + type: boolean + meta: + collection: sexy_recordings + conditions: null + display: null + display_options: null + field: public + group: null + hidden: false + interface: boolean + note: null + options: + label: Public + readonly: false + required: false + sort: 15 + special: + - cast-boolean + translations: null + validation: null + validation_message: null + width: full + schema: + name: public + table: sexy_recordings + data_type: boolean + default_value: false + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null +relations: + - collection: directus_users + field: banner + related_collection: directus_files + meta: + junction_field: null + many_collection: directus_users + many_field: banner + one_allowed_collections: null + one_collection: directus_files + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: directus_users + column: banner + foreign_key_table: directus_files + foreign_key_column: id + constraint_name: directus_users_banner_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: junction_directus_users_files + field: directus_files_id + related_collection: directus_files + meta: + junction_field: directus_users_id + many_collection: junction_directus_users_files + many_field: directus_files_id + one_allowed_collections: null + one_collection: directus_files + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: junction_directus_users_files + column: directus_files_id + foreign_key_table: directus_files + foreign_key_column: id + constraint_name: junction_directus_users_files_directus_files_id_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: junction_directus_users_files + field: directus_users_id + related_collection: directus_users + meta: + junction_field: directus_files_id + many_collection: junction_directus_users_files + many_field: directus_users_id + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: photos + sort_field: null + schema: + table: junction_directus_users_files + column: directus_users_id + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: junction_directus_users_files_directus_users_id_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: sexy_articles + field: user_created + related_collection: directus_users + meta: + junction_field: null + many_collection: sexy_articles + many_field: user_created + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_articles + column: user_created + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: sexy_articles_user_created_foreign + on_update: NO ACTION + on_delete: NO ACTION + - collection: sexy_articles + field: image + related_collection: directus_files + meta: + junction_field: null + many_collection: sexy_articles + many_field: image + one_allowed_collections: null + one_collection: directus_files + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_articles + column: image + foreign_key_table: directus_files + foreign_key_column: id + constraint_name: sexy_articles_image_foreign + on_update: NO ACTION + on_delete: NO ACTION + - collection: sexy_articles + field: author + related_collection: directus_users + meta: + junction_field: null + many_collection: sexy_articles + many_field: author + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_articles + column: author + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: sexy_articles_author_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: sexy_videos + field: user_created + related_collection: directus_users + meta: + junction_field: null + many_collection: sexy_videos + many_field: user_created + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_videos + column: user_created + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: sexy_videos_user_created_foreign + on_update: NO ACTION + on_delete: NO ACTION + - collection: sexy_videos + field: image + related_collection: directus_files + meta: + junction_field: null + many_collection: sexy_videos + many_field: image + one_allowed_collections: null + one_collection: directus_files + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_videos + column: image + foreign_key_table: directus_files + foreign_key_column: id + constraint_name: sexy_videos_image_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: sexy_videos + field: movie + related_collection: directus_files + meta: + junction_field: null + many_collection: sexy_videos + many_field: movie + one_allowed_collections: null + one_collection: directus_files + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_videos + column: movie + foreign_key_table: directus_files + foreign_key_column: id + constraint_name: sexy_videos_movie_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: sexy_videos_directus_users + field: directus_users_id + related_collection: directus_users + meta: + junction_field: sexy_videos_id + many_collection: sexy_videos_directus_users + many_field: directus_users_id + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_videos_directus_users + column: directus_users_id + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: sexy_videos_directus_users_directus_users_id_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: sexy_videos_directus_users + field: sexy_videos_id + related_collection: sexy_videos + meta: + junction_field: directus_users_id + many_collection: sexy_videos_directus_users + many_field: sexy_videos_id + one_allowed_collections: null + one_collection: sexy_videos + one_collection_field: null + one_deselect_action: nullify + one_field: models + sort_field: null + schema: + table: sexy_videos_directus_users + column: sexy_videos_id + foreign_key_table: sexy_videos + foreign_key_column: id + constraint_name: sexy_videos_directus_users_sexy_videos_id_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: sexy_recordings + field: user_created + related_collection: directus_users + meta: + junction_field: null + many_collection: sexy_recordings + many_field: user_created + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_recordings + column: user_created + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: sexy_recordings_user_created_foreign + on_update: NO ACTION + on_delete: NO ACTION + - collection: sexy_recordings + field: linked_video + related_collection: sexy_videos + meta: + junction_field: null + many_collection: sexy_recordings + many_field: linked_video + one_allowed_collections: null + one_collection: sexy_videos + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: sexy_recordings + column: linked_video + foreign_key_table: sexy_videos + foreign_key_column: id + constraint_name: sexy_recordings_linked_video_foreign + on_update: NO ACTION + on_delete: SET NULL diff --git a/packages/bundle/src/endpoint/index.ts b/packages/bundle/src/endpoint/index.ts index b64d17c..7b754a5 100644 --- a/packages/bundle/src/endpoint/index.ts +++ b/packages/bundle/src/endpoint/index.ts @@ -57,5 +57,193 @@ export default { 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" }); + } + }); }, }; diff --git a/packages/frontend/src/lib/components/recording-card/recording-card.svelte b/packages/frontend/src/lib/components/recording-card/recording-card.svelte new file mode 100644 index 0000000..7babb80 --- /dev/null +++ b/packages/frontend/src/lib/components/recording-card/recording-card.svelte @@ -0,0 +1,180 @@ + + + + +
+
+
+

+ {recording.title} +

+ + {$_(`recording_card.status_${recording.status}`)} + +
+ {#if recording.description} +

+ {recording.description} +

+ {/if} +
+
+
+ + + +
+
+ + {$_("recording_card.duration")} + {formatDuration(recording.duration)} +
+
+ + {$_("recording_card.events")} + {recording.events.length} +
+
+ + {$_("recording_card.devices")} + {recording.device_info.length} +
+
+ + +
+ {#each recording.device_info.slice(0, 2) as device} +
+ + {device.name} + • {device.capabilities.join(", ")} +
+ {/each} + {#if recording.device_info.length > 2} +
+ +{recording.device_info.length - 2} more device{recording.device_info.length - + 2 > + 1 + ? "s" + : ""} +
+ {/if} +
+ + + {#if recording.tags && recording.tags.length > 0} +
+ {#each recording.tags as tag} + + {tag} + + {/each} +
+ {/if} + + +
+
+ + {new Date(recording.date_created).toLocaleDateString()} + + {#if recording.public} + + + {$_("recording_card.public")} + + {:else} + + + {$_("recording_card.private")} + + {/if} +
+ {#if recording.linked_video} + + + {$_("recording_card.linked_video")} + + {/if} +
+ + +
+ {#if onPlay} + + {/if} + {#if onDelete} + + {/if} +
+
+
diff --git a/packages/frontend/src/lib/i18n/locales/en.ts b/packages/frontend/src/lib/i18n/locales/en.ts index 5a94f70..18c9e3f 100644 --- a/packages/frontend/src/lib/i18n/locales/en.ts +++ b/packages/frontend/src/lib/i18n/locales/en.ts @@ -118,6 +118,33 @@ export default { confirm_password: "Confirm 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: { login: { diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 65c0c2b..5818b47 100644 --- a/packages/frontend/src/lib/services.ts +++ b/packages/frontend/src/lib/services.ts @@ -16,7 +16,7 @@ import { readComments, aggregate, } 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 { logger } from "$lib/logger"; @@ -548,3 +548,36 @@ export async function getItemsByTag( { category, tag }, ); } + +export async function getRecordings(fetch?: typeof globalThis.fetch) { + return loggedApiCall( + "getRecordings", + async () => { + const directus = getDirectusInstance(fetch); + const response = await directus.request( + 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 }, + ); +} diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 3405f44..8246730 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -122,3 +122,36 @@ export interface ShareContent { 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; +} diff --git a/packages/frontend/src/routes/me/+page.server.ts b/packages/frontend/src/routes/me/+page.server.ts index d6f10d7..b9609e3 100644 --- a/packages/frontend/src/routes/me/+page.server.ts +++ b/packages/frontend/src/routes/me/+page.server.ts @@ -1,8 +1,13 @@ -import { getFolders } from "$lib/services"; +import { getFolders, getRecordings } from "$lib/services"; export async function load({ locals, fetch }) { + const recordings = locals.authStatus.authenticated + ? await getRecordings(fetch).catch(() => []) + : []; + return { authStatus: locals.authStatus, folders: await getFolders(fetch), + recordings, }; } diff --git a/packages/frontend/src/routes/me/+page.svelte b/packages/frontend/src/routes/me/+page.svelte index 32e5660..f06832c 100644 --- a/packages/frontend/src/routes/me/+page.svelte +++ b/packages/frontend/src/routes/me/+page.svelte @@ -22,7 +22,7 @@ import { goto, invalidateAll } from "$app/navigation"; import { getAssetUrl, isModel } from "$lib/directus"; import * as Alert from "$lib/components/ui/alert"; 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 Meta from "$lib/components/meta/meta.svelte"; import { TagsInput } from "$lib/components/ui/tags-input"; @@ -32,9 +32,12 @@ import { MEGABYTE, } from "$lib/components/ui/file-drop-zone"; import * as Avatar from "$lib/components/ui/avatar"; +import RecordingCard from "$lib/components/recording-card/recording-card.svelte"; const { data } = $props(); +let recordings = $state(data.recordings); + let activeTab = $state("settings"); 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(() => { if (data.authStatus.authenticated) { setExistingAvatar(); @@ -212,11 +234,15 @@ onMount(() => { - + {$_("me.settings.title")} + + + {$_("me.recordings.title")} + @@ -464,6 +490,66 @@ onMount(() => { + + + +
+
+

+ {$_("me.recordings.title")} +

+

+ {$_("me.recordings.description")} +

+
+ +
+ + {#if recordings.length === 0} + + +
+
+ +
+

+ {$_("me.recordings.no_recordings")} +

+

+ {$_("me.recordings.no_recordings_description")} +

+ +
+
+
+ {:else} +
+ {#each recordings as recording (recording.id)} + + {/each} +
+ {/if} +
diff --git a/packages/frontend/src/routes/play/+page.svelte b/packages/frontend/src/routes/play/+page.svelte index af398d0..7b035f8 100644 --- a/packages/frontend/src/routes/play/+page.svelte +++ b/packages/frontend/src/routes/play/+page.svelte @@ -19,13 +19,24 @@ import Button from "$lib/components/ui/button/button.svelte"; import { onMount } from "svelte"; import { goto } from "$app/navigation"; 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"); let connected = $state(client.connected); let scanning = $state(false); let devices = $state([]); +// Recording state +let isRecording = $state(false); +let recordingStartTime = $state(null); +let recordedEvents = $state([]); +let showSaveDialog = $state(false); +let recordingDuration = $state(0); + async function init() { const connector = new ButtplugWasmClientConnector(); // await ButtplugWasmClientConnector.activateLogging("info"); @@ -99,6 +110,49 @@ async function handleChange( 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) { @@ -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(); onMount(() => { @@ -166,7 +271,7 @@ onMount(() => {

{$_("play.description")}

-
+
+ + {#if devices.length > 0} + {#if !isRecording} + + {:else} + + {/if} + {/if}
@@ -207,4 +335,18 @@ onMount(() => { {/if} + + + ({ + name: d.name, + index: d.info.index, + capabilities: d.info.messageAttributes.ScalarCmd.map((cmd) => cmd.ActuatorType), + }))} + duration={recordingDuration} + onSave={handleSaveRecording} + onCancel={handleCancelSave} + /> diff --git a/packages/frontend/src/routes/play/components/recording-save-dialog.svelte b/packages/frontend/src/routes/play/components/recording-save-dialog.svelte new file mode 100644 index 0000000..d0dff6e --- /dev/null +++ b/packages/frontend/src/routes/play/components/recording-save-dialog.svelte @@ -0,0 +1,174 @@ + + + !isOpen && handleCancel()}> + + + Save Recording + + Save your recording to view and play it later from your dashboard + + + +
+ +
+
+ + Duration + {formatDuration(duration)} +
+
+ + Events + {events.length} +
+
+ + Devices + {deviceInfo.length} +
+
+ + +
+ + {#each deviceInfo as device} +
+ + {device.name} + + • {device.capabilities.join(", ")} + +
+ {/each} +
+ + +
+
+ + +
+ +
+ +