TranStudio / index.html
bitsnaps's picture
fix: navbar override
4d65912 verified
raw
history blame
61.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TranStudio</title>
<link rel="stylesheet" href="/static/css/buefy.min.css">
<link rel="stylesheet" href="/static/css/materialdesignicons.min.css">
<script>
window.process = { env: { NODE_ENV: 'production' } };
</script>
<script src="/static/js/vue.global.prod.js"></script>
<script src="/static/js/buefy.min.js"></script>
<style>
.segments-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
}
.segment-box {
padding: 10px;
margin: 5px 0;
border: 1px solid #eee;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.segment-box:hover {
background-color: #f5f5f5;
}
.segment-box.is-active {
border-color: #3273dc;
background-color: #e8f0fe;
}
.segment-time {
font-size: 0.8em;
color: #666;
margin-bottom: 5px;
}
.segment-text {
margin: 5px 0;
}
.segment-actions {
text-align: right;
}
.audio-player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 1rem;
box-shadow: 0 -2px 5px rgba(0,0,0,0.1);
z-index: 100;
margin-top: 2rem;
}
.waveform-container {
margin: 1rem 0;
padding: 0 1rem;
}
.waveform {
position: relative;
height: 10vh;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: rgba(50, 115, 220, 0.3);
pointer-events: none;
}
.player-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.segment-marker {
position: absolute;
height: 100%;
background: rgba(50, 115, 220, 0.2);
cursor: pointer;
transition: background 0.2s;
border-left: 1px solid #3273dc;
border-right: 1px solid #3273dc;
}
.segment-marker:hover {
background: rgba(50, 115, 220, 0.4);
}
.segment-tooltip {
position: absolute;
bottom: 100%;
background: #333;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.8rem;
white-space: nowrap;
display: none;
}
.segment-marker:hover .segment-tooltip {
display: block;
}
.current-time {
text-align: center;
font-size: 0.9rem;
color: #666;
margin-top: 0.5rem;
}
.control-btn {
/* padding: 0.5rem; */
transition: all 0.2s;
}
.control-btn:hover {
transform: scale(1.1);
}
.selection-info {
margin: 10px 0;
display: flex;
align-items: center;
}
.slider-tick {
width: 2px;
height: 12px;
background-color: currentColor;
border-radius: 1px;
}
/* Customize the range slider track */
.b-slider.is-info .b-slider-track {
background: rgba(50, 115, 220, 0.3);
}
/* Style for the selected range */
.b-slider.is-info .b-slider-fill {
background: #3273dc;
}
.navbar-end {
margin-top: 10vh;
}
</style>
</head>
<body>
<div id="app" class="container">
<b-navbar>
<template #start>
<b-navbar-item @click="toggleSidebar">
<b-icon :icon="showSidebar ? 'menu-open' : 'menu'"></b-icon>
</b-navbar-item>
<b-navbar-item tag="router-link" :to="{ path: '/' }">
<a href="/">TranStudio</a>
</b-navbar-item>
</template>
<template #end>
<!-- <b-navbar-dropdown :label="isAuthenticated ? : 'Account'"> -->
<template v-if="isAuthenticated">
<b-navbar-item v-if="isAdmin" @click="showAdminPanel = true">
<b-icon icon="shield-account"></b-icon>
<span class="ml-1">Admin</span>
</b-navbar-item>
<b-navbar-item @click="logout">Logout {{ username }}</b-navbar-item>
</template>
<template v-else>
<b-navbar-item @click="showLoginModal = true">Login</b-navbar-item>
<b-navbar-item @click="showSignupModal = true">Sign Up</b-navbar-item>
</template>
<!-- </b-navbar-dropdown> -->
</template>
</b-navbar>
<div class="columns mb-6 pb-6">
<!-- Sidebar -->
<div class="column is-one-quarter" v-if="showSidebar">
<b-menu>
<b-menu-list :label="`Previous Transcriptions (${transcriptions.length})`">
<b-menu-item v-for="transcription in transcriptions"
class="is-flex is-justify-content-space-between is-align-items-center"
:key="transcription.id"
@click="loadTranscription(transcription)" :label="transcription.name">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="width: 100%">
<span>
{{transcription?.audio_file}}
</span>
<b-button
type="is-danger"
size="is-small"
icon-left="delete"
@click.stop="deleteTranscription(transcription.id)">
</b-button>
</div>
</b-menu-item>
</b-menu-list>
</b-menu>
</div>
<!-- Main Content -->
<div class="column">
<!-- <b-field label="Select AI Model">
<b-select v-model="selectedModel">
<option value="whisper-large-v3-turbo">Groq - Whisper Large v3 Turbo</option>
<option value="openai-whisper-large-v3-turbo">Huggingface - OpenAI Whisper Large v3 Turbo</option>
</b-select>
</b-field> -->
<b-field label="Format">
<b-select v-model="responseFormat">
<option value="verbose_json">Verbose (with timestamps)</option>
<option value="json">Standard</option>
<option value="text">Simple</option>
</b-select>
</b-field>
<b-field label="Language">
<b-select v-model="selectedLanguage">
<option value="auto">Auto Detect</option>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="ar">Arabic</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="it">Italian</option>
</b-select>
</b-field>
<b-field label="Temperature">
<b-slider v-model="temperature" :min="0" :max="1" :step="0.1"></b-slider>
<span class="tag is-primary">{{ temperature }}</span>
</b-field>
<b-field label="Chunk Size (in minutes)">
<b-slider v-model="chunkSize" :min="5" :max="30" :step="1"></b-slider>
<span class="tag is-primary">{{ chunkSize }}</span>
</b-field>
<b-field label="Overlap (in seconds)">
<b-slider v-model="overlap" :min="2" :max="60" :step="1"></b-slider>
<span class="tag is-primary">{{ overlap }}</span>
</b-field>
<b-field label="Selection" v-if="totalDuration">
<b-slider v-model="selection" :step="1" type="is-info" :tooltip="false">
<!-- <template v-for="n in totalDuration" :key="n">
<b-slider-tick :value="n">{{ n }}</b-slider-tick>
</template> -->
</b-slider>
</b-field>
<div class="selection-info" v-if="totalDuration">
<span class="tag is-info">
Start: {{ formatTime(selection[0] * totalDuration / 100) }}
</span>
<span class="tag is-info ml-2">
End: {{ formatTime(selection[1] * totalDuration / 100) }}
</span>
</div>
<b-field label="System Prompt (Optional)">
<b-input v-model="systemPrompt" maxlength="256" type="textarea"></b-input>
</b-field>
<b-upload v-model="audioFile" accept="audio/*" @change="handleAudioUpload" multiple>
<a class="button is-primary">
<b-icon icon="upload"></b-icon>
<span>Click to upload</span>
</a>
</b-upload>
<b-field v-if="audioFile.length">
<span class="file-name">
{{ audioFile[0].name }}
</span>
</b-field>
</b-field>
<!-- <div v-if="audioUrl" class="audio-controls mb-4">
<audio ref="audioPlayer" :src="audioUrl"></audio>
<b-button @click="togglePlayPause">
{{ isPlaying ? 'Pause' : 'Play' }}
</b-button>
</div> -->
<b-field class="mt-2" label="Transcription">
<b-input v-model="transcriptionText" type="textarea" rows="5"></b-input>
</b-field>
<b-field class="mt-2" label="Segments" v-if="segments.length && responseFormat === 'verbose_json'">
<div class="segments-container">
<div v-for="(segment, index) in segments" :key="segment.id"
class="segment-box"
:class="{'is-active': activeSegment === index}"
@click="playSegment(segment)">
<div class="segment-time">
{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}
</div>
<div class="segment-text">
<b-input v-model="segment.text" type="textarea" rows="2"></b-input>
</div>
<div class="segment-actions">
<b-button type="is-info" size="is-small" @click.stop="copySegmentText(segment.text)">
<b-icon icon="content-copy"></b-icon>
</b-button>
<b-button type="is-danger" size="is-small" @click.stop="deleteSegment(index)">
<b-icon icon="delete"></b-icon>
</b-button>
</div>
</div>
</div>
</b-field>
<b-button type="is-primary" class="mr-2" @click="processTranscription" :disabled="!audioUrl" :loading="isProcessing">Process</b-button>
<b-button type="is-success" class="mr-2" @click="saveTranscription" :disabled="!transcriptionText" :loading="isProcessing">Save</b-button>
<b-button class="control-btn" @click="togglePlayer" :disabled="!audioUrl">
<b-icon :icon="showPlayer ? 'menu-down' : 'menu-up'"></b-icon>
</b-button>
</div>
</div><!-- .columns -->
<div class="columns mt-2">
<div class="audio-player" v-if="showPlayer">
<div class="player-controls" v-if="audioUrl">
<b-button class="control-btn" @click="togglePlay">
<b-icon :icon="isPlaying ? 'pause' : 'play'"></b-icon>
</b-button>
<b-button class="control-btn" @click="stopAudio">
<b-icon icon="stop"></b-icon>
</b-button>
<b-slider v-model="volume" :min="0" :max="1" :step="0.1" @input="updateVolume" size="is-small">
<template #indicator>
<span class="tag is-primary is-small">{{ volume.toFixed(1) }}</span>
</template>
</b-slider>
<b-button class="control-btn" @click="toggleMute">
<b-icon :icon="isMuted ? 'volume-off' : 'volume-high'"></b-icon>
</b-button>
<b-button class="control-btn" @click="togglePlayer">
<b-icon :icon="showPlayer ? 'menu-down' : 'menu-up'"></b-icon>
</b-button>
</div><!-- .player-controls -->
<div class="waveform-container" v-if="segments.length">
<div class="waveform" ref="waveform">
<div v-for="(segment, index) in segments"
:key="segment.id"
class="segment-marker"
:style="{ left: `${(segment.start / totalDuration) * 100}%`, width: `${((segment.end - segment.start) / totalDuration) * 100}%` }"
@click="playSegment(segment)">
<div class="segment-tooltip">
{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}
</div>
</div>
<div class="progress" :style="{ width: `${(currentTime / totalDuration) * 100}%` }"></div>
</div>
</div><!-- .waveform-container -->
<div class="current-time">
{{ formatTime(currentTime) }} / {{ formatTime(totalDuration) }}
</div>
</div><!-- .audio-player -->
</div>
<!-- Login Modal -->
<b-modal v-model="showLoginModal" has-modal-card trap-focus>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Login</p>
</header>
<section class="modal-card-body">
<b-field label="Username">
<b-input type="text" v-model="loginForm.username" placeholder="Enter your username"></b-input>
</b-field>
<b-field label="Password">
<b-input type="password" v-model="loginForm.password" password-reveal></b-input>
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary" @click="handleLogin" :loading="isLoading">Login</b-button>
<b-button @click="showLoginModal = false">Cancel</b-button>
</footer>
</div>
</b-modal>
<!-- Signup Modal -->
<b-modal v-model="showSignupModal" has-modal-card trap-focus>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Sign Up</p>
</header>
<section class="modal-card-body">
<b-field label="Username">
<b-input v-model="signupForm.username" placeholder="Enter username"></b-input>
</b-field>
<b-field label="Email">
<b-input type="email" v-model="signupForm.email" placeholder="Enter your email"></b-input>
</b-field>
<b-field label="Password">
<b-input type="password" v-model="signupForm.password" password-reveal></b-input>
</b-field>
<b-field label="Confirm Password">
<b-input type="password" v-model="signupForm.confirmPassword" password-reveal></b-input>
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary" @click="handleSignup" :loading="isLoading">Sign Up</b-button>
<b-button @click="showSignupModal = false">Cancel</b-button>
</footer>
</div>
</b-modal>
<!-- Admin Panel Modal -->
<b-modal v-model="showAdminPanel" has-modal-card trap-focus :width="640">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">User Management</p>
</header>
<section class="modal-card-body">
<div class="block">
<b-field grouped>
<b-input placeholder="Search users..." v-model="userSearchQuery" expanded></b-input>
<b-button type="is-primary" icon-left="magnify">Search</b-button>
</b-field>
</div>
<b-table
:data="filteredUsers"
:loading="loadingUsers"
:paginated="true"
:per-page="5"
:mobile-cards="true">
<b-table-column field="id" label="ID" width="40" numeric v-slot="props">
{{ props.row.id }}
</b-table-column>
<b-table-column field="username" label="Username" v-slot="props">
{{ props.row.username }}
</b-table-column>
<b-table-column field="email" label="Email" v-slot="props">
{{ props.row.email }}
</b-table-column>
<b-table-column field="is_admin" label="Admin" v-slot="props">
<b-icon
:icon="props.row.is_admin ? 'check' : 'close'"
:type="props.row.is_admin ? 'is-success' : 'is-danger'">
</b-icon>
</b-table-column>
<b-table-column field="disabled" label="Status" v-slot="props">
<b-tag :type="props.row.disabled ? 'is-danger' : 'is-success'">
{{ props.row.disabled ? 'Disabled' : 'Active' }}
</b-tag>
</b-table-column>
<b-table-column label="Actions" v-slot="props">
<div class="buttons">
<b-button
size="is-small"
:type="props.row.disabled ? 'is-success' : 'is-warning'"
:icon-left="props.row.disabled ? 'account-check' : 'account-cancel'"
@click="toggleUserStatus(props.row)"
:disabled="props.row.is_admin && props.row.id === currentUser.id">
{{ props.row.disabled ? 'Enable' : 'Disable' }}
</b-button>
<b-button
size="is-small"
type="is-danger"
icon-left="delete"
@click="deleteUser(props.row)"
:disabled="props.row.id === currentUser.id">
Delete
</b-button>
</div>
</b-table-column>
<template #empty>
<div class="has-text-centered">No users found</div>
</template>
</b-table>
</section>
<footer class="modal-card-foot">
<b-button @click="refreshUsers" type="is-info" icon-left="refresh">Refresh</b-button>
<b-button @click="showAdminPanel = false">Close</b-button>
</footer>
</div>
</b-modal>
</div><!-- #app -->
<script src="/static/js/howler.min.js"></script>
<script>
const { createApp, ref, onMounted } = Vue;
const app = createApp({
data() {
return {
responseFormat: 'verbose_json',
temperature: 0,
chunkSize: 10,
overlap: 5,
selection: [1,100],
systemPrompt: '',
isAuthenticated: false,
username: '',
token: '',
isLoading: false,
audioFile: [],
segments: [],
transcriptionText: '',
selectedLanguage: 'auto',
transcriptions: [],
showSidebar: true,
showPlayer: false,
showApiKeyModal: false,
audioUrl: null,
isPlaying: false,
audioPlayer: null,
isProcessing: false,
howl: null,
activeSegment: null,
isPlayingSegment: false,
isMuted: false,
volume: 0.5,
currentTime: 0,
totalDuration: 0,
seekInterval: null,
waveformData: null,
waveformCanvas: null,
ctx: null,
showLoginModal: false,
loginForm: {
username: '',
password: '',
},
showSignupModal: false,
signupForm: {
username: '',
email: '',
password: '',
confirmPassword: ''
},
// Admin panel data
showAdminPanel: false,
isAdmin: false,
currentUser: null,
users: [],
loadingUsers: false,
userSearchQuery: '',
// processingProgress: 0,
// totalChunks: 0,
// showVolumeSlider: false
}
},
methods: {
async login() {
this.isAuthenticated = true;
},
logout() {
this.token = '';
this.username = '';
this.isAuthenticated = false;
this.isAdmin = false;
this.currentUser = null;
this.users = [];
this.transcriptions = [];
localStorage.removeItem('token');
// Reset UI state
this.audioUrl = null;
this.audioFile = [];
this.segments = [];
this.transcriptionText = '';
// Show notification
this.$buefy.toast.open({
message: 'Successfully logged out!',
type: 'is-success'
});
// Redirect to home page if not already there
if (window.location.pathname !== '/') {
window.location.href = '/';
} else {
// window.location.reload();
}
},
async handleAudioUpload(event) {
if (!event.target.files || !event.target.files.length) return;
const file = event.target.files[0];
const fileUrl = URL.createObjectURL(file);
fileExt = file.name.split('.').splice(-1);
this.howl = new Howl({
src: [fileUrl],
format: this.fileExt, // This should be an array
html5: true,
onend: () => {
this.isPlayingSegment = false;
this.activeSegment = null;
}
});
this.audioUrl = fileUrl;
this.audioFile = [file];
},
initializeAudio() {
if (this.howl) {
this.howl.unload();
}
const fileExt = this.audioFile[0].name.split('.').splice(-1);
this.howl = new Howl({
src: [this.audioUrl],
format: fileExt,
html5: true,
volume: this.volume,
sprite: this.createSprites(),
onplay: () => {
this.isPlaying = true;
this.startSeekUpdate();
},
onpause: () => {
this.isPlaying = false;
this.stopSeekUpdate();
},
onstop: () => {
this.isPlaying = false;
this.currentTime = 0;
this.stopSeekUpdate();
},
onend: () => {
this.isPlaying = false;
this.currentTime = 0;
this.stopSeekUpdate();
},
onload: () => {
this.totalDuration = this.howl.duration();
/*/ Set selection to the length of the audio file
this.selection = [];
for (let i = 0; i < this.totalDuration; i += this.chunkSize) {
this.selection.push(i);
}*/
}
});
},
async initializeWaveform() {
if (!this.audioUrl) return;
// Load audio file and decode it
const response = await fetch(this.audioUrl);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// Get waveform data
const channelData = audioBuffer.getChannelData(0);
this.waveformData = this.processWaveformData(channelData);
// Draw waveform
this.$nextTick(() => {
this.drawWaveform();
});
},
processWaveformData(data) {
const step = Math.ceil(data.length / 1000);
const waveform = [];
for (let i = 0; i < data.length; i += step) {
const slice = data.slice(i, i + step);
// Use reducer instead of spread operator for large arrays
const max = slice.reduce((a, b) => Math.max(a, b), -Infinity);
const min = slice.reduce((a, b) => Math.min(a, b), Infinity);
waveform.push({ max, min });
}
return waveform;
},
drawWaveform() {
if (this.$refs.waveform){
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const width = this.$refs.waveform.offsetWidth;
const height = 100;
canvas.width = width;
canvas.height = height;
this.$refs.waveform.style.backgroundImage = `url(${canvas.toDataURL()})`;
ctx.fillStyle = '#3273dc';
this.waveformData.forEach((point, i) => {
const x = (i / this.waveformData.length) * width;
const y = (1 - point.max) * height / 2;
const h = (point.max - point.min) * height / 2;
ctx.fillRect(x, y, 1, h);
});
}
},
createSprites() {
const sprites = {};
this.segments.forEach((segment, index) => {
sprites[`segment_${index}`] = [segment.start * 1000, (segment.end - segment.start) * 1000];
});
return sprites;
},
togglePlay() {
if (this.isPlaying) {
this.howl.pause();
} else {
this.howl.play();
}
},
stopAudio() {
this.howl.stop();
},
updateVolume() {
if (this.volume <= 0) {
this.isMuted = true;
this.howl.mute(true);
} else if (this.isMuted && this.volume > 0) {
this.isMuted = false;
this.howl.mute(false);
}
this.howl.volume(this.volume);
},
toggleVolumeSlider() {
this.showVolumeSlider = !this.showVolumeSlider;
},
toggleMute() {
this.isMuted = !this.isMuted;
this.howl.mute(this.isMuted);
if (!this.isMuted && this.volume === 0) {
this.volume = 0.5;
}
},
playSegment(segment) {
const index = this.segments.indexOf(segment);
if (index !== -1) {
this.howl.play(`segment_${index}`);
}
},
startSeekUpdate() {
this.seekInterval = setInterval(() => {
this.currentTime = this.howl.seek() || 0;
}, 100);
},
stopSeekUpdate() {
clearInterval(this.seekInterval);
},
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
},
togglePlayPause() {
if (this.audioPlayer.paused) {
this.audioPlayer.play();
this.isPlaying = true;
} else {
this.audioPlayer.pause();
this.isPlaying = false;
}
},
playSegment(segment) {
if (!this.howl) return;
if (this.activeSegment === segment.id && this.isPlayingSegment) {
this.howl.pause();
this.isPlayingSegment = false;
return;
}
this.howl.seek(segment.start);
this.howl.play();
this.activeSegment = segment.id;
this.isPlayingSegment = true;
this.howl.once('end', () => {
this.isPlayingSegment = false;
this.activeSegment = null;
});
},
copySegmentText(text) {
navigator.clipboard.writeText(text).then(() => {
this.$buefy.toast.open({
message: 'Text copied to clipboard',
type: 'is-success',
position: 'is-bottom-right'
});
}).catch(() => {
this.$buefy.toast.open({
message: 'Failed to copy text',
type: 'is-danger',
position: 'is-bottom-right'
});
});
},
deleteSegment(index) {
this.segments.splice(index, 1);
this.updateTranscriptionText();
},
updateTranscriptionText() {
this.transcriptionText = this.segments
.map(segment => segment.text)
.join(' ');
},
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
},
async processTranscription() {
const formData = new FormData();
formData.append('file', this.audioFile[0]);
formData.append('response_format', this.responseFormat);
formData.append('temperature', this.temperature.toString());
formData.append('chunk_size', parseInt(this.chunkSize * 60).toString());
formData.append('overlap', this.overlap.toString());
formData.append('prompt', this.systemPrompt.toString());
// Add selection timestamps
const startTime = (this.selection[0] * this.totalDuration / 100).toFixed(2);
const endTime = (this.selection[1] * this.totalDuration / 100).toFixed(2);
formData.append('start_time', startTime);
formData.append('end_time', endTime);
if (this.selectedLanguage !== 'auto') {
formData.append('language', this.selectedLanguage);
}
try {
this.isProcessing = true;
/*/ Monitor the progress of the upload for user feedback (need to be updated for reading back response)
const xhr = new XMLHttpRequest();
xhr.upload.onloadstart = function (event) {
console.log('Upload started');
};
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
console.log(`Upload progress: ${percentComplete.toFixed(2)}%`);
}
};
xhr.upload.onload = function () {
console.log('Upload complete');
};
xhr.onerror = function () {
console.error('Error uploading file');
};
xhr.open('POST', '/api/upload', true);
xhr.send(formData);*/
const response = await fetch('/api/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
},
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Transcription failed');
}
const result = await response.json();
// Check for backend validation errors
if (result.metadata?.errors?.length) {
console.error('Chunk processing errors:', result.metadata.errors);
this.$buefy.snackbar.open({
message: `${result.metadata.errors.length} chunks failed to process`,
type: 'is-warning',
position: 'is-bottom-right'
});
}
if (this.responseFormat === 'verbose_json') {
// Validate segments
if (!result.segments) {
throw new Error('Server returned invalid segments format');
}
this.segments = result.segments;
this.updateTranscriptionText();
} else {
this.transcriptionText = result.text || result;
}
// this.totalChunks = result.metadata?.total_chunks || 0;
// this.processingProgress = ((processedChunks / this.totalChunks) * 100).toFixed(1);
} catch (error) {
console.error('Transcription error:', error);
this.$buefy.toast.open({
message: `Error: ${error.message}`,
type: 'is-danger',
position: 'is-bottom-right'
});
}
this.isProcessing = false;
this.showPlayer = true;
},
async loadTranscriptions() {
try {
// Check if token exists
if (!this.token) {
this.token = localStorage.getItem('token');
if (!this.token) {
// console.log('No authentication token found');
return;
}
}
const response = await fetch('/api/transcriptions', {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (response.status === 401) {
// Token expired or invalid
console.log('Authentication token expired or invalid');
this.logout();
this.$buefy.toast.open({
message: 'Your session has expired. Please login again.',
type: 'is-warning'
});
return;
}
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
this.transcriptions = await response.json();
} catch (error) {
console.error('Error loading transcriptions:', error);
this.$buefy.toast.open({
message: `Failed to load transcriptions: ${error.message}`,
type: 'is-danger'
});
}
},
async loadTranscription(transcription) {
try {
const response = await fetch(`/api/transcriptions/${transcription.id}`, {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
const data = await response.json();
// Set the audio file for playback
this.transcriptionText = data['text'];
this.segments = data['segments'];
// this.audioFile = data['audio_file'];
if (data.audio_file) {
// this.audioUrl = `/uploads/${data['audio_file']}`;
// this.audioFile.push(data['audio_file']);
// this.initializeAudio();
}
} catch (error) {
console.error('Error loading transcription:', error);
}
},
async saveTranscription() {
if (this.audioFile.length == 0){
this.$buefy.toast.open({
message: 'Please upload an audio file first',
type: 'is-warning',
position: 'is-bottom-right'
});
return;
}
try {
this.isProcessing = true;
const response = await fetch('/api/save-transcription', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({
text: this.transcriptionText,
segments: this.segments,
audio_file: this.audioFile[0].name
})
});
if (!response.ok) throw new Error('Failed to save transcription');
await this.loadTranscriptions();
this.$buefy.toast.open({
message: 'Transcription saved successfully',
type: 'is-success',
position: 'is-bottom-right'
});
} catch (error) {
console.error('Error saving transcription:', error);
this.$buefy.toast.open({
message: `Error: ${error.message}`,
type: 'is-danger',
position: 'is-bottom-right'
});
}
this.isProcessing = false;
},
toggleSidebar() {
this.showSidebar = !this.showSidebar;
},
togglePlayer() {
this.showPlayer = !this.showPlayer;
},
async deleteTranscription(id) {
try {
// Show confirmation dialog
this.$buefy.dialog.confirm({
title: 'Delete Transcription',
message: 'Are you sure you want to delete this transcription? This action cannot be undone.',
confirmText: 'Delete',
type: 'is-danger',
hasIcon: true,
onConfirm: async () => {
const response = await fetch(`/api/transcriptions/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (!response.ok) throw new Error('Failed to delete transcription');
// Remove from local list
this.transcriptions = this.transcriptions.filter(t => t.id !== id);
// Show success message
this.$buefy.toast.open({
message: 'Transcription deleted successfully',
type: 'is-success',
position: 'is-bottom-right'
});
}
});
} catch (error) {
console.error('Error deleting transcription:', error);
this.$buefy.toast.open({
message: `Error: ${error.message}`,
type: 'is-danger',
position: 'is-bottom-right'
});
}
},
async handleLogin() {
try {
this.isLoading = true;
const formData = new FormData();
formData.append('username', this.loginForm.username); // Make sure this matches the backend expectation
formData.append('password', this.loginForm.password);
const response = await fetch('/token', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Login failed');
}
const data = await response.json();
this.token = data.access_token;
localStorage.setItem('token', data.access_token);
// Get user info
const userResponse = await fetch('/api/me', {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (!userResponse.ok) {
throw new Error('Failed to get user information');
}
const userData = await userResponse.json();
this.username = userData.username;
this.isAuthenticated = true;
this.showLoginModal = false;
this.$buefy.toast.open({
message: 'Successfully logged in!',
type: 'is-success'
});
// Load user data after successful login
this.loadTranscriptions();
} catch (error) {
this.$buefy.toast.open({
message: `Error: ${error.message}`,
type: 'is-danger'
});
} finally {
this.isLoading = false;
}
},
async handleSignup() {
try {
this.isLoading = true;
if (this.signupForm.password !== this.signupForm.confirmPassword) {
throw new Error('Passwords do not match');
}
const response = await fetch('/api/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: this.signupForm.username,
email: this.signupForm.email,
password: this.signupForm.password
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Signup failed');
}
this.showSignupModal = false;
this.$buefy.toast.open({
message: 'Account created successfully! Please login.',
type: 'is-success'
});
// Clear form
this.signupForm = {
username: '',
email: '',
password: '',
confirmPassword: ''
};
// Show login modal
this.showLoginModal = true;
} catch (error) {
this.$buefy.toast.open({
message: `Error: ${error.message}`,
type: 'is-danger'
});
} finally {
this.isLoading = false;
}
},
async checkAuth() {
const token = localStorage.getItem('token');
if (token) {
try {
this.token = token;
const response = await fetch('/api/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const userData = await response.json();
this.username = userData.username;
this.isAuthenticated = true;
this.isAdmin = userData.is_admin;
this.currentUser = userData;
// If user is admin, load users list
if (this.isAdmin) {
this.loadUsers();
}
} else {
// Token invalid or expired
this.logout();
}
} catch (error) {
console.error('Auth check failed:', error);
this.logout();
}
}
},
// Add these methods to the methods section
async loadUsers() {
if (!this.isAdmin) return;
try {
this.loadingUsers = true;
const response = await fetch('/api/users', {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (!response.ok) {
throw new Error('Failed to load users');
}
this.users = await response.json();
} catch (error) {
console.error('Error loading users:', error);
this.$buefy.toast.open({
message: `Error: ${error.message}`,
type: 'is-danger'
});
} finally {
this.loadingUsers = false;
}
},
async toggleUserStatus(user) {
try {
// Don't allow admins to disable themselves
if (user.is_admin && user.id === this.currentUser.id && !user.disabled) {
this.$buefy.toast.open({
message: 'Admins cannot disable their own accounts',
type: 'is-warning'
});
return;
}
const action = user.disabled ? 'enable' : 'disable';
const response = await fetch(`/api/users/${user.id}/${action}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `Failed to ${action} user`);
}
// Update local user data
user.disabled = !user.disabled;
this.$buefy.toast.open({
message: `User ${user.username} ${action}d successfully`,
type: 'is-success'
});
} catch (error) {
console.error(`Error ${user.disabled ? 'enabling' : 'disabling'} user:`, error);
this.$buefy.toast.open({
message: `Error: ${error.message}`,
type: 'is-danger'
});
}
},
async deleteUser(user) {
try {
// Don't allow admins to delete themselves
if (user.id === this.currentUser.id) {
this.$buefy.toast.open({
message: 'You cannot delete your own account',
type: 'is-warning'
});
return;
}
// Show confirmation dialog
this.$buefy.dialog.confirm({
title: 'Delete User',
message: `Are you sure you want to delete user "${user.username}"? This action cannot be undone.`,
confirmText: 'Delete',
type: 'is-danger',
hasIcon: true,
onConfirm: async () => {
const response = await fetch(`/api/users/${user.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to delete user');
}
// Remove from local list
this.users = this.users.filter(u => u.id !== user.id);
this.$buefy.toast.open({
message: `User ${user.username} deleted successfully`,
type: 'is-success'
});
}
});
} catch (error) {
console.error('Error deleting user:', error);
this.$buefy.toast.open({
message: `Error: ${error.message}`,
type: 'is-danger'
});
}
},
refreshUsers() {
this.loadUsers();
},
}, // methods
// Add this to the Vue app definition, after methods
computed: {
filteredUsers() {
if (!this.userSearchQuery) {
return this.users;
}
const query = this.userSearchQuery.toLowerCase();
return this.users.filter(user =>
user.username.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
},
mounted() {
this.checkAuth();
this.loadTranscriptions();
},
watch: {
audioUrl() {
if (this.audioUrl) {
this.initializeAudio();
this.initializeWaveform();
}
},
showAdminPanel(newVal) {
if (newVal && this.isAdmin) {
this.loadUsers();
}
}
},
beforeUnmount() {
if (this.howl) {
this.howl.unload();
}
this.stopSeekUpdate();
}
});
app.use(Buefy.default);
app.mount('#app');
</script>
</body>
</html>