File size: 9,867 Bytes
b1a388c
 
369feea
 
 
 
 
 
b1a388c
 
 
 
 
369feea
b1a388c
369feea
 
b1a388c
 
369feea
 
 
9114bf3
369feea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1a388c
369feea
 
 
 
 
 
 
 
9114bf3
369feea
 
 
 
 
 
 
 
b1a388c
369feea
 
9114bf3
369feea
 
 
3092643
 
 
 
369feea
 
 
 
b1a388c
9114bf3
b1a388c
9114bf3
 
3092643
9114bf3
 
3092643
 
b1a388c
9114bf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1a388c
b3e4ba6
9114bf3
369feea
 
 
9114bf3
369feea
 
 
 
9114bf3
369feea
b1a388c
 
 
 
 
 
 
b3e4ba6
b1a388c
 
 
 
369feea
b1a388c
369feea
 
 
 
 
9114bf3
 
 
 
 
 
 
b1a388c
369feea
 
 
 
 
 
 
 
 
 
 
 
b1a388c
369feea
 
 
 
 
 
 
 
 
 
 
 
b1a388c
 
 
3092643
 
 
b1a388c
3092643
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1a388c
 
 
 
 
369feea
 
 
 
 
 
b1a388c
 
3092643
b1a388c
 
 
 
 
 
 
9114bf3
b1a388c
 
9114bf3
 
 
 
 
 
 
 
b1a388c
 
 
 
9114bf3
b1a388c
9114bf3
b1a388c
 
9114bf3
b1a388c
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
<template>
  <div class="card overflow-hidden">
    <!-- Header: toggle collapse + controls -->
    <div class="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
      <button
        class="flex items-center gap-2 text-left"
        @click="open = !open"
      >
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
          <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
        </svg>
        <span class="text-[12px] font-semibold text-[var(--ink)]">Transcript</span>
        <span class="rounded-full bg-[var(--bg2)] px-2 py-0.5 text-[10px] text-[var(--ink3)]">
          {{ filteredSegments.length }}<template v-if="searchQuery"> / {{ segments.length }}</template>
        </span>
      </button>

      <div class="flex items-center gap-2">
        <span class="font-mono text-[10px] text-[var(--ink3)]">{{ fmtTime(currentTime) }}</span>

        <button
          class="flex h-6 w-6 items-center justify-center rounded transition-colors"
          :class="showSearch ? 'bg-[var(--spotify-green)] text-black' : 'text-[var(--ink3)] hover:bg-[var(--bg2)]'"
          title="Tìm kiếm trong transcript"
          @click="toggleSearch"
        >
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
        </button>

        <button
          class="flex h-6 w-6 items-center justify-center rounded text-[var(--ink3)] hover:bg-[var(--bg2)] transition-colors"
          :title="expanded ? 'Thu nhỏ transcript' : 'Mở rộng transcript'"
          @click="expanded = !expanded"
        >
          <svg v-if="!expanded" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
          <svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M4 14h6m0 0v6m0-6l-7 7m17-11h-6m0 0V4m0 6l7-7"/></svg>
        </button>

        <button @click="open = !open" class="flex h-6 w-6 items-center justify-center rounded text-[var(--ink3)] hover:bg-[var(--bg2)]">
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
            class="transition-transform" :class="open ? 'rotate-180' : ''">
            <polyline points="6 9 12 15 18 9"/>
          </svg>
        </button>
      </div>
    </div>

    <div v-show="showSearch && open" class="border-b border-[var(--border)] px-3 py-2">
      <div class="relative">
        <svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--ink3)]" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
        <input
          ref="searchInputRef"
          v-model="searchQuery"
          class="w-full rounded-lg border border-[var(--border2)] bg-[var(--bg)] py-1.5 pl-7 pr-3 text-[12px] outline-none focus:border-[var(--spotify-green)]"
          placeholder="Tìm từ trong transcript..."
          @keydown.escape="clearSearch"
        />
        <button v-if="searchQuery" @click="clearSearch" class="absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--ink3)] hover:text-[var(--ink)]">
          <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
        </button>
      </div>
    </div>

    <div
      v-show="open"
      ref="listEl"
      class="overflow-y-auto border-t border-[var(--border)] px-3 py-2 transition-all"
      :style="{ height: expanded ? 'calc(100vh - 380px)' : '13rem', maxHeight: expanded ? 'none' : '13rem' }"
    >
      <div v-if="!filteredSegments.length && !searchQuery" class="py-6 text-center text-[12px] text-[var(--ink3)]">
        Không có transcript cho phần này.
      </div>

      <div v-if="!filteredSegments.length && searchQuery" class="py-6 text-center text-[12px] text-[var(--ink3)]">
        Không tìm thấy "{{ searchQuery }}" trong transcript.
      </div>

      <div
        v-for="(seg, idx) in filteredSegments"
        :key="seg.id"
        :ref="(el) => setCardRef(el, seg.id)"
        class="sh-transcript-card mb-2 w-full rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-2.5 text-left transition-all"
        :class="[
          isHighlighted(seg) ? 'is-active' : '',
          seg.untimed ? 'cursor-default' : 'cursor-pointer hover:border-[var(--spotify-green)]',
        ]"
        @click="seg.untimed ? null : $emit('seek', seg.from)"
      >
        <div class="mb-1.5 flex items-center justify-between gap-2">
          <button
            v-if="!seg.untimed"
            type="button"
            class="rounded-md px-2 py-0.5 text-[10px] font-bold"
            :class="isHighlighted(seg) ? 'bg-[var(--spotify-green)] text-black' : 'bg-[var(--bg-interactive)] text-[var(--ink3)]'"
            @click.stop="$emit('seek', seg.from)"
          >
            {{ fmtTime(seg.from) }}
          </button>
          <span v-else class="rounded-md bg-[var(--bg-interactive)] px-2 py-0.5 text-[10px] font-bold text-[var(--ink3)]"></span>
          <span v-if="seg.speaker" class="truncate text-[10px] font-semibold text-[var(--ink3)]">{{ seg.speaker }}</span>
        </div>

        <p
          class="m-0 break-words text-[13px] leading-[1.65] transition-colors"
          :class="isHighlighted(seg) ? 'font-semibold text-[var(--ink)]' : 'text-[var(--ink2)]'"
          v-html="sanitizeQuizHtml(highlightSearch(seg.text))"
        />

        <button
          v-if="$attrs.onSaveWord"
          class="mt-2 flex items-center gap-1 text-[10px] text-[var(--ink3)] opacity-0 transition-opacity group-hover:opacity-100 hover:text-[var(--spotify-green)]"
          title="Lưu từ vựng"
          @click.stop="$emit('save-word', seg.text)"
        >
          <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
          Lưu từ
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import { sanitizeQuizHtml } from '@/utils/sanitizeHtml.js'

const props = defineProps({
  paragraphs:     { type: Array,  default: () => [] },
  currentTime:    { type: Number, default: 0 },
  highlightedIds: { type: Object, default: () => new Set() },
})
defineEmits(['seek', 'save-word'])

const open     = ref(true)
const expanded = ref(false)
const listEl   = ref(null)
const cardRefs = ref({})

const SCROLL_ANCHOR_PX = 132

function setCardRef(el, id) {
  if (el) cardRefs.value[id] = el
}

const showSearch   = ref(false)
const searchQuery  = ref('')
const searchInputRef = ref(null)

function toggleSearch() {
  showSearch.value = !showSearch.value
  if (showSearch.value) {
    nextTick(() => searchInputRef.value?.focus())
  } else {
    clearSearch()
  }
}

function clearSearch() {
  searchQuery.value = ''
  showSearch.value  = false
}

function highlightSearch(text) {
  if (!searchQuery.value) return text
  const escaped = searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  const re = new RegExp(`(${escaped})`, 'gi')
  return text.replace(re, '<mark style="background:#fef08a;border-radius:2px;padding:0 1px">$1</mark>')
}

const segments = computed(() => {
  const out = []
  for (const p of props.paragraphs) {
    const children = p.children || []
    let addedTimed = false
    for (const c of children) {
      if (!Number.isFinite(c.from) || !Number.isFinite(c.to)) continue
      addedTimed = true
      out.push({ id: c.id, from: c.from, to: c.to, speaker: c.speaker, text: c.text, untimed: false })
    }
    if (!addedTimed) {
      const text = (p.text || children.map((c) => c.text).join(' ')).trim()
      if (text) {
        out.push({
          id: p.id || `para-${p.paragraph}`,
          from: null,
          to: null,
          speaker: p.speaker || children[0]?.speaker,
          text,
          untimed: true,
        })
      }
    }
  }
  return out
})

const filteredSegments = computed(() => {
  if (!searchQuery.value) return segments.value
  const q = searchQuery.value.toLowerCase()
  return segments.value.filter(s => s.text.toLowerCase().includes(q))
})

const activeId = computed(() => {
  const t = props.currentTime || 0
  return segments.value.find((s) => !s.untimed && t >= s.from && t <= s.to)?.id ?? null
})

function isHighlighted(seg) {
  if (props.highlightedIds.size > 0) return props.highlightedIds.has(seg.id)
  return seg.id === activeId.value
}

async function scrollActiveToAnchor(id) {
  if (!id || !open.value) return
  await nextTick()
  const el = cardRefs.value[id]
  const container = listEl.value
  if (!el || !container) return
  const containerRect = container.getBoundingClientRect()
  const elRect = el.getBoundingClientRect()
  const delta = elRect.top - containerRect.top - SCROLL_ANCHOR_PX
  if (Math.abs(delta) < 2) return
  container.scrollBy({ top: delta, behavior: 'smooth' })
}

watch(() => props.highlightedIds, async (ids) => {
  if (ids.size > 0) {
    await scrollActiveToAnchor([...ids][0])
  }
}, { deep: true })

watch(activeId, (id) => {
  if (props.highlightedIds.size === 0) scrollActiveToAnchor(id)
})

function fmtTime(sec) {
  const s = Math.max(0, Number(sec) || 0)
  const m = Math.floor(s / 60).toString().padStart(2, '0')
  const ss = Math.floor(s % 60).toString().padStart(2, '0')
  return `${m}:${ss}`
}
</script>