- content_for :head do meta name="turbo-cache-control" content="no-cache" .container-fluid#reelCreator v-cloak="" data-controller="reels" .row .col-md-3 = render partial: "respondent_categories/sidebar" .col-md-9 = render_breadcrumbs([ \ { name: 'Home', path: projects_path }, \ { name: @project.title, path: project_path(@project) }, \ { name: @respondent_category.name, path: [@project, @respondent_category] }, \ { name: 'Reel Creator' } \ ]) / Tab Navigation .card.border-0.shadow-sm.mb-4 .card-header.bg-white.border-0.pt-4.pb-0 ul.nav.nav-tabs.border-0 id="reelTabs" role="tablist" li.nav-item role="presentation" button.nav-link.fw-semibold.px-4.py-3.border-0.position-relative[ id="highlight-list-tab" :class="{ active: activeTab === 'highlight-list' }" @click="switchTab('highlight-list')" type="button" role="tab" aria-controls="highlight-list" :aria-selected="activeTab === 'highlight-list'" ] i.bi.bi-highlights.me-2 | Highlight List .tab-indicator / Add more tabs here in the future li.nav-item role="presentation" button.nav-link.fw-semibold.px-4.py-3.border-0.position-relative[ id="reel-stitcher-tab" :class="{ active: activeTab === 'reel-stitcher' }" @click="switchTab('reel-stitcher')" type="button" role="tab" aria-controls="reel-stitcher" :aria-selected="activeTab === 'reel-stitcher'" ] i.bi.bi-camera-reels.me-2 | Stitch Highlights .tab-indicator li.nav-item role="presentation" button.nav-link.fw-semibold.px-4.py-3.border-0.position-relative[ id="reel-gallery-tab" :class="{ active: activeTab === 'reel-gallery' }" @click="switchTab('reel-gallery')" type="button" role="tab" aria-controls="reel-gallery" :aria-selected="activeTab === 'reel-gallery'" ] i.bi.bi-collection-play-fill.me-2 | Gallery .tab-indicator .tab-content id="reelTabContent" / Highlight List Tab .tab-pane.fade[:class="{ 'show active': activeTab === 'highlight-list' }" id="highlight-list" role="tabpanel" aria-labelledby="highlight-list-tab"] .row.g-2.mb-4 .col .d-flex.align-items-center .input-group span class="input-group-text bg-white" i.feather-14 data-feather="search" style="color: #BEBEBE;" input type="text" class="form-control index-input border-start-0" placeholder="Search respondents" v-model="filterQuery" div v-if="isFilterActive != false" @click="clearFilters()" vue-feather type="x-square" size="16" class="mt-1 ms-3" style="color: black;" div v-if="isFilterActive == false" vue-feather type="filter" size="16" class="mt-1 ms-3" style="color: black;" .row.g-4 .col-12 v-for="respondent in filteredRespondents" :key="respondent.id" .custom-card .card.shadow-sm.rounded-3.border-0.mb-4 .card-header.bg-primary.bg-opacity-75.text-white.rounded-top-3.cursor-pointer[ data-bs-toggle="collapse" :data-bs-target="'#collapse' + respondent.id" aria-expanded="true" ] .d-flex.justify-content-between.align-items-center .d-flex.align-items-center.gap-2 i.bi.bi-chevron-down.toggle-icon h5.card-title.mb-0 | {{ respondent.first_name }} span.badge[ :class="respondent.video_file_file_name ? 'bg-success-subtle text-success-emphasis' : 'bg-danger-subtle text-danger-emphasis'" ] | {{ respondent.video_file_file_name ? 'Has Video' : 'No Video' }} .collapse.show :id="'collapse' + respondent.id" .card-body h6.text-muted.mb-3.border-bottom.pb-2 Transcript Highlights .list-group.list-group-flush .list-group-item.border-0.px-0.py-4[ data-controller="reels-list" v-for="highlight in respondent.transcript_highlights" :key="highlight.id" ] .d-flex.align-items-center.gap-2.mb-3 span.badge.bg-secondary-subtle.text-secondary-emphasis.rounded-pill | {{ highlight.id }} span.badge.bg-info-subtle.text-info-emphasis v-if="highlight.highlight_theme != null" | {{ highlight.highlight_theme.theme }} div.ms-auto button.btn.btn-sm.ms-2 @click="confirmDelete('Are you sure you want to delete this highlight?', () => deleteHighlight(highlight.id, highlight.respondent_id))" i.feather-20.text-primary data-feather="trash-2" .highlight-content.text-body-secondary.mb-3 | {{ highlight.verbose_to_text }} div v-if="respondent.video_file_file_name || respondent.audio_file_file_name" .text-center v-if="highlight.processing" span.badge.bg-warning.text-warning-emphasis.d-inline-flex.align-items-center.gap-2 .spinner-border.spinner-border-sm role="status" | Processing Reel div v-if="!highlight.processing" .text-end v-if="!highlight.reel_available" button.btn.btn-create-reel.rounded-pill.px-4[ @click="createReel(respondent.id, highlight.id)" ] i.bi.bi-camera-video.me-2 | Create Reel .reel-preview.rounded-3.border.p-3 v-if="highlight.reel_available" .d-flex.justify-content-between.align-items-center .d-flex.align-items-center.gap-2 i.bi.bi-camera-reels-fill.text-primary.fs-18 span.text-body-secondary Reel Generated button.btn.btn-outline-secondary.text-dark.btn-sm.rounded-pill[ type="button" data-action="click->reels-list#togglePreview" ] i.bi.bi-eye.me-1 | Preview Reel .reel-video-container.d-none data-reels-list-target="mediaContainer" video.w-100.rounded controls="" data-reels-list-target="media" v-if="highlight.media_type == 'video'" source :src="highlight.reel_url_unsigned" type="video/mp4" audio.w-100.rounded controls="" data-reels-list-target="media" v-if="highlight.media_type == 'audio'" source :src="highlight.reel_url_unsigned" type="audio/mp3" / Reel Stitching Tab .tab-pane.fade[:class="{ 'show active': activeTab === 'reel-stitcher' }" id="reel-stitcher" role="tabpanel" aria-labelledby="reel-stitcher-tab"] .card.shadow-sm.rounded-3.border-0 .card-body / Header section with grid controls .d-flex.justify-content-between.align-items-center.mb-4 div h5.card-title.mb-1 Stitch Highlights p.text-muted.mb-0 Select highlights to create a stitched reel .d-flex.align-items-center.gap-2 / Grid size controls .btn-group role="group" aria-label="Grid size" button.btn.btn-outline-secondary[ :class="{ 'active': gridColumns === 2 }" @click="setGridColumns(2)" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-grid.me-1 | 2 Col button.btn.btn-outline-secondary[ :class="{ 'active': gridColumns === 3 }" @click="setGridColumns(3)" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-grid-3x3.me-1 | 3 Col button.btn.btn-outline-secondary[ :class="{ 'active': gridColumns === 'full' }" @click="setGridColumns('full')" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-arrows-fullscreen.me-1 | Full .btn-group role="group" aria-label="Media type" button.btn.btn-outline-secondary[ :class="{ 'active': mediaType === 'audio' }" @click="switchMediaTab('audio')" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-music-note-beamed.me-1 | audio button.btn.btn-outline-secondary[ :class="{ 'active': mediaType === 'video' }" @click="switchMediaTab('video')" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-film.me-1 | video .filter-bar.mb-4.p-3.bg-white.border.rounded-3.shadow-sm .row.align-items-center.g-3 .col-auto .d-flex.align-items-center.text-primary i.bi.bi-filter-left.fs-4.me-2 span.fw-bold.small.text-uppercase.tracking-wider Filters: .col-md-3 .input-group.input-group-sm span.input-group-text.bg-transparent.border-end-0 i.bi.bi-person.text-muted select.form-select.border-start-0 v-model="filterRespondentId" option value="" All Respondents option v-for="res in uniqueRespondents" :value="res.id" {{ res.name }} .col-md-3 .input-group.input-group-sm span.input-group-text.bg-transparent.border-end-0 i.bi.bi-tag.text-muted select.form-select.border-start-0 v-model="filterThemeId" option value="" All Themes option v-for="theme in uniqueThemes" :value="theme.id" {{ theme.theme }} .col / Results Count Badge .d-flex.align-items-center.gap-2 span.badge.rounded-pill.bg-light.text-dark.border | {{ filteredHighlights.length }} highlights found / Clear button - subtle and only shows when filters are active button.btn.btn-link.btn-sm.text-decoration-none.p-0.ms-2[ v-if="filterRespondentId || filterThemeId" @click="filterRespondentId = ''; filterThemeId = ''" ] i.bi.bi-x-circle-fill.me-1 | Clear all / Selection summary and actions .row.mb-4 v-if="selectedHighlightsData.length > 0" .col-12 .selected-highlights-section.p-3 .d-flex.justify-content-between.align-items-center.mb-3 h6.mb-0 i.bi.bi-list-ol.me-2 | Selected Highlights ({{ selectedHighlightsData.length }}) .form-check.form-switch v-show="mediaType === 'video'" input.form-check-input type="checkbox" role="switch" id="subtitleSwitch" v-model="subtitlesEnabled" label.form-check-label for="subtitleSwitch" Display Subtitles .btn-group button.btn.btn-sm.btn-outline-danger @click="clearSelection()" i.bi.bi-x-circle.me-1 | Clear All button.btn.btn-sm.btn-primary @click="stitchHighlights()" :disabled="selectedHighlightsData.length < 2" i.bi.bi-camera-reels.me-1 | Stitch {{ selectedHighlightsData.length }} highlights / Draggable selected highlights draggable[ v-model="selectedHighlightsData" group="highlights" @start="onDragStart" @end="onDragEnd" handle=".drag-handle" animation="200" class="row g-2" item-key="reel-drag" ] template v-slot:item="{element, index}" .col-auto .selected-highlight-item.p-2.d-flex.align-items-center.gap-2 .order-badge | {{ index + 1 }} .drag-handle i.bi.bi-grip-vertical .flex-grow-1.d-flex.flex-column.min-width-0 small.fw-bold.text-truncate[ style="max-width: 150px;" data-bs-toggle="tooltip" :title="element.respondent_name" ] | {{ element.respondent_name }} .badge.bg-secondary.text-primary.text-truncate.align-self-start[ style="max-width: 80px; font-size: 0.7rem;" ] | {{ element.id }} div small.text-muted | {{ formatDuration(element) }} button.btn.btn-sm.btn-outline-danger @click="removeFromSelection(element.id)" i.bi.bi-x .text-center.py-3 v-if="selectedHighlightsData.length === 0" i.bi.bi-arrow-down.text-muted p.text-muted.mb-0.mt-2 Drag highlights here to reorder them / Video grid .row[:class="gridClasses" class="g-3"] .col v-for="highlight in filteredHighlights" :key="highlight.id" .card.video-card.h-100[:class="{ 'selected': isHighlightSelected(highlight.id) }"] .card-body.p-0.position-relative / Video preview .video-preview-container.position-relative v-if="highlight.media_type == 'video'" video.w-100.video-thumbnail[ :src="highlight.reel_url_unsigned" preload="metadata" controls ] .audio-preview-container.bg-light.d-flex.flex-column.justify-content-center.align-items-center.position-relative.p-4[ v-if="highlight.media_type == 'audio'" style="min-height: 200px;" ] i.bi.bi-music-note-beamed.display-4.text-primary.mb-3 audio.w-100[ :src="highlight.reel_url_unsigned" preload="metadata" controls ] / Selection checkbox overlay .selection-overlay.position-absolute.top-0.end-0.p-2 .form-check input.form-check-input[ type="checkbox" :id="'highlight-' + highlight.id" :checked="isHighlightSelected(highlight.id)" @click.stop="toggleHighlightSelection(highlight.id)" ] / Video info footer .card-footer.bg-light.border-0.px-3.py-2 .d-flex.justify-content-between.align-items-center / Left Side: Badges and Buttons .d-flex.align-items-center.gap-2[style="min-width: 0"] / 1. Respondent Name (Constrained Width) .badge.bg-light.text-primary.text-truncate[ style="max-width: 12rem;" data-bs-toggle="tooltip" data-bs-placement="top" :title="highlight.respondent_name" ] | {{ highlight.respondent_name }} / 2. Theme Badge .badge.bg-primary.text-white.text-truncate[ v-if="highlight.highlight_theme" style="max-width: 10rem;" data-bs-toggle="tooltip" data-bs-placement="top" :title="highlight.highlight_theme.theme" ] | {{ highlight.highlight_theme.theme }} / 3. ID Badge .badge.bg-secondary.text-primary | {{ highlight.id }} / 4. Buttons button.btn.btn-sm @click="showEditingModal(highlight)" i.bi.bi-pencil-square.text-primary button.btn.btn-sm @click="confirmDelete('Are you sure?', () => deleteHighlight(highlight.id, highlight.respondent_id))" i.bi.bi-trash.text-primary / Right Side: Duration .text-end.flex-shrink-0 small.text-muted | {{ formatDuration(highlight) }} .col-12 v-if="filteredHighlights.length === 0" .text-center.py-5.bg-light.rounded-3.border.border-dashed i.bi.bi-search.display-4.text-muted.mb-3 h5 No highlights match your filters p.text-muted Try adjusting your selection or clearing the filters. button.btn.btn-sm.btn-outline-primary @click="filterRespondentId = ''; filterThemeId = ''" | Clear Filters / Empty state .row v-if="highlights.length === 0" .col-12 .text-center.py-5 i.bi.bi-highlights.text-muted.mb-3 style="font-size: 3rem;" h6.text-muted No highlights available p.text-muted.mb-0 Create some highlights first with the transcript. / Reel Gallery Tab .tab-pane.fade[:class="{ 'show active': activeTab === 'reel-gallery' }" id="reel-gallery" role="tabpanel" aria-labelledby="reel-gallery-tab"] .card.shadow-sm.rounded-3.border-0 .card-body / Loading state .d-flex.flex-column.align-items-center.justify-content-center.py-5 v-if="refreshReels" .spinner-border.text-primary.mb-3 role="status" span.visually-hidden Loading... p.text-muted Refreshing reels... div v-else="" / Header section with grid controls .d-flex.justify-content-between.align-items-center.mb-4 div .d-flex.align-items-center.gap-2.mb-1 h5.card-title.mb-0 Reels .badge.bg-warning.text-dark.d-flex.align-items-center v-if="anyReelsProcessing" .spinner-border.spinner-border-sm.me-2 role="status" span.visually-hidden Loading... | New reels are being processed p.text-muted.mb-0 List of highlights stitched together .d-flex.align-items-center.gap-2 / Grid size controls .btn-group role="group" aria-label="Grid size" button.btn.btn-outline-secondary[ :class="{ 'active': gridColumns === 2 }" @click="setGridColumns(2)" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-grid.me-1 | 2 Col button.btn.btn-outline-secondary[ :class="{ 'active': gridColumns === 3 }" @click="setGridColumns(3)" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-grid-3x3.me-1 | 3 Col button.btn.btn-outline-secondary[ :class="{ 'active': gridColumns === 'full' }" @click="setGridColumns('full')" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-arrows-fullscreen.me-1 | Full .btn-group role="group" aria-label="Media type" button.btn.btn-outline-secondary[ :class="{ 'active': mediaType === 'audio' }" @click="switchMediaTab('audio')" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-music-note-beamed.me-1 | audio button.btn.btn-outline-secondary[ :class="{ 'active': mediaType === 'video' }" @click="switchMediaTab('video')" type="button" ] .d-flex.align-items-center.me-1 i.bi.bi-film.me-1 | video .row v-if="filteredStitchedReels.length > 0" :class="gridClasses" class="g-3" .col v-for="reel in filteredStitchedReels" :key="reel.id" .card.video-card.h-100 .card-body.p-0.position-relative / Video preview .video-preview-container.position-relative v-if="reel.reel_type == 'video'" video.w-100.video-thumbnail[ :src="reel.video_url_unsigned" preload="metadata" controls ] .processing-overlay v-if="reel.processing || (reel.video_file_name == null)" class="position-absolute top-0 start-0 w-100 h-100 d-flex flex-column align-items-center justify-content-center" .spinner-border.text-light role="status" span.visually-hidden Loading... .processing-text.text-light.mt-2.text-center Processing... .audio-preview-container.bg-light.d-flex.flex-column.justify-content-center.align-items-center.position-relative.p-4[ v-if="reel.reel_type == 'audio'" style="min-height: 200px;" ] i.bi.bi-music-note-beamed.display-4.text-primary.mb-3 audio.w-100[ :src="reel.audio_url_unsigned" preload="metadata" controls ] .processing-overlay v-if="reel.processing || (reel.audio_file_name == null)" class="position-absolute top-0 start-0 w-100 h-100 d-flex flex-column align-items-center justify-content-center" .spinner-border.text-light role="status" span.visually-hidden Loading... .processing-text.text-light.mt-2.text-center Processing... / Video info footer .card-footer.bg-light.border-0.px-3.py-2 .d-flex.justify-content-between.align-items-center / Left Side: Title, ID, Edit .d-flex.align-items-center.gap-2[style="min-width: 0"] .badge.bg-light.text-primary.fw-bold.text-truncate[ v-if="reel.title" style="max-width: 15rem;" data-bs-toggle="tooltip" data-bs-placement="top" :title="reel.title" ] | {{ reel.title }} .badge.bg-secondary.text-primary.fw-bold | {{ reel.id }} button.btn.btn-sm @click="showReelEditingModal(reel)" i.bi.bi-pencil-square.text-primary / Right Side: Delete div.flex-shrink-0 button.btn.btn-sm @click="confirmDelete('Are you sure you want to delete this reel?', () => deleteReel(reel.id))" i.bi.bi-trash.text-primary .row v-if="filteredStitchedReels.length === 0" .col-12 .text-center.py-5 i.bi.bi-collection.text-muted.mb-3 style="font-size: 3rem;" h6.text-muted No Reels available p.text-muted.mb-0 Create some reels first to use the stitcher. #highlightEditingModal.modal.fade tabindex="-1" aria-labelledby="highlightEditingModalLabel" aria-hidden="true" .modal-dialog.modal-dialog-centered.modal-lg .modal-content .modal-header.border-bottom-0 h5.modal-title | Edit Highlight Subtitles button.btn-close type="button" data-bs-dismiss="modal" aria-label="Close" .modal-body.p-0 .video-section.p-4.pb-3 .video-card .video-preview-container.position-relative div v-if="currentHighlightEdit != null" video.w-100.video-thumbnail[ :src="currentHighlightEdit.reel_url_unsigned" preload="metadata" controls ] / Scrollable text content section .text-content-section.px-4 v-if="currentHighlightEdit && currentHighlightEdit.content" div v-for="(contentItem, index) in currentHighlightEdit.content" :key="index" class="mb-2 mt-2" label :for="'text-' + index" class="form-label fw-semibold" | Text Segment {{ index + 1 }} textarea[ :id="'text-' + index" class="form-control" rows="3" v-model="contentItem.text" placeholder="Enter subtitle text..." ] .modal-footer.border-top-0.justify-content-center.gap-2 button.btn.btn-secondary.px-4 type="button" @click="hideEditingModal()" i.bi.bi-x-circle.me-2.text-primary | Cancel button.btn.btn-primary.px-4 type="button" @click="updateHighlightText()" :disabled="!currentHighlightEdit" i.bi.bi-check-circle.me-2 | Save Changes #reelEditingModal.modal.fade tabindex="-1" aria-labelledby="reelEditingModalLabel" aria-hidden="true" .modal-dialog.modal-dialog-centered.modal-lg .modal-content .modal-header.border-bottom-0 h5.modal-title | Edit Reel Name button.btn-close type="button" data-bs-dismiss="modal" aria-label="Close" .modal-body.p-0 .video-section.p-4.pb-3 .video-card .video-preview-container.position-relative div v-if="currentReelEdit != null" video.w-100.video-thumbnail[ v-if="currentReelEdit.reel_type == 'video'" :src="currentReelEdit.video_url_unsigned" preload="metadata" controls ] .audio-preview-container.bg-light.d-flex.flex-column.justify-content-center.align-items-center.position-relative.p-4[ v-if="currentReelEdit.reel_type == 'audio'" style="min-height: 200px;" ] i.bi.bi-music-note-beamed.display-4.text-primary.mb-3 audio.w-100[ :src="currentReelEdit.audio_url_unsigned" preload="metadata" controls ] .px-4 v-if="currentReelEdit" label class="form-label fw-semibold" | Reel Name input[type="text" class="form-control" placeholder="Enter reel name..." v-model="reelName"] .modal-footer.border-top-0.justify-content-center.gap-2 button.btn.btn-secondary.px-4 type="button" @click="hideReelEditingModal()" i.bi.bi-x-circle.me-2.text-primary | Cancel button.btn.btn-primary.px-4 type="button" @click="updateStitchedHighlight(currentReelEdit.id)" :disabled="!currentReelEdit" i.bi.bi-check-circle.me-2 | Save Changes .toast-container.position-fixed.top-0.end-0.p-3 .toast id="successToast" role="alert" aria-live="assertive" aria-atomic="true" .toast-header i.feather-18.text-success data-feather="check-circle" strong.ms-2.me-auto Success! button.btn-close.btn-sm data-bs-dismiss="toast" aria-label="Close" .toast-body | {{ successMessage }} .toast-container.position-fixed.top-0.end-0.p-3 .toast id="errorToast" role="alert" aria-live="assertive" aria-atomic="true" .toast-header i.feather-18.text-danger data-feather="x-octagon" strong.ms-2.me-auto Error! button.btn-close.btn-sm data-bs-dismiss="toast" aria-label="Close" .toast-body | {{ errorMessage }}