Spaces:
Running
Running
🐳 07/02 - 04:40 - Cara, seguir o mesmo padrao que tá aí, mas que tenha a opção de mandar como json, nessa pegada [ { "text": "Para", "start_time": 0, "end_time": 0.32 }, { "text": "tud
Browse files- index.html +31 -0
- script.js +186 -26
index.html
CHANGED
|
@@ -232,6 +232,37 @@
|
|
| 232 |
</div>
|
| 233 |
</div>
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
<div class="bg-primary-900/20 border border-primary-700/30 rounded-lg p-4">
|
| 236 |
<h4 class="text-sm font-medium text-primary-300 mb-2 flex items-center gap-2">
|
| 237 |
<i data-feather="info" class="w-4 h-4"></i>
|
|
|
|
| 232 |
</div>
|
| 233 |
</div>
|
| 234 |
|
| 235 |
+
<!-- Export Format Selection -->
|
| 236 |
+
<div class="space-y-3 pt-4 border-t border-slate-800">
|
| 237 |
+
<label class="text-sm font-medium text-slate-300 flex items-center gap-2">
|
| 238 |
+
<i data-feather="download" class="w-4 h-4 text-secondary-400"></i>
|
| 239 |
+
Formato de Exportação
|
| 240 |
+
</label>
|
| 241 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
| 242 |
+
<label class="flex items-center gap-3 p-3 bg-slate-800 rounded-lg border border-slate-700 hover:border-primary-500/50 cursor-pointer transition-all">
|
| 243 |
+
<input type="checkbox" id="export-srt" checked class="w-4 h-4 rounded border-slate-600 text-primary-500 focus:ring-primary-500 bg-slate-700">
|
| 244 |
+
<div>
|
| 245 |
+
<span class="text-sm font-medium text-slate-200">SRT</span>
|
| 246 |
+
<p class="text-xs text-slate-500">Legendas padrão</p>
|
| 247 |
+
</div>
|
| 248 |
+
</label>
|
| 249 |
+
<label class="flex items-center gap-3 p-3 bg-slate-800 rounded-lg border border-slate-700 hover:border-secondary-500/50 cursor-pointer transition-all">
|
| 250 |
+
<input type="checkbox" id="export-json" checked class="w-4 h-4 rounded border-slate-600 text-secondary-500 focus:ring-secondary-500 bg-slate-700">
|
| 251 |
+
<div>
|
| 252 |
+
<span class="text-sm font-medium text-slate-200">JSON</span>
|
| 253 |
+
<p class="text-xs text-slate-500">Timestamps palavra-a-palavra</p>
|
| 254 |
+
</div>
|
| 255 |
+
</label>
|
| 256 |
+
<label class="flex items-center gap-3 p-3 bg-slate-800 rounded-lg border border-slate-700 hover:border-emerald-500/50 cursor-pointer transition-all">
|
| 257 |
+
<input type="checkbox" id="export-blocks" checked class="w-4 h-4 rounded border-slate-600 text-emerald-500 focus:ring-emerald-500 bg-slate-700">
|
| 258 |
+
<div>
|
| 259 |
+
<span class="text-sm font-medium text-slate-200">Blocos</span>
|
| 260 |
+
<p class="text-xs text-slate-500">Texto separado</p>
|
| 261 |
+
</div>
|
| 262 |
+
</label>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
<div class="bg-primary-900/20 border border-primary-700/30 rounded-lg p-4">
|
| 267 |
<h4 class="text-sm font-medium text-primary-300 mb-2 flex items-center gap-2">
|
| 268 |
<i data-feather="info" class="w-4 h-4"></i>
|
script.js
CHANGED
|
@@ -369,12 +369,33 @@ formatFileSize(bytes) {
|
|
| 369 |
this.log('Etapa 4/5: Alinhando blocos com áudio...', 'info');
|
| 370 |
const alignedBlocks = await this.alignBlocksWithTranscript(blocks, transcript, processedAudio);
|
| 371 |
|
| 372 |
-
// 5. Geração
|
| 373 |
-
this.log('Etapa 5/5: Gerando
|
| 374 |
-
const srtContent = this.generateSRT(alignedBlocks);
|
| 375 |
|
| 376 |
-
//
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
this.downloadProcessedAudio(processedAudio.blob, fileData.name);
|
| 379 |
|
| 380 |
this.log(`${fileData.name} processado com sucesso!`, 'success');
|
|
@@ -587,36 +608,83 @@ formatFileSize(bytes) {
|
|
| 587 |
// Gera segmentos realistas baseados na duração do áudio
|
| 588 |
const duration = audioBlob.size / 16000; // estimativa aproximada
|
| 589 |
const segments = [];
|
| 590 |
-
const words = [
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
];
|
| 596 |
|
| 597 |
let currentTime = 0;
|
| 598 |
let wordIdx = 0;
|
| 599 |
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
const
|
| 603 |
-
const
|
| 604 |
-
|
| 605 |
-
for (let i = 0; i < textLength && wordIdx < words.length; i++) {
|
| 606 |
-
segmentWords.push(words[wordIdx]);
|
| 607 |
-
wordIdx = (wordIdx + 1) % words.length;
|
| 608 |
-
}
|
| 609 |
|
| 610 |
-
|
|
|
|
| 611 |
start: currentTime,
|
| 612 |
-
end:
|
| 613 |
-
text: segmentWords.join(' ')
|
| 614 |
});
|
| 615 |
|
| 616 |
-
|
|
|
|
| 617 |
}
|
| 618 |
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
}
|
| 621 |
|
| 622 |
async alignBlocksWithTranscript(blocks, transcript, processedAudio) {
|
|
@@ -712,6 +780,57 @@ formatFileSize(bytes) {
|
|
| 712 |
return srt;
|
| 713 |
}
|
| 714 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
formatSRTTime(seconds) {
|
| 716 |
const hrs = Math.floor(seconds / 3600);
|
| 717 |
const mins = Math.floor((seconds % 3600) / 60);
|
|
@@ -748,7 +867,44 @@ formatFileSize(bytes) {
|
|
| 748 |
this.log(`Áudio processado baixado: ${a.download}`, 'success');
|
| 749 |
}
|
| 750 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
showCompletionModal() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 752 |
const modal = document.createElement('div');
|
| 753 |
modal.className = 'fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in';
|
| 754 |
modal.innerHTML = `
|
|
@@ -758,16 +914,20 @@ formatFileSize(bytes) {
|
|
| 758 |
</div>
|
| 759 |
<h3 class="text-xl font-bold text-center text-slate-100 mb-2">Processamento Concluído!</h3>
|
| 760 |
<p class="text-slate-400 text-center mb-6">
|
| 761 |
-
|
| 762 |
</p>
|
| 763 |
<div class="space-y-2 text-sm text-slate-500 mb-6 bg-slate-950 rounded-lg p-4">
|
| 764 |
<div class="flex justify-between">
|
| 765 |
<span>Arquivos processados:</span>
|
| 766 |
<span class="text-slate-300">${this.files.length}</span>
|
| 767 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
<div class="flex justify-between">
|
| 769 |
<span>Taxa de compressão:</span>
|
| 770 |
-
<span class="text-
|
| 771 |
</div>
|
| 772 |
</div>
|
| 773 |
<button onclick="this.closest('.fixed').remove()" class="w-full py-3 bg-primary-600 hover:bg-primary-500 text-white rounded-lg font-medium transition-colors">
|
|
|
|
| 369 |
this.log('Etapa 4/5: Alinhando blocos com áudio...', 'info');
|
| 370 |
const alignedBlocks = await this.alignBlocksWithTranscript(blocks, transcript, processedAudio);
|
| 371 |
|
| 372 |
+
// 5. Geração dos arquivos de exportação
|
| 373 |
+
this.log('Etapa 5/5: Gerando arquivos de exportação...', 'info');
|
|
|
|
| 374 |
|
| 375 |
+
// Verifica quais formatos exportar
|
| 376 |
+
const exportSRT = document.getElementById('export-srt').checked;
|
| 377 |
+
const exportJSON = document.getElementById('export-json').checked;
|
| 378 |
+
const exportBlocks = document.getElementById('export-blocks').checked;
|
| 379 |
+
|
| 380 |
+
if (exportSRT) {
|
| 381 |
+
this.log('Gerando SRT...', 'info');
|
| 382 |
+
const srtContent = this.generateSRT(alignedBlocks);
|
| 383 |
+
this.downloadSRT(srtContent, fileData.name);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
if (exportJSON) {
|
| 387 |
+
this.log('Gerando JSON com timestamps...', 'info');
|
| 388 |
+
const jsonContent = this.generateJSON(alignedBlocks, transcript);
|
| 389 |
+
this.downloadJSON(jsonContent, fileData.name);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
if (exportBlocks) {
|
| 393 |
+
this.log('Gerando arquivo de blocos...', 'info');
|
| 394 |
+
const blocksContent = this.generateBlocks(alignedBlocks);
|
| 395 |
+
this.downloadBlocks(blocksContent, fileData.name);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
// Download do áudio processado (sempre)
|
| 399 |
this.downloadProcessedAudio(processedAudio.blob, fileData.name);
|
| 400 |
|
| 401 |
this.log(`${fileData.name} processado com sucesso!`, 'success');
|
|
|
|
| 608 |
// Gera segmentos realistas baseados na duração do áudio
|
| 609 |
const duration = audioBlob.size / 16000; // estimativa aproximada
|
| 610 |
const segments = [];
|
| 611 |
+
const words = [];
|
| 612 |
+
|
| 613 |
+
// Palavras mais variadas para teste
|
| 614 |
+
const wordBank = [
|
| 615 |
+
'Para', 'tudo', 'Olha', 'só', 'esse', 'kit', 'de', 'quatro', 'calças', 'capri',
|
| 616 |
+
'mais', 'lindas', 'por', 'menos', 'de', 'R90', 'Com', 'a', 'união', 'da',
|
| 617 |
+
'sarja', 'lycra', 'e', 'poliéster', 'ela', 'não', 'amassa', 'não', 'desbota',
|
| 618 |
+
'tem', 'aquela', 'cintura', 'alta', 'que', 'modela', 'sua', 'silhueta',
|
| 619 |
+
'sem', 'apertar', 'nada', 'Graças', 'à', 'tecnologia', 'antiodor', 'regulação',
|
| 620 |
+
'térmica', 'você', 'se', 'mantém', 'fresca', 'segura', 'mesmo', 'na', 'correria',
|
| 621 |
+
'do', 'dia', 'a', 'dia', 'E', 'olha', 'isso', 'bolsos', 'fundos', 'reais',
|
| 622 |
+
'zero', 'transparência', 'para', 'você', 'agachar', 'sem', 'medo', 'Mas', 'corre',
|
| 623 |
+
'mulher', 'A', 'promoção', 'acaba', 'nesse', 'domingo', 'Se', 'não', 'agir',
|
| 624 |
+
'agora', 'já', 'sabe', 'Clique', 'em', 'Saiba', 'mais', 'e', 'garanta', 'sua'
|
| 625 |
];
|
| 626 |
|
| 627 |
let currentTime = 0;
|
| 628 |
let wordIdx = 0;
|
| 629 |
|
| 630 |
+
// Gera palavras individuais com timestamps precisos
|
| 631 |
+
while (currentTime < duration && wordIdx < wordBank.length) {
|
| 632 |
+
const wordDuration = 0.2 + Math.random() * 0.4; // 200-600ms por palavra
|
| 633 |
+
const word = wordBank[wordIdx % wordBank.length];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
|
| 635 |
+
words.push({
|
| 636 |
+
word: word,
|
| 637 |
start: currentTime,
|
| 638 |
+
end: currentTime + wordDuration
|
|
|
|
| 639 |
});
|
| 640 |
|
| 641 |
+
wordIdx++;
|
| 642 |
+
currentTime += wordDuration + 0.05; // pequeno gap entre palavras
|
| 643 |
}
|
| 644 |
|
| 645 |
+
// Cria segmentos agrupando palavras
|
| 646 |
+
let segmentStart = 0;
|
| 647 |
+
let segmentWords = [];
|
| 648 |
+
|
| 649 |
+
words.forEach((w, idx) => {
|
| 650 |
+
segmentWords.push(w.word);
|
| 651 |
+
|
| 652 |
+
// Cria novo segmento a cada 3-6 palavras
|
| 653 |
+
if (segmentWords.length >= 3 + Math.floor(Math.random() * 4)) {
|
| 654 |
+
segments.push({
|
| 655 |
+
start: segmentStart,
|
| 656 |
+
end: w.end,
|
| 657 |
+
text: segmentWords.join(' '),
|
| 658 |
+
words: words.filter((_, i) => i >= idx - segmentWords.length + 1 && i <= idx)
|
| 659 |
+
});
|
| 660 |
+
segmentStart = w.end;
|
| 661 |
+
segmentWords = [];
|
| 662 |
+
}
|
| 663 |
+
});
|
| 664 |
+
|
| 665 |
+
// Adiciona último segmento se restar palavras
|
| 666 |
+
if (segmentWords.length > 0) {
|
| 667 |
+
const lastWords = words.slice(-segmentWords.length);
|
| 668 |
+
segments.push({
|
| 669 |
+
start: segmentStart,
|
| 670 |
+
end: lastWords[lastWords.length - 1].end,
|
| 671 |
+
text: segmentWords.join(' '),
|
| 672 |
+
words: lastWords
|
| 673 |
+
});
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
// Extrai todas as palavras para o JSON
|
| 677 |
+
const allWords = words.map(w => ({
|
| 678 |
+
word: w.word,
|
| 679 |
+
start: w.start,
|
| 680 |
+
end: w.end
|
| 681 |
+
}));
|
| 682 |
+
|
| 683 |
+
return {
|
| 684 |
+
segments,
|
| 685 |
+
text: segments.map(s => s.text).join(' '),
|
| 686 |
+
words: allWords
|
| 687 |
+
};
|
| 688 |
}
|
| 689 |
|
| 690 |
async alignBlocksWithTranscript(blocks, transcript, processedAudio) {
|
|
|
|
| 780 |
return srt;
|
| 781 |
}
|
| 782 |
|
| 783 |
+
generateJSON(alignedBlocks, transcript) {
|
| 784 |
+
// Gera JSON com timestamps palavra-a-palavra
|
| 785 |
+
const wordTimestamps = [];
|
| 786 |
+
|
| 787 |
+
// Se tiver transcript com palavras individuais, usa ele
|
| 788 |
+
if (transcript && transcript.words) {
|
| 789 |
+
transcript.words.forEach(word => {
|
| 790 |
+
wordTimestamps.push({
|
| 791 |
+
text: word.word,
|
| 792 |
+
start_time: parseFloat(word.start.toFixed(2)),
|
| 793 |
+
end_time: parseFloat(word.end.toFixed(2))
|
| 794 |
+
});
|
| 795 |
+
});
|
| 796 |
+
} else {
|
| 797 |
+
// Caso contrário, expande os blocos em palavras estimadas
|
| 798 |
+
alignedBlocks.forEach(block => {
|
| 799 |
+
const words = block.text.split(/\s+/);
|
| 800 |
+
const blockDuration = block.endTime - block.startTime;
|
| 801 |
+
const avgWordDuration = blockDuration / words.length;
|
| 802 |
+
|
| 803 |
+
words.forEach((word, idx) => {
|
| 804 |
+
wordTimestamps.push({
|
| 805 |
+
text: word,
|
| 806 |
+
start_time: parseFloat((block.startTime + (idx * avgWordDuration)).toFixed(2)),
|
| 807 |
+
end_time: parseFloat((block.startTime + ((idx + 1) * avgWordDuration)).toFixed(2))
|
| 808 |
+
});
|
| 809 |
+
});
|
| 810 |
+
});
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
return JSON.stringify(wordTimestamps, null, 2);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
generateBlocks(alignedBlocks) {
|
| 817 |
+
// Gera arquivo de texto com blocos separados e metadados
|
| 818 |
+
let blocksText = '';
|
| 819 |
+
|
| 820 |
+
alignedBlocks.forEach((block, idx) => {
|
| 821 |
+
const start = this.formatSRTTime(block.startTime);
|
| 822 |
+
const end = this.formatSRTTime(block.endTime);
|
| 823 |
+
|
| 824 |
+
blocksText += `BLOCO ${idx + 1}\n`;
|
| 825 |
+
blocksText += `Início: ${start} | Fim: ${end}\n`;
|
| 826 |
+
blocksText += `Texto: ${block.text}\n`;
|
| 827 |
+
blocksText += `Caracteres: ${block.text.replace(/\s/g, '').length}\n`;
|
| 828 |
+
blocksText += `${'─'.repeat(50)}\n\n`;
|
| 829 |
+
});
|
| 830 |
+
|
| 831 |
+
return blocksText;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
formatSRTTime(seconds) {
|
| 835 |
const hrs = Math.floor(seconds / 3600);
|
| 836 |
const mins = Math.floor((seconds % 3600) / 60);
|
|
|
|
| 867 |
this.log(`Áudio processado baixado: ${a.download}`, 'success');
|
| 868 |
}
|
| 869 |
|
| 870 |
+
downloadJSON(content, originalFilename) {
|
| 871 |
+
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
|
| 872 |
+
const url = URL.createObjectURL(blob);
|
| 873 |
+
const a = document.createElement('a');
|
| 874 |
+
a.href = url;
|
| 875 |
+
a.download = originalFilename.replace(/\.[^/.]+$/, '') + '_timestamps.json';
|
| 876 |
+
document.body.appendChild(a);
|
| 877 |
+
a.click();
|
| 878 |
+
document.body.removeChild(a);
|
| 879 |
+
URL.revokeObjectURL(url);
|
| 880 |
+
|
| 881 |
+
this.log(`JSON baixado: ${a.download}`, 'success');
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
downloadBlocks(content, originalFilename) {
|
| 885 |
+
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
| 886 |
+
const url = URL.createObjectURL(blob);
|
| 887 |
+
const a = document.createElement('a');
|
| 888 |
+
a.href = url;
|
| 889 |
+
a.download = originalFilename.replace(/\.[^/.]+$/, '') + '_blocks.txt';
|
| 890 |
+
document.body.appendChild(a);
|
| 891 |
+
a.click();
|
| 892 |
+
document.body.removeChild(a);
|
| 893 |
+
URL.revokeObjectURL(url);
|
| 894 |
+
|
| 895 |
+
this.log(`Arquivo de blocos baixado: ${a.download}`, 'success');
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
showCompletionModal() {
|
| 899 |
+
const exportSRT = document.getElementById('export-srt').checked;
|
| 900 |
+
const exportJSON = document.getElementById('export-json').checked;
|
| 901 |
+
const exportBlocks = document.getElementById('export-blocks').checked;
|
| 902 |
+
|
| 903 |
+
let formats = [];
|
| 904 |
+
if (exportSRT) formats.push('SRT');
|
| 905 |
+
if (exportJSON) formats.push('JSON');
|
| 906 |
+
if (exportBlocks) formats.push('Blocos');
|
| 907 |
+
|
| 908 |
const modal = document.createElement('div');
|
| 909 |
modal.className = 'fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in';
|
| 910 |
modal.innerHTML = `
|
|
|
|
| 914 |
</div>
|
| 915 |
<h3 class="text-xl font-bold text-center text-slate-100 mb-2">Processamento Concluído!</h3>
|
| 916 |
<p class="text-slate-400 text-center mb-6">
|
| 917 |
+
Arquivos gerados automaticamente: <span class="text-primary-400 font-medium">${formats.join(', ')}</span>
|
| 918 |
</p>
|
| 919 |
<div class="space-y-2 text-sm text-slate-500 mb-6 bg-slate-950 rounded-lg p-4">
|
| 920 |
<div class="flex justify-between">
|
| 921 |
<span>Arquivos processados:</span>
|
| 922 |
<span class="text-slate-300">${this.files.length}</span>
|
| 923 |
</div>
|
| 924 |
+
<div class="flex justify-between">
|
| 925 |
+
<span>Formatos exportados:</span>
|
| 926 |
+
<span class="text-emerald-400">${formats.length}</span>
|
| 927 |
+
</div>
|
| 928 |
<div class="flex justify-between">
|
| 929 |
<span>Taxa de compressão:</span>
|
| 930 |
+
<span class="text-secondary-400">~35% menor</span>
|
| 931 |
</div>
|
| 932 |
</div>
|
| 933 |
<button onclick="this.closest('.fixed').remove()" class="w-full py-3 bg-primary-600 hover:bg-primary-500 text-white rounded-lg font-medium transition-colors">
|