Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
7207
templates/account.html
Normal file
7207
templates/account.html
Normal file
File diff suppressed because it is too large
Load Diff
3689
templates/admin.html
Normal file
3689
templates/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
127
templates/auth/check_email.html
Normal file
127
templates/auth/check_email.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>{{ title }} - DictIA</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
|
||||
<script>
|
||||
function applyTheme() {
|
||||
if (!document.documentElement) return;
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
||||
themeClasses.forEach(theme => {
|
||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
||||
});
|
||||
if (savedScheme !== 'blue') {
|
||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
||||
DictIA
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow flex items-center justify-center">
|
||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mb-6">
|
||||
<div class="w-20 h-20 mx-auto bg-[var(--bg-info-light)] rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-envelope text-[var(--text-info-strong)] text-3xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if action == 'verification' %}
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2>
|
||||
<p class="text-[var(--text-secondary)] mb-2">We've sent a verification link to:</p>
|
||||
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
|
||||
<p class="text-[var(--text-muted)] text-sm mb-6">
|
||||
Click the link in the email to verify your account. The link will expire in 24 hours.
|
||||
</p>
|
||||
{% elif action == 'verification_required' %}
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verification Required</h2>
|
||||
<p class="text-[var(--text-secondary)] mb-2">Please verify your email address:</p>
|
||||
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
|
||||
<p class="text-[var(--text-muted)] text-sm mb-6">
|
||||
Check your inbox for a verification email. If you haven't received it, you can request a new one.
|
||||
</p>
|
||||
{% elif action == 'password_reset' %}
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2>
|
||||
<p class="text-[var(--text-secondary)] mb-2">If an account exists with this email:</p>
|
||||
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
|
||||
<p class="text-[var(--text-muted)] text-sm mb-6">
|
||||
We've sent a password reset link. The link will expire in 1 hour.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if show_resend and (action == 'verification' or action == 'verification_required') %}
|
||||
<div class="mb-6">
|
||||
<form method="POST" action="{{ url_for('auth.resend_verification') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="email" value="{{ email }}">
|
||||
<button type="submit" class="text-[var(--text-accent)] hover:underline text-sm">
|
||||
<i class="fas fa-redo mr-1"></i> Resend verification email
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="pt-4 border-t border-[var(--border-secondary)]">
|
||||
<a href="{{ url_for('auth.login') }}" class="text-[var(--text-accent)] hover:underline">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
105
templates/auth/forgot_password.html
Normal file
105
templates/auth/forgot_password.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>{{ title }} - DictIA</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
|
||||
<script>
|
||||
function applyTheme() {
|
||||
if (!document.documentElement) return;
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
||||
themeClasses.forEach(theme => {
|
||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
||||
});
|
||||
if (savedScheme !== 'blue') {
|
||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
||||
DictIA
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow flex items-center justify-center">
|
||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Forgot Password</h2>
|
||||
<p class="text-[var(--text-muted)] text-sm text-center mb-6">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.forgot_password') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="email" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Email Address</label>
|
||||
<input type="email" id="email" name="email" required
|
||||
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||||
placeholder="Enter your email address">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-4">
|
||||
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]">
|
||||
<i class="fas fa-paper-plane mr-2"></i> Send Reset Link
|
||||
</button>
|
||||
|
||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
||||
<span>Remember your password?</span>
|
||||
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">Back to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
114
templates/auth/reset_password.html
Normal file
114
templates/auth/reset_password.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>{{ title }} - DictIA</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
|
||||
<script>
|
||||
function applyTheme() {
|
||||
if (!document.documentElement) return;
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
||||
themeClasses.forEach(theme => {
|
||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
||||
});
|
||||
if (savedScheme !== 'blue') {
|
||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
||||
DictIA
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow flex items-center justify-center">
|
||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Reset Password</h2>
|
||||
<p class="text-[var(--text-muted)] text-sm text-center mb-6">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">New Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||||
placeholder="Enter your new password">
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">Password must be at least 8 characters long.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="confirm_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Confirm Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required
|
||||
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||||
placeholder="Confirm your new password">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-4">
|
||||
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]">
|
||||
<i class="fas fa-key mr-2"></i> Reset Password
|
||||
</button>
|
||||
|
||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
||||
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
85
templates/auth/verify_success.html
Normal file
85
templates/auth/verify_success.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>{{ title }} - DictIA</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
|
||||
<script>
|
||||
function applyTheme() {
|
||||
if (!document.documentElement) return;
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
||||
themeClasses.forEach(theme => {
|
||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
||||
});
|
||||
if (savedScheme !== 'blue') {
|
||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
||||
DictIA
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow flex items-center justify-center">
|
||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
||||
<div class="text-center">
|
||||
<div class="mb-6">
|
||||
<div class="w-20 h-20 mx-auto bg-[var(--bg-success-light)] rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check text-[var(--text-success-strong)] text-3xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verified!</h2>
|
||||
<p class="text-[var(--text-secondary)] mb-6">
|
||||
Your email address has been successfully verified. You can now log in to your account.
|
||||
</p>
|
||||
|
||||
<a href="{{ url_for('auth.login') }}" class="inline-block w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||||
<i class="fas fa-sign-in-alt mr-2"></i> Continue to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
11
templates/components/banner.html
Normal file
11
templates/components/banner.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!-- Custom Banner -->
|
||||
<div v-if="customBannerHtml && showBanner"
|
||||
class="flex-shrink-0 bg-[var(--bg-accent)] bg-opacity-10 border-b border-[var(--border-accent)]
|
||||
px-4 py-2 flex items-center gap-3 text-sm text-[var(--text-primary)]">
|
||||
<i class="fas fa-bullhorn text-[var(--text-accent)] flex-shrink-0"></i>
|
||||
<div class="flex-1 ai-message banner-content" v-html="customBannerHtml"></div>
|
||||
<button @click="showBanner = false"
|
||||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] flex-shrink-0">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
57
templates/components/detail-view.html
Normal file
57
templates/components/detail-view.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!-- Detail View Container -->
|
||||
<div v-if="currentView === 'detail' && selectedRecording" class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Incognito Mode Indicator Bar -->
|
||||
<div v-if="selectedRecording.incognito" class="bg-gradient-to-r from-violet-500/10 via-purple-500/10 to-violet-500/10 border-b border-violet-300/30 dark:border-violet-500/30 px-4 py-2 flex-shrink-0">
|
||||
<div class="flex items-center justify-between gap-4 max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
|
||||
<i class="fas fa-user-secret text-white text-xs"></i>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-violet-700 dark:text-violet-300">Incognito</span>
|
||||
<span class="text-xs text-violet-600/80 dark:text-violet-400/80">
|
||||
• Session only • Not saved to account
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearIncognitoRecordingWithConfirm"
|
||||
class="flex-shrink-0 px-2.5 py-1 text-xs font-medium text-violet-600 dark:text-violet-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title="Discard this recording">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile View -->
|
||||
<div v-if="isMobile" class="flex flex-col h-full overflow-hidden">
|
||||
{% include 'components/detail/mobile-header.html' %}
|
||||
{% include 'components/detail/audio-player.html' %}
|
||||
{% include 'components/detail/tab-navigation.html' %}
|
||||
|
||||
<!-- Mobile Tab Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{% include 'components/detail/mobile-transcript-panel.html' %}
|
||||
{% include 'components/detail/mobile-summary-panel.html' %}
|
||||
{% include 'components/detail/mobile-notes-panel.html' %}
|
||||
{% include 'components/detail/mobile-chat-panel.html' %}
|
||||
{% include 'components/detail/mobile-events-panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop View -->
|
||||
<div v-else class="flex-1 flex flex-col overflow-hidden">
|
||||
{% include 'components/detail/desktop-header.html' %}
|
||||
|
||||
<!-- Main Content Split View -->
|
||||
<div id="mainContentColumns" class="flex-1 flex overflow-hidden">
|
||||
{% include 'components/detail/desktop-transcription-panel.html' %}
|
||||
|
||||
<!-- Resizable Divider -->
|
||||
<div id="mainColumnResizer" @mousedown="startColumnResize"></div>
|
||||
|
||||
{% include 'components/detail/desktop-right-panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'components/detail/empty-state.html' %}
|
||||
236
templates/components/detail/audio-player.html
Normal file
236
templates/components/detail/audio-player.html
Normal file
@@ -0,0 +1,236 @@
|
||||
<!-- Audio Player Component (Mobile) -->
|
||||
<div class="bg-[var(--bg-secondary)] px-4 py-3 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<!-- Show message if audio has been deleted -->
|
||||
<div v-if="selectedRecording.audio_deleted_at"
|
||||
class="text-[var(--text-muted)] text-sm flex items-center gap-2">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span v-text="t('help.audioDeletedMessage')"></span>
|
||||
</div>
|
||||
<!-- Show message for incognito recordings (no audio stored) -->
|
||||
<div v-else-if="selectedRecording.incognito"
|
||||
class="text-[var(--text-muted)] text-sm flex items-center gap-2">
|
||||
<i class="fas fa-user-secret"></i>
|
||||
<span v-text="t('incognito.audioNotStored')"></span>
|
||||
</div>
|
||||
<!-- Custom Audio Player -->
|
||||
<div v-else>
|
||||
<!-- Video/Audio element wrapped in Teleport for fullscreen -->
|
||||
<Teleport to="body" :disabled="!videoFullscreen">
|
||||
<div :class="videoFullscreen ? 'video-fullscreen-overlay' : ''"
|
||||
@mousemove="videoFullscreen && handleFullscreenMouseMove()"
|
||||
@click.self="videoFullscreen && toggleAudioPlayback()">
|
||||
|
||||
<component :is="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') ? 'video' : 'audio'"
|
||||
ref="audioPlayerElement"
|
||||
:src="'/audio/' + selectedRecording.id"
|
||||
:volume="playerVolume"
|
||||
@play="handleAudioPlayPause"
|
||||
@pause="handleAudioPlayPause"
|
||||
@timeupdate="handleCustomAudioTimeUpdate"
|
||||
@loadedmetadata="handleAudioLoadedMetadata"
|
||||
@durationchange="handleAudioDurationChange"
|
||||
@ended="handleAudioEnded"
|
||||
@waiting="handleAudioWaiting"
|
||||
@canplay="handleAudioCanPlay"
|
||||
@click="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && toggleAudioPlayback()"
|
||||
@dblclick="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && !videoFullscreen && enterVideoFullscreen()"
|
||||
:class="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/')
|
||||
? (videoFullscreen ? 'video-fullscreen-video' : (videoCollapsed ? 'hidden' : 'w-full rounded-lg mb-2 cursor-pointer'))
|
||||
: 'hidden'">
|
||||
</component>
|
||||
|
||||
<!-- Fullscreen Subtitle Overlay -->
|
||||
<div v-if="videoFullscreen && currentSubtitle"
|
||||
class="video-fullscreen-subtitle"
|
||||
:class="{ 'subtitle-shifted': fullscreenControlsVisible }">
|
||||
<span v-if="currentSubtitle.speaker"
|
||||
class="video-fullscreen-subtitle-speaker"
|
||||
:style="{ color: 'var(--' + currentSubtitle.color + ')' }">${ currentSubtitle.speaker }:</span>
|
||||
<span>${ currentSubtitle.text }</span>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen Control Bar -->
|
||||
<div v-if="videoFullscreen"
|
||||
class="video-fullscreen-controls"
|
||||
:class="{ visible: fullscreenControlsVisible }"
|
||||
@mousemove.stop="handleFullscreenMouseMove()">
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="px-4 mb-2">
|
||||
<div class="w-full h-5 rounded-full cursor-pointer relative group flex items-center"
|
||||
@mousedown="startProgressDrag"
|
||||
@touchstart.prevent="startProgressDrag">
|
||||
<div class="progress-track w-full h-1 rounded-full relative bg-white/30">
|
||||
<div class="h-full bg-white rounded-full pointer-events-none"
|
||||
:style="{ width: audioProgressPercent + '%' }"></div>
|
||||
</div>
|
||||
<div class="absolute top-1/2 w-3 h-3 bg-white rounded-full shadow-md transition-transform group-hover:scale-125 pointer-events-none -translate-y-1/2"
|
||||
:style="{ left: 'clamp(0px, calc(' + audioProgressPercent + '% - 6px), calc(100% - 12px))' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Row -->
|
||||
<div class="flex items-center gap-3 px-4 pb-4">
|
||||
<!-- Play/Pause -->
|
||||
<button @click.stop="toggleAudioPlayback"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all">
|
||||
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-lg" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
|
||||
</button>
|
||||
|
||||
<!-- Volume -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click.stop="toggleAudioMute"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all">
|
||||
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-sm"></i>
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.05"
|
||||
:value="audioIsMuted ? 0 : playerVolume"
|
||||
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
|
||||
class="fullscreen-volume-slider w-20">
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="flex items-baseline gap-1 text-white">
|
||||
<span class="text-sm font-mono">${ formatAudioTime(displayCurrentTime) }</span>
|
||||
<span class="text-xs opacity-60">/</span>
|
||||
<span class="text-xs opacity-60 font-mono">${ formatAudioTime(audioDuration) }</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Speed -->
|
||||
<button @click.stop="cyclePlaybackRate"
|
||||
class="px-2 h-8 flex items-center justify-center rounded-lg text-white hover:bg-white/20 transition-all"
|
||||
title="Playback speed">
|
||||
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
|
||||
</button>
|
||||
|
||||
<!-- Exit Fullscreen -->
|
||||
<button @click.stop="exitVideoFullscreen"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all"
|
||||
:title="t('tooltips.exitFullscreen')">
|
||||
<i class="fas fa-compress text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Normal Controls (hidden when fullscreen) -->
|
||||
<div v-show="!videoFullscreen">
|
||||
<!-- Progress Bar (draggable, touch support) -->
|
||||
<div class="w-full h-4 rounded-full cursor-pointer relative mb-2 group flex items-center"
|
||||
@mousedown="startProgressDrag"
|
||||
@touchstart.prevent="startProgressDrag">
|
||||
<!-- Track background -->
|
||||
<div class="progress-track w-full h-2 rounded-full relative bg-[var(--border-accent)] opacity-40">
|
||||
<!-- Progress fill -->
|
||||
<div class="h-full bg-[var(--text-accent)] rounded-full pointer-events-none opacity-100"
|
||||
:style="{ width: audioProgressPercent + '%' }"></div>
|
||||
</div>
|
||||
<!-- Progress dot - stays within track bounds -->
|
||||
<div class="absolute top-1/2 w-4 h-4 bg-[var(--text-accent)] rounded-full shadow-md transition-transform group-hover:scale-110 pointer-events-none -translate-y-1/2"
|
||||
:style="{ left: 'clamp(0px, calc(' + audioProgressPercent + '% - 8px), calc(100% - 16px))' }"
|
||||
style="box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Row -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Play/Pause Button -->
|
||||
<button @click="toggleAudioPlayback"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full player-play-button transition-all duration-200 flex-shrink-0 shadow-md hover:shadow-lg active:scale-95"
|
||||
:title="audioIsPlaying ? t('tooltips.pause') : t('tooltips.play')">
|
||||
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-base" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
|
||||
</button>
|
||||
|
||||
<!-- Time Display -->
|
||||
<div class="flex items-baseline gap-1 flex-shrink-0">
|
||||
<span class="text-sm font-semibold font-mono" :class="isDraggingProgress ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]'">${ formatAudioTime(displayCurrentTime) }</span>
|
||||
<span class="text-xs text-[var(--text-muted)]">/</span>
|
||||
<span class="text-xs text-[var(--text-muted)] font-mono">${ formatAudioTime(audioDuration) }</span>
|
||||
</div>
|
||||
|
||||
<!-- Playback Speed Control -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<button ref="speedButtonMobile"
|
||||
@click="showSpeedMenu = !showSpeedMenu; $nextTick(() => updateSpeedMenuPosition($refs.speedButtonMobile))"
|
||||
data-speed-toggle
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all"
|
||||
:title="t('tooltips.playbackSpeed') || 'Playback speed'">
|
||||
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
|
||||
</button>
|
||||
<!-- Dropdown menu (teleported to body, fixed positioning) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showSpeedMenu" @click.stop
|
||||
data-speed-dropdown
|
||||
class="fixed bg-[var(--bg-tertiary)] border border-[var(--border-accent)] rounded-md shadow-xl z-[9999] speed-dropdown overflow-y-auto backdrop-blur-sm"
|
||||
:style="speedMenuPosition">
|
||||
<div class="py-0.5">
|
||||
<button v-for="speed in playbackSpeeds" :key="speed"
|
||||
@mousedown.prevent="setPlaybackRate(speed); showSpeedMenu = false"
|
||||
class="w-full px-2 py-0.5 text-[11px] font-mono text-left hover:bg-[var(--bg-accent-light)] transition-colors"
|
||||
:class="speed === playbackRate ? 'text-[var(--text-accent)] font-semibold bg-[var(--bg-accent-light)]' : 'text-[var(--text-primary)]'">
|
||||
${ speed }x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<div class="relative flex items-center flex-shrink-0 group/vol"
|
||||
@mouseenter="showVolumeSlider = true"
|
||||
@mouseleave="showVolumeSlider = false">
|
||||
<button @click="toggleAudioMute"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] transition-all"
|
||||
:class="audioIsMuted || playerVolume === 0 ? 'text-[var(--text-muted)]' : 'text-[var(--text-accent)]'"
|
||||
:title="audioIsMuted ? t('tooltips.unmute') : t('tooltips.mute')">
|
||||
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-sm"></i>
|
||||
</button>
|
||||
<!-- Volume Slider (expands on hover, outer div is invisible hover bridge) -->
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 pt-2 z-50 transition-all duration-200"
|
||||
:class="showVolumeSlider ? 'opacity-100 pointer-events-auto scale-100' : 'opacity-0 pointer-events-none scale-95'"
|
||||
@mouseenter="showVolumeSlider = true"
|
||||
@mouseleave="showVolumeSlider = false">
|
||||
<div class="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-accent)] rounded-lg shadow-xl flex flex-col items-center gap-1">
|
||||
<span class="text-[10px] font-mono text-[var(--text-muted)]">${ Math.round(playerVolume * 100) }</span>
|
||||
<input type="range" min="0" max="1" step="0.05"
|
||||
:value="audioIsMuted ? 0 : playerVolume"
|
||||
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
|
||||
class="volume-slider-vertical"
|
||||
style="height: 80px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Button -->
|
||||
<a :href="'/audio/' + selectedRecording.id + '?download=true'"
|
||||
download
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all flex-shrink-0"
|
||||
:title="t('buttons.downloadAudio') || 'Download audio'">
|
||||
<i class="fas fa-download text-sm"></i>
|
||||
</a>
|
||||
|
||||
<!-- Video Fullscreen Button -->
|
||||
<button v-if="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && !videoCollapsed"
|
||||
@click="enterVideoFullscreen"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all flex-shrink-0"
|
||||
:title="t('tooltips.fullscreenVideo')">
|
||||
<i class="fas fa-expand text-sm"></i>
|
||||
</button>
|
||||
|
||||
<!-- Video Toggle Button -->
|
||||
<button v-if="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/')"
|
||||
@click="videoCollapsed = !videoCollapsed"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] transition-all flex-shrink-0"
|
||||
:class="videoCollapsed ? 'text-[var(--text-muted)]' : 'text-[var(--text-accent)]'"
|
||||
:title="videoCollapsed ? t('tooltips.showVideo') : t('tooltips.hideVideo')">
|
||||
<i :class="videoCollapsed ? 'fas fa-eye-slash' : 'fas fa-eye'" class="text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
115
templates/components/detail/desktop-chat-section.html
Normal file
115
templates/components/detail/desktop-chat-section.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!-- Desktop Chat Section -->
|
||||
<div class="border-t border-[var(--border-primary)] flex flex-col" :class="{'flex-1 overflow-hidden': isChatMaximized}">
|
||||
<!-- Chat Toggle -->
|
||||
<div class="bg-[var(--bg-tertiary)] px-4 py-3 hover:bg-[var(--bg-accent-hover)] transition-colors flex items-center">
|
||||
<button @click="() => { showChat = !showChat; if (!showChat) isChatMaximized = false; }"
|
||||
class="flex-1 text-left flex items-center">
|
||||
<span class="font-medium flex items-center">
|
||||
<i class="fas fa-comments mr-2"></i>
|
||||
${ t('chat.chatWithTranscription') }
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="chatMessages.length > 0"
|
||||
@click="downloadChat"
|
||||
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
:title="t('buttons.downloadChat')">
|
||||
<i class="fas fa-download text-sm"></i>
|
||||
</button>
|
||||
<button @click="toggleChatMaximize"
|
||||
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
:title="isChatMaximized ? t('tooltips.restoreChat') : t('tooltips.maximizeChat')">
|
||||
<i :class="['fas text-sm', isChatMaximized ? 'fa-compress' : 'fa-expand']"></i>
|
||||
</button>
|
||||
<button @click="() => { showChat = !showChat; if (!showChat) isChatMaximized = false; }"
|
||||
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||||
<i :class="['fas transition-transform duration-200', showChat ? 'fa-chevron-down' : 'fa-chevron-up']"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Content -->
|
||||
<div v-if="showChat" class="flex flex-col relative" :class="isChatMaximized ? 'flex-1 overflow-hidden' : 'max-h-96'">
|
||||
<!-- Clear button - fixed at top right -->
|
||||
<button v-if="chatMessages.length > 0"
|
||||
@click="clearChat"
|
||||
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:text-[var(--text-danger)] rounded transition-all duration-200 z-20"
|
||||
title="Clear chat">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<div ref="chatMessagesRef"
|
||||
class="flex-1 overflow-y-auto p-4 space-y-4 bg-[var(--bg-secondary)]">
|
||||
<div v-if="chatMessages.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-robot text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)]" v-text="t('help.askAboutTranscription')"></p>
|
||||
</div>
|
||||
|
||||
<div v-for="(message, index) in chatMessages"
|
||||
:key="index"
|
||||
:class="[
|
||||
'message relative group',
|
||||
message.role === 'user' ? 'user-message ml-auto' : 'ai-message',
|
||||
message.role === 'assistant' ? 'pr-10' : ''
|
||||
]">
|
||||
<!-- Copy button for assistant messages -->
|
||||
<button v-if="message.role === 'assistant'"
|
||||
@click="copyMessage(message.content, $event)"
|
||||
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded transition-all duration-200"
|
||||
:title="t('buttons.copyMessage')">
|
||||
<i class="fas fa-copy text-sm"></i>
|
||||
</button>
|
||||
<!-- Show thinking content if available -->
|
||||
<div v-if="message.thinking && message.role === 'assistant'" class="mb-2">
|
||||
<button @click="message.thinkingExpanded = !message.thinkingExpanded"
|
||||
class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] flex items-center gap-1">
|
||||
<i :class="['fas', message.thinkingExpanded ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
|
||||
<span v-text="t('help.modelReasoning')"></span>
|
||||
<span class="text-[var(--text-muted)]">(${message.thinking.split('\n').length} lines)</span>
|
||||
</button>
|
||||
<div v-if="message.thinkingExpanded"
|
||||
class="mt-2 p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg text-xs text-[var(--text-muted)] max-h-64 overflow-y-auto">
|
||||
<pre class="whitespace-pre-wrap font-mono">${message.thinking}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main message content -->
|
||||
<div v-if="message.html" v-html="message.html"></div>
|
||||
<div v-else class="whitespace-pre-wrap">${message.content}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isChatLoading" class="ai-message">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
${ t('chat.thinking') }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Input -->
|
||||
<div class="border-t border-[var(--border-primary)] p-4 bg-[var(--bg-tertiary)]">
|
||||
<div class="flex gap-2">
|
||||
<textarea v-model="chatInput"
|
||||
ref="chatInputRef"
|
||||
@keydown="handleChatKeydown"
|
||||
:disabled="selectedRecording.status !== 'COMPLETED' || processedTranscription.isError"
|
||||
:placeholder="t('chat.placeholderWithHint')"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm"
|
||||
rows="2">
|
||||
</textarea>
|
||||
<button @click="sendChatMessage"
|
||||
:disabled="!chatInput.trim() || isChatLoading || selectedRecording.status !== 'COMPLETED' || processedTranscription.isError"
|
||||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="processedTranscription.isError"
|
||||
class="text-xs text-amber-500 mt-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
${ t('chat.cannotChatTranscriptionFailed') }
|
||||
</p>
|
||||
<p v-else-if="selectedRecording.status !== 'COMPLETED'"
|
||||
class="text-xs text-[var(--text-muted)] mt-2">
|
||||
${ t('chat.availableAfterTranscription') }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
templates/components/detail/desktop-events-tab.html
Normal file
65
templates/components/detail/desktop-events-tab.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!-- Desktop Events Tab -->
|
||||
<div v-if="selectedTab === 'events' && selectedRecording.events && selectedRecording.events.length > 0" class="h-full p-4 overflow-y-auto">
|
||||
<div class="space-y-4">
|
||||
<div v-for="event in selectedRecording.events" :key="event.id"
|
||||
class="bg-[var(--bg-tertiary)] rounded-lg p-4 border border-[var(--border-primary)] hover:border-[var(--border-accent)] transition-colors">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
<i class="fas fa-calendar-check mr-2 text-[var(--text-accent)]"></i>
|
||||
${ event.title }
|
||||
</h3>
|
||||
<p v-if="event.description" class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
${ event.description }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="downloadEventICS(event)"
|
||||
class="px-3 py-1.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm flex items-center gap-1.5"
|
||||
:title="t('events.addToCalendar')">
|
||||
<i class="fas fa-download"></i>
|
||||
<span v-text="t('events.addToCalendar')"></span>
|
||||
</button>
|
||||
<button @click="deleteEvent(event)"
|
||||
class="p-1.5 text-[var(--text-muted)] hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
:title="t('events.delete')">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-center gap-2" v-if="event.start_datetime">
|
||||
<i class="fas fa-clock text-[var(--text-muted)] w-4"></i>
|
||||
<span class="text-[var(--text-secondary)]">
|
||||
<strong v-text="t('events.start') + ':'"></strong> ${ formatEventDateTime(event.start_datetime) }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" v-if="event.end_datetime">
|
||||
<i class="fas fa-clock text-[var(--text-muted)] w-4"></i>
|
||||
<span class="text-[var(--text-secondary)]">
|
||||
<strong v-text="t('events.end') + ':'"></strong> ${ formatEventDateTime(event.end_datetime) }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" v-if="event.location">
|
||||
<i class="fas fa-map-marker-alt text-[var(--text-muted)] w-4"></i>
|
||||
<span class="text-[var(--text-secondary)]">
|
||||
<strong v-text="t('events.location') + ':'"></strong> ${ event.location }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2" v-if="event.attendees && event.attendees.length > 0">
|
||||
<i class="fas fa-users text-[var(--text-muted)] w-4 mt-0.5"></i>
|
||||
<div class="text-[var(--text-secondary)]">
|
||||
<strong v-text="t('events.attendees') + ':'"></strong>
|
||||
<span class="ml-1">${ event.attendees.join(', ') }</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRecording.events.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-calendar-times text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)]" v-text="t('events.noEvents')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
220
templates/components/detail/desktop-header.html
Normal file
220
templates/components/detail/desktop-header.html
Normal file
@@ -0,0 +1,220 @@
|
||||
<!-- Desktop Recording Header -->
|
||||
<div class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] p-6 flex-shrink-0">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h1 v-if="!editingTitle"
|
||||
@dblclick="selectedRecording.can_edit !== false ? toggleEditTitle() : null"
|
||||
:class="[
|
||||
'text-2xl font-bold truncate transition-opacity',
|
||||
selectedRecording.is_shared ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]',
|
||||
selectedRecording.can_edit !== false ? 'cursor-text hover:opacity-80' : ''
|
||||
]"
|
||||
:title="selectedRecording.can_edit !== false ? 'Double-click to edit' : selectedRecording.title || 'Untitled Recording'">
|
||||
${selectedRecording.title || 'Untitled Recording'}
|
||||
</h1>
|
||||
<input v-else
|
||||
v-model="selectedRecording.title"
|
||||
@blur="saveTitle"
|
||||
@keyup.enter="saveTitle"
|
||||
@keyup.esc="cancelEditTitle"
|
||||
ref="titleInput"
|
||||
class="text-2xl font-bold bg-transparent border-b-2 border-[var(--border-focus)] focus:outline-none text-[var(--text-primary)] flex-1 px-1"
|
||||
placeholder="Untitled Recording">
|
||||
|
||||
<button v-if="!editingTitle && selectedRecording.can_edit !== false"
|
||||
@click="toggleEditTitle"
|
||||
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
|
||||
:title="'Edit title'">
|
||||
<i class="fas fa-pen text-sm"></i>
|
||||
</button>
|
||||
|
||||
<!-- Status Badge (for non-completed recordings) -->
|
||||
<span v-if="!editingTitle && selectedRecording.status !== 'COMPLETED'"
|
||||
:class="getStatusClass(selectedRecording.status)"
|
||||
class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full whitespace-nowrap flex-shrink-0">
|
||||
${formatStatus(selectedRecording.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<!-- Incognito recordings have limited actions -->
|
||||
<template v-if="!selectedRecording.incognito">
|
||||
<!-- Folder Assignment (icon-only dropdown matching other buttons) -->
|
||||
<div v-if="foldersEnabled && selectedRecording.can_edit !== false"
|
||||
class="relative p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:title="selectedRecording.folder_id ? getFolderName(selectedRecording.folder_id) : 'Assign Folder'">
|
||||
<select @change="assignFolderToRecording(selectedRecording.id, $event.target.value || null)"
|
||||
:value="selectedRecording.folder_id || ''"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
|
||||
<option value="">No Folder</option>
|
||||
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
|
||||
${ folder.name }
|
||||
</option>
|
||||
</select>
|
||||
<i class="fas fa-folder"
|
||||
:style="{ color: selectedRecording.folder_id ? getFolderColor(selectedRecording.folder_id) : '' }"></i>
|
||||
</div>
|
||||
<button @click="toggleInbox(selectedRecording)"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:class="selectedRecording.is_inbox ? 'text-blue-500' : ''"
|
||||
:title="selectedRecording.is_inbox ? 'Mark as Read' : 'Move to Inbox'">
|
||||
<i class="fas fa-inbox"></i>
|
||||
</button>
|
||||
<button @click="toggleHighlight(selectedRecording)"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:class="selectedRecording.is_highlighted ? 'text-yellow-500' : ''"
|
||||
:title="selectedRecording.is_highlighted ? 'Remove Highlight' : 'Highlight'">
|
||||
<i class="fas fa-star"></i>
|
||||
</button>
|
||||
<button @click="editRecordingTags(selectedRecording)"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:title="t('buttons.editTags')">
|
||||
<i class="fas fa-tags"></i>
|
||||
</button>
|
||||
<button @click="confirmReprocess('transcription', selectedRecording)" v-if="selectedRecording && selectedRecording.can_edit !== false && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:title="useAsrEndpoint ? 'Reprocess with ASR' : 'Reprocess transcription'">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
</button>
|
||||
<button @click="confirmReprocess('summary', selectedRecording)" v-if="selectedRecording && selectedRecording.can_edit !== false && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:title="t('buttons.reprocessSummary')">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<button @click="confirmReset(selectedRecording)" v-if="['PENDING', 'PROCESSING', 'SUMMARIZING', 'FAILED'].includes(selectedRecording.status)"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-orange-500"
|
||||
:title="t('buttons.resetStuckProcessing')">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
<button @click="openSpeakerModal" v-if="processedTranscription.hasDialogue"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:title="t('buttons.identifySpeakers')">
|
||||
<i class="fas fa-user-tag"></i>
|
||||
</button>
|
||||
<button v-if="!selectedRecording.is_shared || (selectedRecording.share_info && selectedRecording.share_info.can_reshare)"
|
||||
@click="openUnifiedShareModal(selectedRecording)"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:title="t('buttons.shareRecording')">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
</button>
|
||||
<button v-if="canDeleteRecordings && selectedRecording.can_delete !== false" @click="confirmDelete(selectedRecording)"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-danger-light)] text-[var(--text-danger)] transition-colors">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Incognito mode: only show discard button -->
|
||||
<template v-else>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400 mr-2">
|
||||
<i class="fas fa-user-secret mr-1"></i>
|
||||
Incognito
|
||||
</span>
|
||||
<button @click="clearIncognitoRecordingWithConfirm"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"
|
||||
title="Discard incognito recording">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Row -->
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-[var(--text-muted)] mt-2">
|
||||
<!-- Folder Pill, Shared Status Badges and Tags -->
|
||||
<div v-if="(foldersEnabled && selectedRecording.folder_id && !selectedRecording.incognito) || selectedRecording.is_shared || selectedRecording.shared_with_count > 0 || selectedRecording.public_share_count > 0 || selectedRecording.has_group_tags || (selectedRecording.tags && selectedRecording.tags.length > 0)" class="flex items-center gap-1.5 flex-wrap">
|
||||
<!-- Folder Pill (shown when folder assigned) -->
|
||||
<span v-if="foldersEnabled && selectedRecording.folder_id && !selectedRecording.incognito"
|
||||
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:style="{ backgroundColor: getFolderColor(selectedRecording.folder_id), color: getContrastTextColor(getFolderColor(selectedRecording.folder_id)) }"
|
||||
:title="'Folder: ' + getFolderName(selectedRecording.folder_id)">
|
||||
<i class="fas fa-folder mr-1 text-[10px]" style="vertical-align: middle; line-height: 0;"></i>
|
||||
${ getFolderName(selectedRecording.folder_id) }
|
||||
</span>
|
||||
<!-- Shared by someone else (INCOMING) -->
|
||||
<span v-if="selectedRecording.is_shared"
|
||||
class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full bg-purple-500 text-white"
|
||||
:title="t('sharing.sharedBy') + ' ' + (selectedRecording.owner_username || t('sharing.unknown'))">
|
||||
<i class="fas fa-arrow-down" style="vertical-align: middle; line-height: 0;"></i>
|
||||
</span>
|
||||
|
||||
<!-- Group indicator (show for both owned and shared recordings with group tags) -->
|
||||
<span v-if="selectedRecording.has_group_tags"
|
||||
class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full bg-blue-500 text-white"
|
||||
:title="t('sharing.teamRecording')">
|
||||
<i class="fas fa-users" style="vertical-align: middle; line-height: 0;"></i>
|
||||
</span>
|
||||
|
||||
<!-- Shared with others (OUTGOING) -->
|
||||
<span v-if="!selectedRecording.is_shared && selectedRecording.shared_with_count > 0"
|
||||
class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full bg-indigo-500 text-white"
|
||||
:title="t('sharing.sharedWith') + ' ' + selectedRecording.shared_with_count + ' ' + t('sharing.users')">
|
||||
<i class="fas fa-arrow-up" style="vertical-align: middle; line-height: 0;"></i>
|
||||
</span>
|
||||
|
||||
<!-- Public link shares -->
|
||||
<span v-if="!selectedRecording.is_shared && selectedRecording.public_share_count > 0"
|
||||
class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full bg-teal-500 text-white"
|
||||
:title="selectedRecording.public_share_count + ' ' + t('sharing.publicLinksGenerated')">
|
||||
<i class="fas fa-globe" style="vertical-align: middle; line-height: 0;"></i>
|
||||
</span>
|
||||
|
||||
<!-- Tags -->
|
||||
<span v-for="tag in selectedRecording.tags" :key="tag.id"
|
||||
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:style="{ backgroundColor: tag.color || '#6B7280', color: getContrastTextColor(tag.color || '#6B7280') }"
|
||||
:title="tag.group_id ? ('Group: ' + tag.group_name) : tag.name">
|
||||
<i v-if="tag.group_id" class="fas fa-users mr-1 text-[10px]" style="vertical-align: middle; line-height: 0;"></i>
|
||||
${tag.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Participants -->
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-users text-[var(--text-accent)]"></i>
|
||||
<span @click="openParticipantsModal"
|
||||
class="cursor-pointer hover:text-[var(--text-accent)] transition-colors max-w-[300px] truncate inline-block"
|
||||
:title="selectedRecording.participants || t('help.noParticipants')">
|
||||
${selectedRecording.participants || t('help.noParticipants')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Owner (for shared recordings) -->
|
||||
<div v-if="selectedRecording.is_shared" class="flex items-center gap-2">
|
||||
<i class="fas fa-user text-[var(--text-accent)]"></i>
|
||||
<span class="max-w-[300px] truncate inline-block"
|
||||
:title="'Owner: ' + (selectedRecording.owner_username || t('sharing.unknown'))">
|
||||
${selectedRecording.owner_username || t('sharing.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Meeting Date -->
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-calendar text-[var(--text-accent)]"></i>
|
||||
<span @click="openMeetingDatePicker"
|
||||
class="cursor-pointer hover:text-[var(--text-accent)] transition-colors">
|
||||
${selectedRecording.meeting_date ? formatDisplayDate(selectedRecording.meeting_date) : 'No date set'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Other Metadata -->
|
||||
<div v-if="activeRecordingMetadata && activeRecordingMetadata.length > 0" class="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<span v-for="(item, index) in activeRecordingMetadata" :key="index" class="flex items-center gap-1.5">
|
||||
<i :class="item.icon"></i>
|
||||
<span :title="item.fullText || item.text">${item.text}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Duplicate Indicator -->
|
||||
<button v-if="selectedRecording.duplicate_info"
|
||||
@click="openDuplicatesModal(selectedRecording.duplicate_info)"
|
||||
class="flex items-center gap-1.5 text-amber-500 hover:text-amber-400 transition-colors cursor-pointer">
|
||||
<i class="fas fa-copy"></i>
|
||||
<span>${ selectedRecording.duplicate_info.total_copies } ${ t('upload.copies') || 'copies' }</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
57
templates/components/detail/desktop-notes-tab.html
Normal file
57
templates/components/detail/desktop-notes-tab.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!-- Desktop Notes Tab -->
|
||||
<div v-if="selectedTab === 'notes'" class="h-full p-4 overflow-y-auto">
|
||||
<div class="content-box h-full relative">
|
||||
<div v-if="!editingNotes" class="absolute top-2 right-4 flex gap-1 z-10">
|
||||
<button @click="copyNotes"
|
||||
:title="t('buttons.copyNotes')"
|
||||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||||
<i class="fas fa-copy text-sm"></i>
|
||||
</button>
|
||||
<button @click="downloadNotes"
|
||||
:title="t('buttons.downloadNotes')"
|
||||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||||
<i class="fas fa-download text-sm"></i>
|
||||
</button>
|
||||
<button @click="toggleEditNotes"
|
||||
:title="t('buttons.editNotes')"
|
||||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||||
<i class="fas fa-edit text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!editingNotes"
|
||||
class="notes-box h-full"
|
||||
@click="clickToEditNotes">
|
||||
<div v-if="selectedRecording.notes_html"
|
||||
v-html="selectedRecording.notes_html">
|
||||
</div>
|
||||
<div v-else-if="selectedRecording.notes"
|
||||
class="whitespace-pre-wrap">
|
||||
${selectedRecording.notes}
|
||||
</div>
|
||||
<div v-else class="text-[var(--text-muted)] italic cursor-pointer hover:text-[var(--text-secondary)]">
|
||||
${ t('help.clickToAddNotes') }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="h-full flex flex-col">
|
||||
<div class="markdown-editor-container flex-1">
|
||||
<textarea ref="notesMarkdownEditor"
|
||||
v-model="selectedRecording.notes"
|
||||
class="w-full h-full p-4 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]"
|
||||
:placeholder="t('form.enterNotesMarkdown')">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<button @click="cancelEditNotes"
|
||||
class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveEditNotes"
|
||||
class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] rounded hover:bg-[var(--bg-button-hover)]">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
272
templates/components/detail/desktop-right-panel.html
Normal file
272
templates/components/detail/desktop-right-panel.html
Normal file
@@ -0,0 +1,272 @@
|
||||
<!-- Desktop Right Panel (Summary/Notes/Chat) -->
|
||||
<div id="rightMainColumn" class="flex flex-col overflow-hidden" :style="{width: rightColumnWidth + '%'}">
|
||||
<!-- Custom Audio Player -->
|
||||
<div class="px-4 py-3 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<!-- Show message if audio has been deleted -->
|
||||
<div v-if="selectedRecording.audio_deleted_at"
|
||||
class="text-[var(--text-muted)] text-sm flex items-center gap-2">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span v-text="t('help.audioDeletedMessage')"></span>
|
||||
</div>
|
||||
<!-- Show message for incognito recordings (no audio stored) -->
|
||||
<div v-else-if="selectedRecording.incognito"
|
||||
class="text-[var(--text-muted)] text-sm flex items-center gap-2 py-2">
|
||||
<i class="fas fa-user-secret"></i>
|
||||
<span v-text="t('incognito.audioNotStored')"></span>
|
||||
</div>
|
||||
<!-- Custom Audio Player Card -->
|
||||
<div v-else class="bg-[var(--bg-tertiary)] rounded-xl p-4 shadow-sm border border-[var(--border-primary)] overflow-hidden">
|
||||
<!-- Video/Audio element wrapped in Teleport for fullscreen -->
|
||||
<Teleport to="body" :disabled="!videoFullscreen">
|
||||
<div :class="videoFullscreen ? 'video-fullscreen-overlay' : ''"
|
||||
@mousemove="videoFullscreen && handleFullscreenMouseMove()"
|
||||
@click.self="videoFullscreen && toggleAudioPlayback()">
|
||||
|
||||
<component :is="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') ? 'video' : 'audio'"
|
||||
ref="audioPlayerElement"
|
||||
:src="'/audio/' + selectedRecording.id"
|
||||
:volume="playerVolume"
|
||||
@play="handleAudioPlayPause"
|
||||
@pause="handleAudioPlayPause"
|
||||
@timeupdate="handleCustomAudioTimeUpdate"
|
||||
@loadedmetadata="handleAudioLoadedMetadata"
|
||||
@durationchange="handleAudioDurationChange"
|
||||
@ended="handleAudioEnded"
|
||||
@waiting="handleAudioWaiting"
|
||||
@canplay="handleAudioCanPlay"
|
||||
@click="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && toggleAudioPlayback()"
|
||||
@dblclick="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && !videoFullscreen && enterVideoFullscreen()"
|
||||
:class="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/')
|
||||
? (videoFullscreen ? 'video-fullscreen-video' : (videoCollapsed ? 'hidden' : 'w-full rounded-lg mb-3 cursor-pointer'))
|
||||
: 'hidden'">
|
||||
</component>
|
||||
|
||||
<!-- Fullscreen Subtitle Overlay -->
|
||||
<div v-if="videoFullscreen && currentSubtitle"
|
||||
class="video-fullscreen-subtitle"
|
||||
:class="{ 'subtitle-shifted': fullscreenControlsVisible }">
|
||||
<span v-if="currentSubtitle.speaker"
|
||||
class="video-fullscreen-subtitle-speaker"
|
||||
:style="{ color: 'var(--' + currentSubtitle.color + ')' }">${ currentSubtitle.speaker }:</span>
|
||||
<span>${ currentSubtitle.text }</span>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen Control Bar -->
|
||||
<div v-if="videoFullscreen"
|
||||
class="video-fullscreen-controls"
|
||||
:class="{ visible: fullscreenControlsVisible }"
|
||||
@mousemove.stop="handleFullscreenMouseMove()">
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="px-4 mb-2">
|
||||
<div class="w-full h-5 rounded-full cursor-pointer relative group flex items-center"
|
||||
@mousedown="startProgressDrag"
|
||||
@touchstart.prevent="startProgressDrag">
|
||||
<div class="progress-track w-full h-1 rounded-full relative bg-white/30">
|
||||
<div class="h-full bg-white rounded-full pointer-events-none"
|
||||
:style="{ width: audioProgressPercent + '%' }"></div>
|
||||
</div>
|
||||
<div class="absolute top-1/2 w-3 h-3 bg-white rounded-full shadow-md transition-transform group-hover:scale-125 pointer-events-none -translate-y-1/2"
|
||||
:style="{ left: 'clamp(0px, calc(' + audioProgressPercent + '% - 6px), calc(100% - 12px))' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Row -->
|
||||
<div class="flex items-center gap-3 px-4 pb-4">
|
||||
<!-- Play/Pause -->
|
||||
<button @click.stop="toggleAudioPlayback"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all">
|
||||
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-lg" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
|
||||
</button>
|
||||
|
||||
<!-- Volume -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click.stop="toggleAudioMute"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all">
|
||||
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-sm"></i>
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.05"
|
||||
:value="audioIsMuted ? 0 : playerVolume"
|
||||
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
|
||||
class="fullscreen-volume-slider w-20">
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="flex items-baseline gap-1 text-white">
|
||||
<span class="text-sm font-mono">${ formatAudioTime(displayCurrentTime) }</span>
|
||||
<span class="text-xs opacity-60">/</span>
|
||||
<span class="text-xs opacity-60 font-mono">${ formatAudioTime(audioDuration) }</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Speed -->
|
||||
<button @click.stop="cyclePlaybackRate"
|
||||
class="px-2 h-8 flex items-center justify-center rounded-lg text-white hover:bg-white/20 transition-all"
|
||||
title="Playback speed">
|
||||
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
|
||||
</button>
|
||||
|
||||
<!-- Exit Fullscreen -->
|
||||
<button @click.stop="exitVideoFullscreen"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all"
|
||||
:title="t('tooltips.exitFullscreen')">
|
||||
<i class="fas fa-compress text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Normal Controls (hidden when fullscreen) -->
|
||||
<div v-show="!videoFullscreen">
|
||||
<!-- Progress Bar (now on top, full width, draggable) -->
|
||||
<div class="w-full h-4 rounded-full cursor-pointer relative mb-3 group flex items-center"
|
||||
@mousedown="startProgressDrag">
|
||||
<!-- Track background -->
|
||||
<div class="progress-track w-full h-2 rounded-full relative bg-[var(--border-accent)] opacity-40">
|
||||
<!-- Progress fill -->
|
||||
<div class="h-full bg-[var(--text-accent)] rounded-full pointer-events-none opacity-100"
|
||||
:style="{ width: audioProgressPercent + '%' }"></div>
|
||||
</div>
|
||||
<!-- Progress dot - stays within track bounds -->
|
||||
<div class="absolute top-1/2 w-4 h-4 bg-[var(--text-accent)] rounded-full shadow-md transition-transform group-hover:scale-110 pointer-events-none -translate-y-1/2"
|
||||
:style="{ left: 'clamp(0px, calc(' + audioProgressPercent + '% - 8px), calc(100% - 16px))' }"
|
||||
style="box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Row -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<!-- Play/Pause Button -->
|
||||
<button @click="toggleAudioPlayback"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full player-play-button transition-all duration-200 flex-shrink-0 shadow-md hover:shadow-lg hover:scale-105"
|
||||
:title="audioIsPlaying ? t('tooltips.pause') : t('tooltips.play')">
|
||||
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-base" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
|
||||
</button>
|
||||
|
||||
<!-- Time Display -->
|
||||
<div class="flex items-baseline gap-1 flex-shrink-0 min-w-0">
|
||||
<span class="text-sm font-semibold font-mono" :class="isDraggingProgress ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]'">${ formatAudioTime(displayCurrentTime) }</span>
|
||||
<span class="text-xs text-[var(--text-muted)]">/</span>
|
||||
<span class="text-xs text-[var(--text-muted)] font-mono">${ formatAudioTime(audioDuration) }</span>
|
||||
</div>
|
||||
|
||||
<!-- Playback Speed Control -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<button ref="speedButtonDesktop"
|
||||
@click="showSpeedMenu = !showSpeedMenu; $nextTick(() => updateSpeedMenuPosition($refs.speedButtonDesktop))"
|
||||
data-speed-toggle
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all"
|
||||
:title="t('tooltips.playbackSpeed') || 'Playback speed'">
|
||||
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
|
||||
</button>
|
||||
<!-- Dropdown menu (teleported to body, fixed positioning) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showSpeedMenu" @click.stop
|
||||
data-speed-dropdown
|
||||
class="fixed bg-[var(--bg-tertiary)] border border-[var(--border-accent)] rounded-md shadow-xl z-[9999] speed-dropdown overflow-y-auto backdrop-blur-sm"
|
||||
:style="speedMenuPosition">
|
||||
<div class="py-0.5">
|
||||
<button v-for="speed in playbackSpeeds" :key="speed"
|
||||
@mousedown.prevent="setPlaybackRate(speed); showSpeedMenu = false"
|
||||
class="w-full px-2 py-0.5 text-[11px] font-mono text-left hover:bg-[var(--bg-accent-light)] transition-colors"
|
||||
:class="speed === playbackRate ? 'text-[var(--text-accent)] font-semibold bg-[var(--bg-accent-light)]' : 'text-[var(--text-primary)]'">
|
||||
${ speed }x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1 min-w-0"></div>
|
||||
|
||||
<!-- Volume Control - hidden on very narrow screens -->
|
||||
<div class="hidden sm:flex items-center gap-1 flex-shrink-0">
|
||||
<button @click="toggleAudioMute"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] hover:opacity-80 transition-all"
|
||||
:class="audioIsMuted || playerVolume === 0 ? 'text-[var(--text-muted)]' : 'text-[var(--text-accent)]'"
|
||||
:title="audioIsMuted ? t('tooltips.unmute') : t('tooltips.mute')">
|
||||
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-sm"></i>
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.05"
|
||||
:value="audioIsMuted ? 0 : playerVolume"
|
||||
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
|
||||
class="volume-slider w-16">
|
||||
</div>
|
||||
|
||||
<!-- Download Button -->
|
||||
<a :href="'/audio/' + selectedRecording.id + '?download=true'"
|
||||
download
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all flex-shrink-0"
|
||||
:title="t('buttons.downloadAudio') || 'Download audio'">
|
||||
<i class="fas fa-download text-sm"></i>
|
||||
</a>
|
||||
|
||||
<!-- Video Fullscreen Button -->
|
||||
<button v-if="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && !videoCollapsed"
|
||||
@click="enterVideoFullscreen"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all flex-shrink-0"
|
||||
:title="t('tooltips.fullscreenVideo')">
|
||||
<i class="fas fa-expand text-sm"></i>
|
||||
</button>
|
||||
|
||||
<!-- Video Toggle Button -->
|
||||
<button v-if="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/')"
|
||||
@click="videoCollapsed = !videoCollapsed"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] transition-all flex-shrink-0"
|
||||
:class="videoCollapsed ? 'text-[var(--text-muted)]' : 'text-[var(--text-accent)]'"
|
||||
:title="videoCollapsed ? t('tooltips.showVideo') : t('tooltips.hideVideo')">
|
||||
<i :class="videoCollapsed ? 'fas fa-eye-slash' : 'fas fa-eye'" class="text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary/Notes Tabs -->
|
||||
<div :class="{'flex-1': !isChatMaximized, 'flex-none': isChatMaximized}" class="flex flex-col overflow-hidden">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)] flex">
|
||||
<button @click="selectedTab = 'summary'"
|
||||
:class="[
|
||||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||||
selectedTab === 'summary'
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||
]">
|
||||
${ t('summary.title') }
|
||||
</button>
|
||||
<button @click="selectedTab = 'notes'"
|
||||
:class="[
|
||||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||||
selectedTab === 'notes'
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||
]">
|
||||
${ t('notes.title') }
|
||||
</button>
|
||||
<button v-if="selectedRecording.events && selectedRecording.events.length > 0"
|
||||
@click="selectedTab = 'events'"
|
||||
:class="[
|
||||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||||
selectedTab === 'events'
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||
]">
|
||||
<i class="fas fa-calendar-alt mr-1"></i>
|
||||
${ t('events.title') } (${ selectedRecording.events.length })
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div v-if="!isChatMaximized" class="flex-1 overflow-hidden">
|
||||
{% include 'components/detail/desktop-summary-tab.html' %}
|
||||
{% include 'components/detail/desktop-notes-tab.html' %}
|
||||
{% include 'components/detail/desktop-events-tab.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Section -->
|
||||
{% include 'components/detail/desktop-chat-section.html' %}
|
||||
</div>
|
||||
71
templates/components/detail/desktop-summary-tab.html
Normal file
71
templates/components/detail/desktop-summary-tab.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!-- Desktop Summary Tab -->
|
||||
<div v-if="selectedTab === 'summary'" class="h-full p-4 overflow-y-auto">
|
||||
<div v-if="selectedRecording.status === 'SUMMARIZING'" class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||||
<p class="mt-2 text-[var(--text-muted)]" v-text="t('summary.summaryInProgress')"></p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!selectedRecording.summary" class="text-center py-8">
|
||||
<i class="fas fa-file-alt text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)] mb-4" v-text="t('summary.noSummary')"></p>
|
||||
<!-- Show message if transcription is an error -->
|
||||
<p v-if="processedTranscription.isError" class="text-sm text-amber-500 mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Cannot generate summary: transcription failed
|
||||
</p>
|
||||
<button @click="generateSummary"
|
||||
:disabled="processedTranscription.isError"
|
||||
:class="[
|
||||
'px-6 py-3 font-medium rounded-lg shadow-lg transition-all duration-200',
|
||||
processedTranscription.isError
|
||||
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:shadow-xl hover:from-blue-700 hover:to-purple-700 transform hover:scale-105'
|
||||
]">
|
||||
<i class="fas fa-magic mr-2"></i>${ t('summary.generateSummary') }
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="content-box relative">
|
||||
<div v-if="!editingSummary" class="absolute top-2 right-4 flex gap-1 z-10">
|
||||
<button @click="copySummary"
|
||||
:title="t('buttons.copySummary')"
|
||||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||||
<i class="fas fa-copy text-sm"></i>
|
||||
</button>
|
||||
<button @click="downloadSummary"
|
||||
:title="t('buttons.downloadSummary')"
|
||||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||||
<i class="fas fa-download text-sm"></i>
|
||||
</button>
|
||||
<button @click="toggleEditSummary"
|
||||
:title="t('buttons.editSummary')"
|
||||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||||
<i class="fas fa-edit text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!editingSummary"
|
||||
class="summary-box"
|
||||
v-html="selectedRecording.summary_html || selectedRecording.summary">
|
||||
</div>
|
||||
<div v-else class="h-full flex flex-col">
|
||||
<div class="markdown-editor-container flex-1">
|
||||
<textarea ref="summaryMarkdownEditor"
|
||||
v-model="selectedRecording.summary"
|
||||
class="w-full h-full p-4 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]"
|
||||
:placeholder="t('form.enterSummaryMarkdown')">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<button @click="cancelEditSummary"
|
||||
class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveEditSummary"
|
||||
class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] rounded hover:bg-[var(--bg-button-hover)]">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
263
templates/components/detail/desktop-transcription-panel.html
Normal file
263
templates/components/detail/desktop-transcription-panel.html
Normal file
@@ -0,0 +1,263 @@
|
||||
<!-- Desktop Transcription Panel (Left Column) -->
|
||||
<div id="leftMainColumn" class="flex flex-col overflow-hidden" :style="{width: leftColumnWidth + '%'}">
|
||||
<!-- Transcription Header -->
|
||||
<div class="bg-[var(--bg-tertiary)] px-4 py-3 border-b border-[var(--border-primary)] flex items-center justify-between">
|
||||
<h3 class="font-semibold flex items-center">
|
||||
<i class="fas fa-file-text mr-2"></i>
|
||||
<span v-text="t('transcription.title')"></span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Follow Player Checkbox -->
|
||||
<div v-if="processedTranscription.isJson && processedTranscription.hasDialogue"
|
||||
class="follow-player-control text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
@click="toggleFollowPlayerMode"
|
||||
:title="followPlayerMode ? t('tooltips.followPlayerEnabled') : t('tooltips.followPlayerDisabled')">
|
||||
<input type="checkbox"
|
||||
:checked="followPlayerMode"
|
||||
@click.stop="toggleFollowPlayerMode">
|
||||
<i class="fas fa-arrows-alt-v follow-icon"></i>
|
||||
</div>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div v-if="processedTranscription.hasDialogue" class="view-mode-toggle">
|
||||
<button @click="toggleTranscriptionViewMode"
|
||||
:class="['toggle-button', transcriptionViewMode === 'simple' ? 'active' : '']">
|
||||
<i class="fas fa-list"></i><span v-text="t('transcription.simple')"></span>
|
||||
</button>
|
||||
<button @click="toggleTranscriptionViewMode"
|
||||
:class="['toggle-button', transcriptionViewMode === 'bubble' ? 'active' : '']">
|
||||
<i class="fas fa-comments"></i><span v-text="t('transcription.bubble')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Copy Button -->
|
||||
<button @click="copyTranscription"
|
||||
class="copy-btn"
|
||||
:title="t('tooltips.copyTranscript')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<!-- Download Button with Dropdown -->
|
||||
<div v-if="selectedRecording && selectedRecording.transcription" class="relative">
|
||||
<button @click="showDownloadMenu = !showDownloadMenu"
|
||||
data-download-toggle
|
||||
class="copy-btn flex items-center"
|
||||
:title="t('tooltips.downloadTranscriptWithTemplate')">
|
||||
<i class="fas fa-download"></i>
|
||||
<i class="fas fa-caret-down ml-1 text-[10px] opacity-50"></i>
|
||||
</button>
|
||||
<!-- Dropdown Menu -->
|
||||
<div v-if="showDownloadMenu"
|
||||
data-download-dropdown
|
||||
class="absolute right-0 top-full mt-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-50 overflow-hidden whitespace-nowrap">
|
||||
<button @click="downloadWithDefaultTemplate(); showDownloadMenu = false"
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2">
|
||||
<i class="fas fa-star text-[var(--text-accent)] text-[10px]"></i>
|
||||
<span v-text="t('transcriptTemplates.downloadDefault')"></span>
|
||||
</button>
|
||||
<button @click="downloadTranscriptWord(); showDownloadMenu = false"
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2 border-t border-[var(--border-secondary)]">
|
||||
<i class="fas fa-file-word text-blue-500 text-[10px]"></i>
|
||||
<span>Télécharger Word</span>
|
||||
</button>
|
||||
<button @click="showTemplateSelector(); showDownloadMenu = false"
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2 border-t border-[var(--border-secondary)]">
|
||||
<i class="fas fa-list text-[var(--text-muted)] text-[10px]"></i>
|
||||
<span v-text="t('transcriptTemplates.chooseTemplate')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit Transcription Button -->
|
||||
<button @click="openTranscriptionEditor"
|
||||
v-if="selectedRecording && selectedRecording.transcription"
|
||||
class="copy-btn"
|
||||
:title="t('tooltips.editTranscript')">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcription Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 relative">
|
||||
<!-- Floating Processing Indicator -->
|
||||
<div v-if="selectedRecording.status === 'PROCESSING'"
|
||||
:class="['processing-indicator-floating', processingIndicatorMinimized ? 'minimized' : '']">
|
||||
<template v-if="!processingIndicatorMinimized">
|
||||
<div class="processing-indicator-content">
|
||||
<i class="fas fa-spinner fa-spin text-[var(--text-accent)]"></i>
|
||||
<span class="text-sm text-[var(--text-secondary)]" v-text="t('help.processingTranscription')"></span>
|
||||
</div>
|
||||
<button @click="processingIndicatorMinimized = true"
|
||||
class="processing-indicator-minimize"
|
||||
:title="t('tooltips.minimize')">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button @click="processingIndicatorMinimized = false"
|
||||
class="processing-indicator-expand"
|
||||
:title="t('help.processingTranscription')">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No transcription state (only show if not processing and no transcription) -->
|
||||
<div v-if="selectedRecording.status !== 'PROCESSING' && !selectedRecording.transcription" class="text-center py-8">
|
||||
<i class="fas fa-file-text text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)]" v-text="t('transcription.noTranscription')"></p>
|
||||
</div>
|
||||
|
||||
<!-- Error Display (when transcription is an error message) -->
|
||||
<div v-if="processedTranscription.isError" class="error-display-container">
|
||||
<div :class="[
|
||||
'rounded-lg p-5 border',
|
||||
processedTranscription.error.type === 'size_limit' ? 'bg-amber-500/10 border-amber-500/30' :
|
||||
processedTranscription.error.type === 'timeout' ? 'bg-orange-500/10 border-orange-500/30' :
|
||||
processedTranscription.error.type === 'auth' ? 'bg-red-500/10 border-red-500/30' :
|
||||
processedTranscription.error.type === 'rate_limit' ? 'bg-yellow-500/10 border-yellow-500/30' :
|
||||
processedTranscription.error.type === 'connection' ? 'bg-blue-500/10 border-blue-500/30' :
|
||||
processedTranscription.error.type === 'service_error' ? 'bg-purple-500/10 border-purple-500/30' :
|
||||
'bg-gray-500/10 border-gray-500/30'
|
||||
]">
|
||||
<div class="flex items-start gap-4">
|
||||
<div :class="[
|
||||
'flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center',
|
||||
processedTranscription.error.type === 'size_limit' ? 'bg-amber-500/20' :
|
||||
processedTranscription.error.type === 'timeout' ? 'bg-orange-500/20' :
|
||||
processedTranscription.error.type === 'auth' ? 'bg-red-500/20' :
|
||||
processedTranscription.error.type === 'rate_limit' ? 'bg-yellow-500/20' :
|
||||
processedTranscription.error.type === 'connection' ? 'bg-blue-500/20' :
|
||||
processedTranscription.error.type === 'service_error' ? 'bg-purple-500/20' :
|
||||
'bg-gray-500/20'
|
||||
]">
|
||||
<i :class="[
|
||||
'fas text-xl',
|
||||
processedTranscription.error.icon,
|
||||
processedTranscription.error.type === 'size_limit' ? 'text-amber-500' :
|
||||
processedTranscription.error.type === 'timeout' ? 'text-orange-500' :
|
||||
processedTranscription.error.type === 'auth' ? 'text-red-500' :
|
||||
processedTranscription.error.type === 'rate_limit' ? 'text-yellow-500' :
|
||||
processedTranscription.error.type === 'connection' ? 'text-blue-500' :
|
||||
processedTranscription.error.type === 'service_error' ? 'text-purple-500' :
|
||||
'text-gray-500'
|
||||
]"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 :class="[
|
||||
'text-lg font-semibold mb-2',
|
||||
processedTranscription.error.type === 'size_limit' ? 'text-amber-600 dark:text-amber-400' :
|
||||
processedTranscription.error.type === 'timeout' ? 'text-orange-600 dark:text-orange-400' :
|
||||
processedTranscription.error.type === 'auth' ? 'text-red-600 dark:text-red-400' :
|
||||
processedTranscription.error.type === 'rate_limit' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||
processedTranscription.error.type === 'connection' ? 'text-blue-600 dark:text-blue-400' :
|
||||
processedTranscription.error.type === 'service_error' ? 'text-purple-600 dark:text-purple-400' :
|
||||
'text-gray-600 dark:text-gray-400'
|
||||
]">
|
||||
${processedTranscription.error.title}
|
||||
</h3>
|
||||
<p class="text-[var(--text-primary)] mb-3">
|
||||
${processedTranscription.error.message}
|
||||
</p>
|
||||
<div v-if="processedTranscription.error.guidance" class="flex items-start gap-2 text-sm text-[var(--text-secondary)] bg-[var(--bg-tertiary)]/50 rounded-lg p-3">
|
||||
<i class="fas fa-lightbulb text-yellow-500 mt-0.5 flex-shrink-0"></i>
|
||||
<span>${processedTranscription.error.guidance}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Action buttons -->
|
||||
<div class="mt-4 flex items-center gap-3 pt-4 border-t border-[var(--border-secondary)]">
|
||||
<button @click="reprocessTranscription(selectedRecording.id)"
|
||||
v-if="selectedRecording.can_edit !== false"
|
||||
class="px-4 py-2 bg-[var(--bg-accent)] hover:bg-[var(--bg-accent-hover)] text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
<span>Reprocess</span>
|
||||
</button>
|
||||
<details class="text-xs w-full">
|
||||
<summary class="cursor-pointer text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre v-if="processedTranscription.error.technical" class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-lg text-[var(--text-muted)] text-xs max-h-32 overflow-auto whitespace-pre-wrap break-all w-full">${processedTranscription.error.technical}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Legend (for dialogue transcriptions in bubble view only) -->
|
||||
<div v-if="!processedTranscription.isError && processedTranscription.hasDialogue && processedTranscription.speakers.length > 0 && transcriptionViewMode === 'bubble'"
|
||||
:class="['speaker-legend', legendExpanded ? 'expanded' : '']">
|
||||
<div class="speaker-legend-header" @click="legendExpanded = !legendExpanded">
|
||||
<div class="speaker-legend-title">
|
||||
<i class="fas fa-users"></i>
|
||||
<span v-text="t('help.speakers')"></span>
|
||||
<span class="speaker-count-indicator">(${processedTranscription.speakers.length})</span>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down speaker-legend-toggle"></i>
|
||||
</div>
|
||||
<div class="speaker-legend-content">
|
||||
<div v-for="(speaker, index) in processedTranscription.speakers"
|
||||
:key="`${selectedRecording.id}-speaker-legend-${index}`"
|
||||
:class="['speaker-legend-item', speaker.color]">
|
||||
<span class="speaker-name">${speaker.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcription Display (hide if it's an error) -->
|
||||
<div v-if="selectedRecording.transcription && !processedTranscription.isError">
|
||||
<!-- Simple View -->
|
||||
<div v-if="!processedTranscription.hasDialogue || transcriptionViewMode === 'simple'"
|
||||
class="transcription-simple-view">
|
||||
<div v-if="processedTranscription.hasDialogue">
|
||||
<div v-for="(segment, index) in processedTranscription.simpleSegments"
|
||||
:key="`seg-${index}-${segment.startTime}`"
|
||||
:class="['speaker-segment', { 'active-playing-segment': currentPlayingSegmentIndex === index }]"
|
||||
@click="seekAudioFromEvent"
|
||||
:data-start-time="segment.startTime"
|
||||
:data-segment-index="index">
|
||||
<div v-if="segment.showSpeaker"
|
||||
:class="['speaker-tablet', segment.color]">
|
||||
${segment.speaker}
|
||||
</div>
|
||||
<div class="speaker-text">
|
||||
${segment.sentence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="processedTranscription.simpleSegments && processedTranscription.simpleSegments.length > 0">
|
||||
<div v-for="(segment, index) in processedTranscription.simpleSegments"
|
||||
:key="`seg-${index}-${segment.startTime}`"
|
||||
:class="['transcript-segment cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors', { 'active-playing-segment': currentPlayingSegmentIndex === index }]"
|
||||
@click="seekAudioFromEvent"
|
||||
:data-start-time="segment.startTime"
|
||||
:data-segment-index="index">
|
||||
${segment.sentence}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="whitespace-pre-wrap">
|
||||
${processedTranscription.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bubble View -->
|
||||
<div v-else-if="transcriptionViewMode === 'bubble'"
|
||||
class="transcription-with-speakers">
|
||||
<template v-for="(row, rowIndex) in processedTranscription.bubbleRows" :key="`${selectedRecording.id}-bubble-row-${rowIndex}`">
|
||||
<div :class="['bubble-row', row.isMe ? 'speaker-me' : '']">
|
||||
<div v-for="(bubble, bubbleIndex) in row.bubbles"
|
||||
:key="`bubble-${rowIndex}-${bubbleIndex}-${bubble.startTime}`"
|
||||
:class="['speaker-bubble', bubble.color, row.isMe ? 'speaker-me' : '', { 'active-playing-segment': currentPlayingSegmentIndex === getBubbleGlobalIndex(rowIndex, bubbleIndex) }]"
|
||||
@click="seekAudioFromEvent"
|
||||
:data-start-time="bubble.startTime"
|
||||
:data-segment-index="getBubbleGlobalIndex(rowIndex, bubbleIndex)">
|
||||
<div class="speaker-bubble-content">
|
||||
${bubble.sentence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
12
templates/components/detail/empty-state.html
Normal file
12
templates/components/detail/empty-state.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!-- Empty State -->
|
||||
<div v-if="currentView === 'detail' && !selectedRecording" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-microphone text-6xl text-[var(--text-muted)] mb-4"></i>
|
||||
<h2 class="text-2xl font-bold mb-2" v-text="t('colorScheme.selectRecording')"></h2>
|
||||
<p class="text-[var(--text-muted)] mb-6" v-text="t('colorScheme.chooseRecording')"></p>
|
||||
<button @click="switchToUploadView"
|
||||
class="px-6 py-3 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Upload New Recording
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
59
templates/components/detail/mobile-chat-panel.html
Normal file
59
templates/components/detail/mobile-chat-panel.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!-- Mobile Chat Panel -->
|
||||
<div v-if="mobileTab === 'chat'" class="h-full flex flex-col rounded-lg border border-[var(--border-primary)] overflow-hidden relative">
|
||||
<!-- Clear button - fixed at top right -->
|
||||
<button v-if="chatMessages.length > 0"
|
||||
@click="clearChat"
|
||||
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:text-[var(--text-danger)] rounded transition-all duration-200 z-20"
|
||||
title="Clear chat">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
|
||||
<div ref="chatMessagesRef" class="flex-1 overflow-y-auto p-4 space-y-4 bg-[var(--bg-secondary)]">
|
||||
<div v-if="chatMessages.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-robot text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)]" v-text="t('help.askAboutTranscription')"></p>
|
||||
</div>
|
||||
<div v-for="(message, index) in chatMessages" :key="index"
|
||||
:class="['message relative group', message.role === 'user' ? 'user-message ml-auto' : 'ai-message',
|
||||
message.role === 'assistant' ? 'pr-10' : '']">
|
||||
<!-- Copy button for assistant messages -->
|
||||
<button v-if="message.role === 'assistant'"
|
||||
@click="copyMessage(message.content, $event)"
|
||||
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded transition-all duration-200"
|
||||
:title="t('buttons.copyMessage')">
|
||||
<i class="fas fa-copy text-sm"></i>
|
||||
</button>
|
||||
<!-- Show thinking content if available -->
|
||||
<div v-if="message.thinking && message.role === 'assistant'" class="mb-2">
|
||||
<button @click="message.thinkingExpanded = !message.thinkingExpanded"
|
||||
class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] flex items-center gap-1">
|
||||
<i :class="['fas', message.thinkingExpanded ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
|
||||
<span v-text="t('help.modelReasoning')"></span>
|
||||
<span class="text-[var(--text-muted)]">(${message.thinking.split('\n').length} lines)</span>
|
||||
</button>
|
||||
<div v-if="message.thinkingExpanded"
|
||||
class="mt-2 p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg text-xs text-[var(--text-muted)] max-h-64 overflow-y-auto">
|
||||
<pre class="whitespace-pre-wrap font-mono">${message.thinking}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main message content -->
|
||||
<div v-if="message.html" v-html="message.html"></div>
|
||||
<div v-else class="whitespace-pre-wrap">${message.content}</div>
|
||||
</div>
|
||||
<div v-if="isChatLoading" class="ai-message">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i> ${ t('chat.thinking') }
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-[var(--border-primary)] p-4 bg-[var(--bg-tertiary)]">
|
||||
<div class="flex gap-2">
|
||||
<textarea v-model="chatInput" ref="chatInputRef" @keydown="handleChatKeydown" :disabled="selectedRecording.status !== 'COMPLETED' || processedTranscription.isError" :placeholder="t('chat.placeholder')" class="flex-1 px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm" rows="2"></textarea>
|
||||
<button @click="sendChatMessage" :disabled="!chatInput.trim() || isChatLoading || selectedRecording.status !== 'COMPLETED' || processedTranscription.isError" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="processedTranscription.isError" class="text-xs text-amber-500 mt-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
${ t('chat.cannotChatTranscriptionFailed') }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
65
templates/components/detail/mobile-events-panel.html
Normal file
65
templates/components/detail/mobile-events-panel.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!-- Mobile Events Panel -->
|
||||
<div v-if="mobileTab === 'events' && selectedRecording.events && selectedRecording.events.length > 0" class="h-full flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
|
||||
<button @click="downloadICS" v-if="selectedRecording.events && selectedRecording.events.length > 0"
|
||||
class="copy-btn"
|
||||
:title="t('buttons.exportCalendar')">
|
||||
<i class="fas fa-calendar-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto mobile-content-box">
|
||||
<div class="space-y-3">
|
||||
<div v-for="event in selectedRecording.events" :key="event.id"
|
||||
class="bg-[var(--bg-tertiary)] rounded-lg p-3 border border-[var(--border-primary)]">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex-1 min-w-0 pr-2">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)] mb-1">
|
||||
<i class="fas fa-calendar-check mr-2 text-[var(--text-accent)] text-sm"></i>
|
||||
${ event.title }
|
||||
</h3>
|
||||
<p v-if="event.description" class="text-xs text-[var(--text-secondary)] mb-2">
|
||||
${ event.description }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<button @click="downloadEventICS(event)"
|
||||
class="px-2 py-1 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-xs flex items-center gap-1"
|
||||
:title="t('events.addToCalendar')">
|
||||
<i class="fas fa-download text-xs"></i>
|
||||
<span v-text="t('events.add')"></span>
|
||||
</button>
|
||||
<button @click="deleteEvent(event)"
|
||||
class="p-1.5 text-[var(--text-muted)] hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
:title="t('events.delete')">
|
||||
<i class="fas fa-trash-alt text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 text-xs">
|
||||
<div class="flex items-center text-[var(--text-muted)]">
|
||||
<i class="fas fa-clock mr-2 text-[var(--text-accent)]"></i>
|
||||
<span>
|
||||
${ formatEventDateTime(event.start_datetime) }
|
||||
<template v-if="event.end_datetime">
|
||||
- ${ formatEventDateTime(event.end_datetime, true) }
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="event.location" class="flex items-center text-[var(--text-muted)]">
|
||||
<i class="fas fa-map-marker-alt mr-2 text-[var(--text-accent)]"></i>
|
||||
<span>${ event.location }</span>
|
||||
</div>
|
||||
<div v-if="event.attendees && event.attendees.length > 0" class="flex items-start text-[var(--text-muted)]">
|
||||
<i class="fas fa-users mr-2 text-[var(--text-accent)] mt-0.5"></i>
|
||||
<div class="flex-1">
|
||||
<span v-for="(attendee, index) in event.attendees" :key="index"
|
||||
class="inline-block bg-[var(--bg-secondary)] px-2 py-0.5 rounded mr-1 mb-1">
|
||||
${ attendee }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
153
templates/components/detail/mobile-header.html
Normal file
153
templates/components/detail/mobile-header.html
Normal file
@@ -0,0 +1,153 @@
|
||||
<!-- Mobile Header for Detail View -->
|
||||
<div class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] p-4 flex-shrink-0">
|
||||
<div @click="isMetadataExpanded = !isMetadataExpanded" class="cursor-pointer">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<h1 v-if="!editingTitle"
|
||||
@dblclick.stop="selectedRecording.can_edit !== false ? toggleEditTitle() : null"
|
||||
:class="[
|
||||
'text-lg font-bold truncate',
|
||||
selectedRecording.is_shared ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]',
|
||||
selectedRecording.can_edit !== false ? 'cursor-text hover:opacity-80 transition-opacity' : ''
|
||||
]"
|
||||
:title="selectedRecording.can_edit !== false ? 'Double-click to edit' : selectedRecording.title || 'Untitled Recording'">
|
||||
${selectedRecording.title || 'Untitled Recording'}
|
||||
</h1>
|
||||
<input v-else
|
||||
v-model="selectedRecording.title"
|
||||
@blur="saveTitle"
|
||||
@keyup.enter="saveTitle"
|
||||
@keyup.esc="cancelEditTitle"
|
||||
@click.stop
|
||||
ref="titleInput"
|
||||
class="text-lg font-bold bg-transparent border-b-2 border-[var(--border-focus)] focus:outline-none text-[var(--text-primary)] flex-1 px-1"
|
||||
placeholder="Untitled Recording">
|
||||
|
||||
<button v-if="!editingTitle && selectedRecording.can_edit !== false"
|
||||
@click.stop="toggleEditTitle"
|
||||
class="p-1.5 text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors flex-shrink-0">
|
||||
<i class="fas fa-pen text-xs"></i>
|
||||
</button>
|
||||
|
||||
<!-- Status Badge (for non-completed recordings) -->
|
||||
<span v-if="!editingTitle && selectedRecording.status !== 'COMPLETED'"
|
||||
:class="getStatusClass(selectedRecording.status)"
|
||||
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap flex-shrink-0">
|
||||
${formatStatus(selectedRecording.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-muted)] truncate">
|
||||
${selectedRecording.participants || t('help.noParticipants')}
|
||||
</p>
|
||||
<!-- Folder Pill, Tags and Share Status -->
|
||||
<div v-if="(foldersEnabled && selectedRecording.folder_id) || getRecordingTags(selectedRecording).length > 0 || selectedRecording.is_shared || selectedRecording.shared_with_count > 0 || selectedRecording.public_share_count > 0" class="flex flex-wrap gap-1 mt-2">
|
||||
<!-- Folder Pill -->
|
||||
<span v-if="foldersEnabled && selectedRecording.folder_id && !selectedRecording.incognito"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||
:style="{ backgroundColor: getFolderColor(selectedRecording.folder_id), color: getContrastTextColor(getFolderColor(selectedRecording.folder_id)) }"
|
||||
:title="'Folder: ' + getFolderName(selectedRecording.folder_id)">
|
||||
<i class="fas fa-folder mr-1" style="vertical-align: middle; line-height: 0;"></i>
|
||||
${ getFolderName(selectedRecording.folder_id) }
|
||||
</span>
|
||||
<button v-for="tag in getRecordingTags(selectedRecording)" :key="tag.id"
|
||||
@click.stop="filterByTag(tag)"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium hover:opacity-80 transition-all cursor-pointer"
|
||||
:style="{ backgroundColor: tag.color, color: getContrastTextColor(tag.color) }"
|
||||
:title="tag.group_id ? ('Group: ' + tag.group_name) : tag.name">
|
||||
<i v-if="tag.group_id" class="fas fa-users mr-1" style="vertical-align: middle; line-height: 0;"></i>
|
||||
<i v-else class="fas fa-tag mr-1" style="vertical-align: middle; line-height: 0;"></i>
|
||||
<span v-text="tag.name"></span>
|
||||
</button>
|
||||
<span v-if="selectedRecording.is_shared" class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-500 text-white">
|
||||
<i class="fas fa-arrow-down mr-1"></i>Shared
|
||||
</span>
|
||||
<span v-if="!selectedRecording.is_shared && selectedRecording.shared_with_count > 0" class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-indigo-500 text-white">
|
||||
<i class="fas fa-arrow-up mr-1"></i>${selectedRecording.shared_with_count}
|
||||
</span>
|
||||
<span v-if="!selectedRecording.is_shared && selectedRecording.public_share_count > 0" class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-teal-500 text-white">
|
||||
<i class="fas fa-globe mr-1"></i>${selectedRecording.public_share_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expand/Collapse Button -->
|
||||
<button @click.stop="isMetadataExpanded = !isMetadataExpanded"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:bg-[var(--bg-accent-hover)] hover:text-[var(--text-accent)] transition-colors flex-shrink-0">
|
||||
<i :class="['fas', 'fa-chevron-down', 'text-sm', 'transition-transform', { 'rotate-180': isMetadataExpanded }]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expandable Metadata and Actions -->
|
||||
<div v-if="isMetadataExpanded" class="mt-4 space-y-4">
|
||||
<div class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div class="flex items-center gap-2" @click.stop="openMeetingDatePicker">
|
||||
<i class="fas fa-calendar text-[var(--text-accent)]"></i>
|
||||
<span class="cursor-pointer hover:text-[var(--text-accent)] transition-colors">${selectedRecording.meeting_date ? formatDisplayDate(selectedRecording.meeting_date) : 'No date set'}</span>
|
||||
</div>
|
||||
<div v-if="selectedRecording.is_shared" class="flex items-center gap-2">
|
||||
<i class="fas fa-user text-[var(--text-accent)]"></i>
|
||||
<span :title="'Owner: ' + (selectedRecording.owner_username || t('sharing.unknown'))">
|
||||
${selectedRecording.owner_username || t('sharing.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="activeRecordingMetadata" v-for="(item, index) in activeRecordingMetadata" :key="index">
|
||||
<span v-if="!item.isTagItem" class="flex items-center gap-1.5">
|
||||
<i :class="item.icon"></i>
|
||||
<span :title="item.fullText || item.text">${item.text}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Duplicate Indicator -->
|
||||
<button v-if="selectedRecording.duplicate_info"
|
||||
@click.stop="openDuplicatesModal(selectedRecording.duplicate_info)"
|
||||
class="flex items-center gap-1.5 text-sm text-amber-500 hover:text-amber-400 transition-colors cursor-pointer mt-2">
|
||||
<i class="fas fa-copy"></i>
|
||||
<span>${ selectedRecording.duplicate_info.total_copies } ${ t('upload.copies') || 'copies' }</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
<!-- Incognito recordings have limited actions -->
|
||||
<template v-if="!selectedRecording.incognito">
|
||||
<!-- Folder Assignment (icon-only dropdown matching other buttons) -->
|
||||
<div v-if="foldersEnabled && selectedRecording.can_edit !== false"
|
||||
class="relative p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors flex-shrink-0"
|
||||
:title="selectedRecording.folder_id ? getFolderName(selectedRecording.folder_id) : 'Assign Folder'">
|
||||
<select @change="assignFolderToRecording(selectedRecording.id, $event.target.value || null)"
|
||||
:value="selectedRecording.folder_id || ''"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
|
||||
<option value="">No Folder</option>
|
||||
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
|
||||
${ folder.name }
|
||||
</option>
|
||||
</select>
|
||||
<i class="fas fa-folder"
|
||||
:style="{ color: selectedRecording.folder_id ? getFolderColor(selectedRecording.folder_id) : '' }"></i>
|
||||
</div>
|
||||
<button @click="toggleInbox(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors" :class="selectedRecording.is_inbox ? 'text-blue-500' : ''"><i class="fas fa-inbox"></i></button>
|
||||
<button @click="toggleHighlight(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors" :class="selectedRecording.is_highlighted ? 'text-yellow-500' : ''"><i class="fas fa-star"></i></button>
|
||||
<button @click="editRecordingTags(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-tags"></i></button>
|
||||
<button @click="confirmReprocess('transcription', selectedRecording)" v-if="selectedRecording && selectedRecording.can_edit !== false && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-redo-alt"></i></button>
|
||||
<button @click="confirmReprocess('summary', selectedRecording)" v-if="selectedRecording && selectedRecording.can_edit !== false && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-sync-alt"></i></button>
|
||||
<button @click="confirmReset(selectedRecording)" v-if="['PENDING', 'PROCESSING', 'SUMMARIZING', 'FAILED'].includes(selectedRecording.status)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-orange-500"><i class="fas fa-undo"></i></button>
|
||||
<button @click="openSpeakerModal" v-if="processedTranscription.hasDialogue" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-user-tag"></i></button>
|
||||
<button v-if="!selectedRecording.is_shared || (selectedRecording.share_info && selectedRecording.share_info.can_reshare)" @click="openUnifiedShareModal(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-share-alt"></i></button>
|
||||
<button v-if="canDeleteRecordings && selectedRecording.can_delete !== false" @click="confirmDelete(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-danger-light)] text-[var(--text-danger)] transition-colors"><i class="fas fa-trash"></i></button>
|
||||
</template>
|
||||
|
||||
<!-- Incognito mode: only show discard button -->
|
||||
<template v-else>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400 px-2">
|
||||
<i class="fas fa-user-secret mr-1"></i>
|
||||
Incognito Mode
|
||||
</span>
|
||||
<button @click="clearIncognitoRecordingWithConfirm"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"
|
||||
title="Discard incognito recording">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
26
templates/components/detail/mobile-notes-panel.html
Normal file
26
templates/components/detail/mobile-notes-panel.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!-- Mobile Notes Panel -->
|
||||
<div v-if="mobileTab === 'notes'" class="h-full flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
|
||||
<button @click="copyNotes" class="copy-btn" :title="t('buttons.copyNotes')"><i class="fas fa-copy"></i></button>
|
||||
<button @click="downloadNotes" class="copy-btn" :title="t('buttons.downloadAsWord')"><i class="fas fa-download"></i></button>
|
||||
<button @click="toggleEditNotes" class="copy-btn" :title="t('buttons.editNotes')"><i class="fas fa-edit"></i></button>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto mobile-content-box">
|
||||
<div v-if="editingNotes" class="h-full flex flex-col">
|
||||
<div class="markdown-editor-container flex-1">
|
||||
<textarea ref="notesMarkdownEditor" v-model="selectedRecording.notes"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<button @click="cancelEditNotes" class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]" v-text="t('common.cancel')"></button>
|
||||
<button @click="saveEditNotes" class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] border border-transparent rounded hover:bg-[var(--bg-button-hover)]" v-text="t('common.save')"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else @click="clickToEditNotes">
|
||||
<div v-if="!selectedRecording.notes" class="text-center py-8 cursor-pointer hover:text-[var(--text-secondary)]">
|
||||
<i class="fas fa-sticky-note text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)]" v-text="t('help.clickToAddNotes')"></p>
|
||||
</div>
|
||||
<div v-else class="notes-box h-full" v-html="selectedRecording.notes_html || selectedRecording.notes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
45
templates/components/detail/mobile-summary-panel.html
Normal file
45
templates/components/detail/mobile-summary-panel.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!-- Mobile Summary Panel -->
|
||||
<div v-if="mobileTab === 'summary'" class="h-full flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
|
||||
<button @click="copySummary" class="copy-btn" :title="t('buttons.copySummary')"><i class="fas fa-copy"></i></button>
|
||||
<button @click="downloadSummary" class="copy-btn" :title="t('buttons.downloadAsWord')"><i class="fas fa-download"></i></button>
|
||||
<button @click="toggleEditSummary" class="copy-btn" :title="t('buttons.editSummary')"><i class="fas fa-edit"></i></button>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto mobile-content-box">
|
||||
<div v-if="editingSummary" class="h-full flex flex-col">
|
||||
<div class="markdown-editor-container flex-1">
|
||||
<textarea ref="summaryMarkdownEditor" v-model="selectedRecording.summary"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<button @click="cancelEditSummary" class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]" v-text="t('common.cancel')"></button>
|
||||
<button @click="saveEditSummary" class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] border border-transparent rounded hover:bg-[var(--bg-button-hover)]" v-text="t('common.save')"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="selectedRecording.status === 'SUMMARIZING'" class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||||
<p class="mt-2 text-[var(--text-muted)]" v-text="t('help.generatingSummary')"></p>
|
||||
</div>
|
||||
<div v-else-if="!selectedRecording.summary" class="text-center py-8">
|
||||
<i class="fas fa-file-alt text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)] mb-4" v-text="t('summary.noSummary')"></p>
|
||||
<!-- Show message if transcription is an error -->
|
||||
<p v-if="processedTranscription.isError" class="text-sm text-amber-500 mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Cannot generate summary: transcription failed
|
||||
</p>
|
||||
<button @click="generateSummary"
|
||||
:disabled="processedTranscription.isError"
|
||||
:class="[
|
||||
'px-6 py-3 font-medium rounded-lg shadow-lg transition-all duration-200',
|
||||
processedTranscription.isError
|
||||
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:shadow-xl hover:from-blue-700 hover:to-purple-700 transform hover:scale-105'
|
||||
]">
|
||||
<i class="fas fa-magic mr-2"></i>Generate Summary
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="summary-box h-full" v-html="selectedRecording.summary_html || selectedRecording.summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
130
templates/components/detail/mobile-transcript-panel.html
Normal file
130
templates/components/detail/mobile-transcript-panel.html
Normal file
@@ -0,0 +1,130 @@
|
||||
<!-- Mobile Transcript Panel -->
|
||||
<div v-if="mobileTab === 'transcript'" class="h-full flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-between gap-2 flex-shrink-0 px-3">
|
||||
<!-- Follow Player Checkbox -->
|
||||
<div v-if="processedTranscription.isJson && processedTranscription.hasDialogue"
|
||||
class="follow-player-control text-[var(--text-muted)]"
|
||||
@click="toggleFollowPlayerMode"
|
||||
:title="followPlayerMode ? t('tooltips.followPlayerEnabled') : t('tooltips.followPlayerDisabled')">
|
||||
<input type="checkbox"
|
||||
:checked="followPlayerMode"
|
||||
@click.stop="toggleFollowPlayerMode">
|
||||
<i class="fas fa-arrows-alt-v follow-icon"></i>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="processedTranscription.hasDialogue" class="view-mode-toggle">
|
||||
<button @click="toggleTranscriptionViewMode" :class="['toggle-button', transcriptionViewMode === 'simple' ? 'active' : '']"><i class="fas fa-list"></i></button>
|
||||
<button @click="toggleTranscriptionViewMode" :class="['toggle-button', transcriptionViewMode === 'bubble' ? 'active' : '']"><i class="fas fa-comments"></i></button>
|
||||
</div>
|
||||
<button @click="copyTranscription" class="copy-btn" :title="t('tooltips.copyTranscript')"><i class="fas fa-copy"></i></button>
|
||||
<button @click="downloadTranscript" v-if="selectedRecording && selectedRecording.transcription" class="copy-btn" :title="t('tooltips.downloadTranscriptWithTemplate')"><i class="fas fa-download"></i></button>
|
||||
<button @click="openTranscriptionEditor" v-if="selectedRecording && selectedRecording.transcription" class="copy-btn" :title="t('tooltips.editTranscript')"><i class="fas fa-edit"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto mobile-content-box relative">
|
||||
<!-- Floating Processing Indicator (Mobile) -->
|
||||
<div v-if="selectedRecording.status === 'PROCESSING'"
|
||||
:class="['processing-indicator-floating', processingIndicatorMinimized ? 'minimized' : '']">
|
||||
<template v-if="!processingIndicatorMinimized">
|
||||
<div class="processing-indicator-content">
|
||||
<i class="fas fa-spinner fa-spin text-[var(--text-accent)]"></i>
|
||||
<span class="text-sm text-[var(--text-secondary)]" v-text="t('help.processingTranscription')"></span>
|
||||
</div>
|
||||
<button @click="processingIndicatorMinimized = true"
|
||||
class="processing-indicator-minimize"
|
||||
:title="t('tooltips.minimize')">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button @click="processingIndicatorMinimized = false"
|
||||
class="processing-indicator-expand"
|
||||
:title="t('help.processingTranscription')">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No transcription state (only show if not processing and no transcription) -->
|
||||
<div v-if="!selectedRecording.transcription && selectedRecording.status !== 'PROCESSING'" class="text-center py-8">
|
||||
<i class="fas fa-file-text text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)]" v-text="t('transcription.noTranscription')"></p>
|
||||
</div>
|
||||
|
||||
<!-- Error Display (when transcription is an error message) -->
|
||||
<div v-if="processedTranscription.isError" class="error-display-container p-2">
|
||||
<div :class="[
|
||||
'rounded-lg p-4 border',
|
||||
processedTranscription.error.type === 'size_limit' ? 'bg-amber-500/10 border-amber-500/30' :
|
||||
processedTranscription.error.type === 'timeout' ? 'bg-orange-500/10 border-orange-500/30' :
|
||||
processedTranscription.error.type === 'auth' ? 'bg-red-500/10 border-red-500/30' :
|
||||
'bg-gray-500/10 border-gray-500/30'
|
||||
]">
|
||||
<div class="flex items-start gap-3">
|
||||
<div :class="[
|
||||
'flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
|
||||
processedTranscription.error.type === 'size_limit' ? 'bg-amber-500/20' :
|
||||
processedTranscription.error.type === 'timeout' ? 'bg-orange-500/20' :
|
||||
processedTranscription.error.type === 'auth' ? 'bg-red-500/20' :
|
||||
'bg-gray-500/20'
|
||||
]">
|
||||
<i :class="[
|
||||
'fas',
|
||||
processedTranscription.error.icon,
|
||||
processedTranscription.error.type === 'size_limit' ? 'text-amber-500' :
|
||||
processedTranscription.error.type === 'timeout' ? 'text-orange-500' :
|
||||
processedTranscription.error.type === 'auth' ? 'text-red-500' :
|
||||
'text-gray-500'
|
||||
]"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-[var(--text-primary)] mb-1">
|
||||
${processedTranscription.error.title}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
${processedTranscription.error.message}
|
||||
</p>
|
||||
<div v-if="processedTranscription.error.guidance" class="text-xs text-[var(--text-muted)] bg-[var(--bg-tertiary)]/50 rounded p-2">
|
||||
<i class="fas fa-lightbulb text-yellow-500 mr-1"></i>
|
||||
${processedTranscription.error.guidance}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-[var(--border-secondary)]">
|
||||
<button @click="reprocessTranscription(selectedRecording.id)"
|
||||
v-if="selectedRecording.can_edit !== false"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-accent)] hover:bg-[var(--bg-accent-hover)] text-white rounded-lg transition-colors text-sm">
|
||||
<i class="fas fa-redo-alt mr-2"></i>Reprocess
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcription content (show regardless of processing state if transcription exists) -->
|
||||
<div v-if="selectedRecording.transcription && !processedTranscription.isError">
|
||||
<div v-if="!processedTranscription.hasDialogue || transcriptionViewMode === 'simple'" class="transcription-simple-view">
|
||||
<div v-if="processedTranscription.hasDialogue">
|
||||
<div v-for="(segment, index) in processedTranscription.simpleSegments" :key="`seg-${index}-${segment.startTime}`" :class="['speaker-segment', { 'active-playing-segment': currentPlayingSegmentIndex === index }]" @click="seekAudioFromEvent" :data-start-time="segment.startTime" :data-segment-index="index">
|
||||
<div v-if="segment.showSpeaker" :class="['speaker-tablet', segment.color]">${segment.speaker}</div>
|
||||
<div class="speaker-text">${segment.sentence}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="processedTranscription.simpleSegments && processedTranscription.simpleSegments.length > 0">
|
||||
<div v-for="(segment, index) in processedTranscription.simpleSegments" :key="`seg-${index}-${segment.startTime}`" :class="['transcript-segment cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors', { 'active-playing-segment': currentPlayingSegmentIndex === index }]" @click="seekAudioFromEvent" :data-start-time="segment.startTime" :data-segment-index="index">${segment.sentence}</div>
|
||||
</div>
|
||||
<div v-else class="whitespace-pre-wrap">${processedTranscription.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="transcriptionViewMode === 'bubble'" class="transcription-with-speakers">
|
||||
<template v-for="(row, rowIndex) in processedTranscription.bubbleRows" :key="`${selectedRecording.id}-bubble-row-${rowIndex}`">
|
||||
<div :class="['bubble-row', row.isMe ? 'speaker-me' : '']">
|
||||
<div v-for="(bubble, bubbleIndex) in row.bubbles" :key="`bubble-${rowIndex}-${bubbleIndex}-${bubble.startTime}`" :class="['speaker-bubble', bubble.color, row.isMe ? 'speaker-me' : '', { 'active-playing-segment': currentPlayingSegmentIndex === getBubbleGlobalIndex(rowIndex, bubbleIndex) }]" @click="seekAudioFromEvent" :data-start-time="bubble.startTime" :data-segment-index="getBubbleGlobalIndex(rowIndex, bubbleIndex)">
|
||||
<div class="speaker-bubble-content">${bubble.sentence}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
20
templates/components/detail/tab-navigation.html
Normal file
20
templates/components/detail/tab-navigation.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!-- Tab Navigation for Mobile -->
|
||||
<div class="flex-shrink-0 bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)] flex overflow-x-auto">
|
||||
<button @click="mobileTab = 'transcript'"
|
||||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'transcript' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
|
||||
v-text="t('transcription.title')"></button>
|
||||
<button @click="mobileTab = 'summary'"
|
||||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'summary' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
|
||||
v-text="t('summary.title')"></button>
|
||||
<button @click="mobileTab = 'notes'"
|
||||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'notes' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
|
||||
v-text="t('notes.title')"></button>
|
||||
<button @click="mobileTab = 'chat'"
|
||||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'chat' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
|
||||
v-text="t('chat.title')"></button>
|
||||
<button v-if="selectedRecording.events && selectedRecording.events.length > 0"
|
||||
@click="mobileTab = 'events'"
|
||||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap', mobileTab === 'events' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']">
|
||||
<i class="fas fa-calendar-alt mr-1"></i>${ t('events.title') } (${ selectedRecording.events.length })
|
||||
</button>
|
||||
</div>
|
||||
91
templates/components/dictia/help-tab.html
Normal file
91
templates/components/dictia/help-tab.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<div id="content-help" class="hidden tab-content pt-0" style="height: calc(100vh - 280px); margin: 0 -1rem -1rem -1rem;">
|
||||
<div class="flex h-full">
|
||||
<!-- Sidebar Navigation -->
|
||||
<div id="help-sidebar" class="w-72 border-r border-[var(--border-primary)] bg-[var(--bg-secondary)] flex flex-col flex-shrink-0 h-full overflow-hidden">
|
||||
<!-- Search -->
|
||||
<div class="p-3 border-b border-[var(--border-primary)]">
|
||||
<div class="relative">
|
||||
<input type="text" id="help-search" placeholder="Rechercher dans la doc..."
|
||||
class="w-full pl-9 pr-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--border-accent)] focus:border-transparent">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] text-xs"></i>
|
||||
</div>
|
||||
<!-- Search results dropdown -->
|
||||
<div id="help-search-results" class="hidden mt-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
<!-- Navigation tree -->
|
||||
<nav id="help-nav" class="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
<!-- Populated by JS -->
|
||||
</nav>
|
||||
<!-- Home link -->
|
||||
<div class="p-3 border-t border-[var(--border-primary)]">
|
||||
<button onclick="loadDocPage('', 'index')" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
<i class="fas fa-home text-xs"></i>
|
||||
<span>Accueil documentation</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile sidebar toggle -->
|
||||
<button id="help-sidebar-toggle" class="hidden fixed bottom-4 left-4 z-50 bg-[var(--bg-accent)] text-white p-3 rounded-full shadow-lg sm:hidden" onclick="toggleHelpSidebar()">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<!-- Main Content Area -->
|
||||
<div id="help-content-area" class="flex-1 overflow-y-auto">
|
||||
<!-- Loading state -->
|
||||
<div id="help-loading" class="hidden flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-accent)] mb-3"></i>
|
||||
<p class="text-sm text-[var(--text-muted)]">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Welcome state (shown initially) -->
|
||||
<div id="help-welcome" class="flex items-center justify-center h-full p-8">
|
||||
<div class="text-center max-w-lg">
|
||||
<i class="fas fa-book-open text-5xl text-[var(--text-accent)] mb-6"></i>
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-3">Documentation DictIA</h2>
|
||||
<p class="text-[var(--text-secondary)] mb-6">Bienvenue dans la documentation intégrée de DictIA. Sélectionnez une page dans le menu de gauche pour commencer.</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button onclick="loadDocPage('guide-utilisateur', 'index')" class="flex flex-col items-center p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:border-[var(--border-accent)] hover:shadow-md transition-all">
|
||||
<i class="fas fa-book-open text-xl text-[var(--text-accent)] mb-2"></i>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">Guide Utilisateur</span>
|
||||
</button>
|
||||
<button onclick="loadDocPage('depannage', 'index')" class="flex flex-col items-center p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:border-[var(--border-accent)] hover:shadow-md transition-all">
|
||||
<i class="fas fa-life-ring text-xl text-[var(--text-accent)] mb-2"></i>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">Dépannage</span>
|
||||
</button>
|
||||
<button id="help-admin-card" onclick="loadDocPage('guide-admin', 'index')" class="hidden flex-col items-center p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:border-[var(--border-accent)] hover:shadow-md transition-all">
|
||||
<i class="fas fa-shield-alt text-xl text-[var(--text-accent)] mb-2"></i>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">Guide Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Document content -->
|
||||
<div id="help-doc-content" class="hidden">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="help-breadcrumb" class="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-primary)] px-8 py-3">
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||
<button onclick="showHelpWelcome()" class="hover:text-[var(--text-accent)]"><i class="fas fa-home"></i></button>
|
||||
<i class="fas fa-chevron-right text-xs"></i>
|
||||
<span id="help-breadcrumb-section"></span>
|
||||
<i class="fas fa-chevron-right text-xs"></i>
|
||||
<span id="help-breadcrumb-page" class="text-[var(--text-primary)]"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rendered content -->
|
||||
<div id="help-rendered-content" class="prose prose-sm max-w-none px-8 py-6">
|
||||
<!-- Rendered markdown HTML goes here -->
|
||||
</div>
|
||||
<!-- Navigation footer -->
|
||||
<div id="help-nav-footer" class="border-t border-[var(--border-primary)] px-8 py-4 flex justify-between">
|
||||
<button id="help-prev-page" class="hidden flex items-center gap-2 text-sm text-[var(--text-accent)] hover:underline" onclick="navigateDocPrev()">
|
||||
<i class="fas fa-arrow-left"></i> <span id="help-prev-title"></span>
|
||||
</button>
|
||||
<div></div>
|
||||
<button id="help-next-page" class="hidden flex items-center gap-2 text-sm text-[var(--text-accent)] hover:underline" onclick="navigateDocNext()">
|
||||
<span id="help-next-title"></span> <i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
99
templates/components/header.html
Normal file
99
templates/components/header.html
Normal file
@@ -0,0 +1,99 @@
|
||||
<!-- Header Component -->
|
||||
<header class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] px-3 sm:px-4 py-0 flex items-center justify-between flex-shrink-0 z-50">
|
||||
<!-- Left side: Menu toggle and logo -->
|
||||
<div class="flex items-center gap-2 sm:gap-4 min-w-0 flex-shrink">
|
||||
<!-- Menu Toggle Button -->
|
||||
<button @click="toggleSidebar"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200 flex-shrink-0 flex items-center justify-center">
|
||||
<i class="fas fa-bars text-lg"></i>
|
||||
</button>
|
||||
|
||||
<!-- Logo and Title -->
|
||||
<div class="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="w-14 h-14 sm:w-16 sm:h-16 flex-shrink-0">
|
||||
<h1 class="text-lg sm:text-xl font-bold text-[var(--text-primary)] truncate">DictIA</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: User menu and controls -->
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
{% include 'components/token_budget_indicator.html' %}
|
||||
|
||||
<!-- Inquire Mode Button -->
|
||||
{% if inquire_mode_enabled %}
|
||||
<a href="/inquire"
|
||||
class="px-2 py-1.5 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-lg hover:opacity-80 transition-opacity text-sm sm:px-4 sm:py-2 sm:min-w-[120px] sm:flex sm:items-center sm:gap-2 sm:justify-center">
|
||||
<i class="fas fa-search"></i>
|
||||
<span class="hidden sm:inline" v-text="t('inquire.title')"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Install PWA Button -->
|
||||
<button v-if="showInstallButton && !isPWAInstalled"
|
||||
@click="promptInstall"
|
||||
class="px-2 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm sm:px-4 sm:py-2 sm:flex sm:items-center sm:gap-2"
|
||||
:title="t('pwa.installApp')">
|
||||
<i class="fas fa-download"></i>
|
||||
<span class="hidden sm:inline" v-text="t('pwa.installApp')"></span>
|
||||
</button>
|
||||
|
||||
<!-- New Recording Button -->
|
||||
<button @click="switchToUploadView"
|
||||
class="px-3 py-1.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm sm:px-4 sm:py-2 sm:min-w-[120px] sm:flex sm:items-center sm:gap-2 sm:justify-center">
|
||||
<i class="fas fa-plus mr-1"></i><span v-text="t('nav.newRecording')"></span>
|
||||
</button>
|
||||
|
||||
<!-- User menu -->
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="relative">
|
||||
<button @click="isUserMenuOpen = !isUserMenuOpen"
|
||||
data-user-menu-toggle
|
||||
class="flex items-center gap-1 sm:gap-2 p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
|
||||
:title="t('admin.userMenu')">
|
||||
<i class="fas fa-user-circle text-lg"></i>
|
||||
<span class="hidden lg:inline text-sm">{{ (current_user.name or current_user.username) if current_user.is_authenticated else 'User' }}</span>
|
||||
<i class="fas fa-chevron-down text-xs hidden sm:inline"></i>
|
||||
</button>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<div v-if="isUserMenuOpen"
|
||||
data-user-menu-dropdown
|
||||
class="absolute right-0 mt-2 w-56 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-50">
|
||||
<a href="/account" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-cog mr-2 w-4 text-center"></i><span v-text="t('nav.settings')"></span>
|
||||
</a>
|
||||
<a href="/account#tags" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-tags mr-2 w-4 text-center"></i><span v-text="t('help.tagManagement')"></span>
|
||||
</a>
|
||||
<button @click="openSharesList" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||||
<i class="fas fa-share-alt mr-2 w-4 text-center"></i><span v-text="t('modal.sharedTranscripts')"></span>
|
||||
</button>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="/admin" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-user-shield mr-2 w-4 text-center"></i>
|
||||
<span v-text="t('admin.title')"></span>
|
||||
</a>
|
||||
{% elif is_group_admin %}
|
||||
<a href="/group-management" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-users-cog mr-2 w-4 text-center"></i>
|
||||
<span v-text="t('nav.groupManagement')"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="border-t border-[var(--border-primary)] my-1"></div>
|
||||
<button @click="toggleDarkMode" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||||
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'" class="mr-2 w-4 text-center"></i>
|
||||
<span v-text="isDarkMode ? t('nav.lightMode') : t('nav.darkMode')"></span>
|
||||
</button>
|
||||
<button @click="openColorSchemeModal" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||||
<i class="fas fa-palette mr-2 w-4 text-center"></i>
|
||||
<span v-text="t('modal.colorScheme')"></span>
|
||||
</button>
|
||||
<div class="border-t border-[var(--border-primary)] my-1"></div>
|
||||
<a href="/logout" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-sign-out-alt mr-2 w-4 text-center"></i><span v-text="t('nav.signOut')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
189
templates/components/progress-popup.html
Normal file
189
templates/components/progress-popup.html
Normal file
@@ -0,0 +1,189 @@
|
||||
<!-- Processing Queue Popup - Compact Unified Progress Tracking -->
|
||||
<div v-if="showProcessingPopup && !progressPopupClosed"
|
||||
:class="[
|
||||
'fixed bottom-4 left-4 z-[100] w-80 transition-all duration-300',
|
||||
progressPopupMinimized ? 'minimized' : ''
|
||||
]">
|
||||
<div class="bg-[var(--bg-secondary)] border border-[var(--bg-accent)] rounded-lg shadow-xl overflow-hidden backdrop-blur-sm bg-opacity-95">
|
||||
<!-- Compact Header -->
|
||||
<div class="bg-gradient-to-r from-[var(--bg-accent)] to-[var(--bg-accent-hover)] px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="fas fa-tasks text-sm text-gray-800 dark:text-white"></i>
|
||||
<span class="font-medium text-sm text-gray-800 dark:text-white">Processing Queue</span>
|
||||
<span class="text-xs bg-black dark:bg-white bg-opacity-20 dark:bg-opacity-20 px-1.5 py-0.5 rounded-full text-gray-800 dark:text-white font-medium">
|
||||
${totalProcessingCount}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<button v-if="allCompletedCount > 0" @click="clearAllCompleted()"
|
||||
class="px-1.5 py-0.5 text-xs bg-black dark:bg-white bg-opacity-10 dark:bg-opacity-20 hover:bg-opacity-20 dark:hover:bg-opacity-30 rounded transition-all text-gray-800 dark:text-white">
|
||||
Clear
|
||||
</button>
|
||||
<button @click="progressPopupMinimized = !progressPopupMinimized"
|
||||
class="p-1 rounded hover:bg-black dark:hover:bg-white hover:bg-opacity-10 dark:hover:bg-opacity-20 transition-colors text-gray-800 dark:text-white">
|
||||
<i :class="progressPopupMinimized ? 'fas fa-chevron-up text-xs' : 'fas fa-chevron-down text-xs'"></i>
|
||||
</button>
|
||||
<button @click="progressPopupClosed = true"
|
||||
class="p-1 rounded hover:bg-red-500 hover:bg-opacity-30 text-gray-800 dark:text-white hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Compact Summary Line -->
|
||||
<div v-if="activeProgressItems.length > 0" class="text-xs text-gray-800 dark:text-white opacity-70 mt-1">
|
||||
<span v-if="activeProgressItems.filter(i => i.status === 'uploading').length > 0">
|
||||
${activeProgressItems.filter(i => i.status === 'uploading').length} uploading<span v-if="activeProgressItems.filter(i => i.status !== 'uploading').length > 0">, </span>
|
||||
</span>
|
||||
<span v-if="activeProgressItems.filter(i => i.status === 'transcribing').length > 0">
|
||||
${activeProgressItems.filter(i => i.status === 'transcribing').length} transcribing<span v-if="activeProgressItems.filter(i => ['summarizing', 'queued'].includes(i.status)).length > 0">, </span>
|
||||
</span>
|
||||
<span v-if="activeProgressItems.filter(i => i.status === 'summarizing').length > 0">
|
||||
${activeProgressItems.filter(i => i.status === 'summarizing').length} summarizing<span v-if="activeProgressItems.filter(i => i.status === 'queued').length > 0">, </span>
|
||||
</span>
|
||||
<span v-if="activeProgressItems.filter(i => i.status === 'queued').length > 0">
|
||||
${activeProgressItems.filter(i => i.status === 'queued').length} queued
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact Content -->
|
||||
<div v-if="!progressPopupMinimized" class="p-2 max-h-64 overflow-y-auto space-y-1.5">
|
||||
<!-- Active Items -->
|
||||
<template v-for="item in activeProgressItems" :key="item.id">
|
||||
<div :class="[
|
||||
'px-2 py-1.5 rounded-md transition-all',
|
||||
item.status === 'uploading' ? 'bg-blue-500/10 border-l-2 border-blue-500' :
|
||||
item.status === 'transcribing' ? 'bg-purple-500/10 border-l-2 border-purple-500' :
|
||||
item.status === 'summarizing' ? 'bg-green-500/10 border-l-2 border-green-500' :
|
||||
'bg-[var(--bg-tertiary)]/50 border-l-2 border-yellow-500'
|
||||
]">
|
||||
<!-- Title row with status icon -->
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="[
|
||||
'fas text-xs',
|
||||
getStatusDisplay(item.status).icon,
|
||||
item.status === 'uploading' ? 'text-blue-500' :
|
||||
item.status === 'transcribing' ? 'text-purple-500' :
|
||||
item.status === 'summarizing' ? 'text-green-500' :
|
||||
'text-yellow-500',
|
||||
getStatusDisplay(item.status).animate ? 'animate-pulse' : ''
|
||||
]"></i>
|
||||
<span class="text-xs font-medium truncate flex-1 text-[var(--text-primary)]">${item.title}</span>
|
||||
<span v-if="item.status === 'uploading' && item.progress !== null"
|
||||
class="text-xs font-bold text-blue-500">${item.progress}%</span>
|
||||
<span v-else :class="[
|
||||
'text-xs font-medium px-1.5 py-0.5 rounded',
|
||||
item.status === 'uploading' ? 'bg-blue-500/20 text-blue-500' :
|
||||
item.status === 'transcribing' ? 'bg-purple-500/20 text-purple-500' :
|
||||
item.status === 'summarizing' ? 'bg-green-500/20 text-green-500' :
|
||||
'bg-yellow-500/20 text-yellow-500'
|
||||
]">${getStatusDisplay(item.status).label}</span>
|
||||
</div>
|
||||
<!-- Progress bar for uploads -->
|
||||
<div v-if="item.status === 'uploading' && item.progress !== null"
|
||||
class="mt-1 h-1 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-500 rounded-full transition-all duration-300"
|
||||
:style="{width: item.progress + '%'}"></div>
|
||||
</div>
|
||||
<!-- Compact message -->
|
||||
<div class="flex items-center justify-between mt-0.5">
|
||||
<span class="text-[10px] text-[var(--text-muted)]">${item.progressMessage}</span>
|
||||
<button v-if="item.status === 'ready' && item.clientId"
|
||||
@click="removeProgressItem(item)"
|
||||
class="text-[10px] text-red-500 hover:text-red-600">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Failed Items -->
|
||||
<div v-if="failedProgressItems.length > 0" class="mt-2">
|
||||
<div class="text-[10px] font-semibold text-red-500 uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
Failed (${failedProgressItems.length})
|
||||
</div>
|
||||
<div v-for="item in failedProgressItems"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'px-2 py-1.5 rounded-md mb-1 border-l-2',
|
||||
item.friendlyError?.type === 'size_limit' ? 'bg-amber-500/10 border-amber-500' :
|
||||
item.friendlyError?.type === 'timeout' ? 'bg-orange-500/10 border-orange-500' :
|
||||
item.friendlyError?.type === 'auth' ? 'bg-red-500/10 border-red-500' :
|
||||
item.friendlyError?.type === 'rate_limit' ? 'bg-yellow-500/10 border-yellow-500' :
|
||||
item.friendlyError?.type === 'connection' ? 'bg-blue-500/10 border-blue-500' :
|
||||
item.friendlyError?.type === 'service_error' ? 'bg-purple-500/10 border-purple-500' :
|
||||
'bg-red-500/10 border-red-500'
|
||||
]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="[
|
||||
'fas text-xs',
|
||||
item.friendlyError?.icon || 'fa-exclamation-circle',
|
||||
item.friendlyError?.type === 'size_limit' ? 'text-amber-500' :
|
||||
item.friendlyError?.type === 'timeout' ? 'text-orange-500' :
|
||||
item.friendlyError?.type === 'auth' ? 'text-red-500' :
|
||||
item.friendlyError?.type === 'rate_limit' ? 'text-yellow-500' :
|
||||
item.friendlyError?.type === 'connection' ? 'text-blue-500' :
|
||||
item.friendlyError?.type === 'service_error' ? 'text-purple-500' :
|
||||
'text-red-500'
|
||||
]"></i>
|
||||
<span class="text-xs truncate flex-1 text-[var(--text-primary)]">${item.title}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button v-if="item.jobId" @click.stop="retryProgressItem(item)"
|
||||
class="p-1 rounded bg-blue-500/20 hover:bg-blue-500/40 transition-colors"
|
||||
title="Retry">
|
||||
<i class="fas fa-redo text-blue-500 text-[10px]"></i>
|
||||
</button>
|
||||
<button @click.stop="removeProgressItem(item)"
|
||||
class="p-1 rounded bg-red-500/20 hover:bg-red-500/40 transition-colors"
|
||||
title="Delete">
|
||||
<i class="fas fa-trash text-red-500 text-[10px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Friendly error title and message -->
|
||||
<div v-if="item.friendlyError" class="mt-0.5">
|
||||
<span :class="[
|
||||
'text-[10px] font-medium',
|
||||
item.friendlyError.type === 'size_limit' ? 'text-amber-500' :
|
||||
item.friendlyError.type === 'timeout' ? 'text-orange-500' :
|
||||
item.friendlyError.type === 'auth' ? 'text-red-500' :
|
||||
item.friendlyError.type === 'rate_limit' ? 'text-yellow-500' :
|
||||
item.friendlyError.type === 'connection' ? 'text-blue-500' :
|
||||
item.friendlyError.type === 'service_error' ? 'text-purple-500' :
|
||||
'text-red-400'
|
||||
]">${item.friendlyError.title}</span>
|
||||
<span v-if="item.friendlyError.guidance" class="text-[10px] text-[var(--text-muted)] block truncate">${item.friendlyError.guidance}</span>
|
||||
</div>
|
||||
<!-- Fallback to raw error message if no friendly error -->
|
||||
<span v-else-if="item.errorMessage" class="text-[10px] text-red-400 block truncate mt-0.5">${item.errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Items -->
|
||||
<div v-if="completedProgressItems.length > 0" class="mt-2">
|
||||
<div class="text-[10px] font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<i class="fas fa-check-double"></i>
|
||||
Completed (${completedProgressItems.length})
|
||||
</div>
|
||||
<div v-for="item in completedProgressItems"
|
||||
:key="item.id"
|
||||
@click="item.recordingId && selectRecording({id: item.recordingId})"
|
||||
class="px-2 py-1 bg-[var(--bg-tertiary)]/30 rounded-md flex items-center gap-2 hover:bg-[var(--bg-tertiary)]/50 cursor-pointer mb-1">
|
||||
<i class="fas fa-check-circle text-xs text-green-500"></i>
|
||||
<span class="text-xs truncate flex-1 text-[var(--text-primary)]">${item.title}</span>
|
||||
<i v-if="item.duplicateWarning" class="fas fa-copy text-xs text-amber-500" title="Duplicate file detected"></i>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/20 text-green-500 font-medium">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="unifiedProgressItems.length === 0"
|
||||
class="text-center py-3 text-[var(--text-muted)] text-xs">
|
||||
<i class="fas fa-inbox text-lg mb-1 opacity-50"></i>
|
||||
<p>No items in queue</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
403
templates/components/recording-view.html
Normal file
403
templates/components/recording-view.html
Normal file
@@ -0,0 +1,403 @@
|
||||
<!-- Recording View -->
|
||||
<div v-else-if="currentView === 'recording'" class="flex-1 flex flex-col p-2 md:p-8 bg-[var(--bg-primary)] min-h-0">
|
||||
<div class="flex-1 flex flex-col max-w-4xl w-full mx-auto bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-primary)] min-h-0 overflow-hidden">
|
||||
<!-- Top: Visualizer and Status (Fixed) -->
|
||||
<div class="flex-shrink-0 p-3 md:p-6 text-center bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]">
|
||||
<div v-if="isRecording" class="w-full mx-auto mb-3 md:mb-4">
|
||||
<!-- Dual visualizer for 'both' mode -->
|
||||
<div v-if="recordingMode === 'both'" class="max-w-4xl mx-auto flex gap-2 md:gap-4">
|
||||
<div class="w-1/2 min-h-[8rem] md:min-h-[12rem] h-32 md:h-48 flex flex-col bg-[var(--bg-primary)] rounded-lg p-1.5 md:p-2">
|
||||
<div class="flex-1 w-full overflow-hidden rounded-md" style="min-height: 6rem;">
|
||||
<canvas ref="micVisualizer" class="w-full h-full"></canvas>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] pt-0.5 md:pt-1" v-text="t('recording.microphone')"></p>
|
||||
</div>
|
||||
<div class="w-1/2 min-h-[8rem] md:min-h-[12rem] h-32 md:h-48 flex flex-col bg-[var(--bg-primary)] rounded-lg p-1.5 md:p-2">
|
||||
<div class="flex-1 w-full overflow-hidden rounded-md" style="min-height: 6rem;">
|
||||
<canvas ref="systemVisualizer" class="w-full h-full"></canvas>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] pt-0.5 md:pt-1" v-text="t('recording.systemAudio')"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Single visualizer for other modes -->
|
||||
<div v-else class="min-h-[8rem] md:min-h-[12rem] h-32 md:h-48 max-w-2xl mx-auto bg-[var(--bg-primary)] rounded-lg p-1.5 md:p-2 flex flex-col">
|
||||
<div class="flex-1 w-full overflow-hidden rounded-md" style="min-height: 6rem;">
|
||||
<canvas ref="visualizer" class="w-full h-full"></canvas>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] pt-0.5 md:pt-1 capitalize">${recordingMode}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isRecording && audioBlobURL" class="w-full mb-3 md:mb-4">
|
||||
<div class="audio-player-container">
|
||||
<audio :src="audioBlobURL" controls class="w-full">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xl md:text-2xl font-mono text-[var(--text-accent)]">${formatTime(recordingTime)}</div>
|
||||
<p class="text-xs md:text-sm text-[var(--text-muted)]">${ isRecording ? 'Recording in progress...' : 'Recording finished' }</p>
|
||||
<!-- Real-time file size display -->
|
||||
<div v-if="isRecording && estimatedFileSize > 0" class="mt-1.5">
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
Estimated size: <span class="font-mono">${formatFileSize(estimatedFileSize)}</span>
|
||||
<span v-if="actualBitrate > 0" class="ml-2">
|
||||
(${Math.round(actualBitrate / 1000)}kbps)
|
||||
</span>
|
||||
</p>
|
||||
<!-- Size warning indicator -->
|
||||
<div v-if="estimatedFileSize > (maxRecordingMB * 1024 * 1024 * 0.8)" class="mt-0.5">
|
||||
<div class="flex items-center text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
<span v-text="t('help.approachingLimit', { limit: maxRecordingMB })"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Background Recording Warning - Below timer -->
|
||||
<div v-if="isRecording && isMobileDevice"
|
||||
class="mt-2 p-2 bg-amber-50 dark:bg-amber-900 dark:bg-opacity-20 border border-amber-300 dark:border-amber-700 rounded">
|
||||
<div class="flex items-start gap-1.5 text-left">
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 dark:text-amber-400 flex-shrink-0 text-xs" style="margin-top: 2px;"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200 leading-snug">
|
||||
<strong class="font-semibold">Keep this app visible!</strong>
|
||||
<span class="block text-[10px] mt-0.5">Recording pauses if minimized or screen locked.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MIDDLE: Content Area -->
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
|
||||
<!-- STATE 1: Recording in progress — notes fill space, no accordion -->
|
||||
<template v-if="isRecording">
|
||||
<div class="flex-1 min-h-0 flex flex-col p-3 md:p-6">
|
||||
<label for="recordingNotes" class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5 flex-shrink-0">
|
||||
<i class="fas fa-pencil-alt mr-1"></i>
|
||||
Recording Notes (Markdown)
|
||||
</label>
|
||||
<div class="recording-notes-editor recording-active" style="min-height: 150px;">
|
||||
<textarea ref="recordingNotesEditor" v-model="recordingNotes"
|
||||
class="w-full h-full bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg p-2 md:p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] resize-none"
|
||||
:placeholder="t('form.notesPlaceholder')"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- STATE 2: Recording finished — accordion -->
|
||||
<template v-else-if="audioBlobURL">
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div :class="['flex flex-col border-b border-[var(--border-primary)]',
|
||||
expandedSection === 'notes' ? 'flex-1 min-h-0' : 'flex-shrink-0']">
|
||||
<!-- Header bar (always visible) -->
|
||||
<button @click="expandedSection = expandedSection === 'notes' ? 'settings' : 'notes'"
|
||||
class="flex-shrink-0 flex items-center justify-between w-full px-3 md:px-6 py-2.5
|
||||
bg-[var(--bg-tertiary)] hover:bg-[var(--bg-accent)] transition-colors">
|
||||
<span class="flex items-center gap-2 text-sm font-medium text-[var(--text-secondary)]">
|
||||
<i class="fas fa-pencil-alt text-[var(--text-muted)]"></i>
|
||||
Recording Notes
|
||||
<span v-if="recordingNotes" class="text-[10px] text-[var(--text-muted)]">(has content)</span>
|
||||
</span>
|
||||
<i :class="['fas text-[var(--text-muted)] transition-transform duration-200',
|
||||
expandedSection === 'notes' ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
|
||||
</button>
|
||||
<!-- Content (when expanded) -->
|
||||
<div v-show="expandedSection === 'notes'" class="flex-1 min-h-0 flex flex-col p-3 md:p-6">
|
||||
<div class="recording-notes-editor accordion-expanded">
|
||||
<textarea ref="recordingNotesEditor" v-model="recordingNotes"
|
||||
class="w-full h-full bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg p-2 md:p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] resize-none"
|
||||
:placeholder="t('form.notesPlaceholder')"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<div :class="['flex flex-col',
|
||||
expandedSection === 'settings' ? 'flex-1 min-h-0' : 'flex-shrink-0']">
|
||||
<!-- Header bar -->
|
||||
<button @click="expandedSection = expandedSection === 'settings' ? 'notes' : 'settings'"
|
||||
class="flex-shrink-0 flex items-center justify-between w-full px-3 md:px-6 py-2.5
|
||||
bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]
|
||||
hover:bg-[var(--bg-accent)] transition-colors">
|
||||
<span class="flex items-center gap-2 text-sm font-medium text-[var(--text-secondary)]">
|
||||
<i class="fas fa-sliders-h text-[var(--text-muted)]"></i>
|
||||
Upload Settings
|
||||
</span>
|
||||
<i :class="['fas text-[var(--text-muted)] transition-transform duration-200',
|
||||
expandedSection === 'settings' ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
|
||||
</button>
|
||||
<!-- Content (when expanded) -->
|
||||
<div v-show="expandedSection === 'settings'" class="flex-1 min-h-0 overflow-y-auto p-3 md:p-6">
|
||||
<div class="flex flex-col gap-3 md:gap-4">
|
||||
|
||||
<!-- Folder Selection (only shown if folders are enabled) -->
|
||||
<div v-if="foldersEnabled">
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5 md:mb-2">
|
||||
<i class="fas fa-folder mr-1"
|
||||
:style="{ color: selectedFolderId ? getFolderColor(selectedFolderId) : 'var(--text-muted)' }"></i>
|
||||
Folder (optional)
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select v-model="selectedFolderId"
|
||||
class="w-full pl-8 pr-8 py-2 text-sm rounded-lg cursor-pointer focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] appearance-none border border-[var(--border-secondary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] hover:bg-[var(--bg-input)] transition-colors">
|
||||
<option :value="null">No Folder</option>
|
||||
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
|
||||
${ folder.name }
|
||||
</option>
|
||||
</select>
|
||||
<i class="fas fa-folder absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none"
|
||||
:style="{ color: selectedFolderId ? getFolderColor(selectedFolderId) : 'var(--text-muted)' }"></i>
|
||||
<i class="fas fa-chevron-down absolute right-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] pointer-events-none" style="font-size: 10px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Selection -->
|
||||
<div>
|
||||
<label for="recordingTagSelect" class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5 md:mb-2">
|
||||
<i class="fas fa-tags mr-1"></i>
|
||||
Select Tags (optional, in priority order)
|
||||
</label>
|
||||
|
||||
<!-- Selected Tags Display - Compact with Drag Reorder -->
|
||||
<div v-if="selectedTags.length > 0" class="mb-2">
|
||||
<div class="p-1.5 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] max-h-16 overflow-y-auto"
|
||||
@touchmove="handleTagTouchMove">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="(tag, index) in selectedTags" :key="tag.id"
|
||||
:data-tag-index="index"
|
||||
draggable="true"
|
||||
@dragstart="handleTagDragStart(index, $event)"
|
||||
@dragover="handleTagDragOver(index, $event)"
|
||||
@drop="handleTagDrop(index, $event)"
|
||||
@dragend="handleTagDragEnd"
|
||||
@touchstart="handleTagTouchStart(index, $event)"
|
||||
@touchend="handleTagTouchEnd"
|
||||
:class="[
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded-full text-[11px] font-medium transition-all duration-150',
|
||||
draggedTagIndex === index ? 'opacity-50 cursor-grabbing' : 'cursor-grab',
|
||||
dragOverTagIndex === index && draggedTagIndex !== index ? 'ring-2 ring-[var(--ring-focus)] ring-offset-1' : ''
|
||||
]"
|
||||
:style="{ backgroundColor: tag.color, color: getContrastTextColor(tag.color) }"
|
||||
:title="(tag.group_id ? ('Group: ' + tag.group_name) : tag.name) + ' (drag to reorder)'">
|
||||
<span class="opacity-75 mr-0.5 text-[9px]">${index + 1}.</span>
|
||||
<i v-if="tag.group_id" class="fas fa-users mr-1 text-[9px]"></i>
|
||||
<span v-if="tag.group_id" class="opacity-75">${tag.group_name}: </span>
|
||||
<span>${tag.name}</span>
|
||||
<button @click.stop="removeTagFromSelection(tag.id)"
|
||||
class="ml-1 hover:opacity-100 opacity-70">
|
||||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">
|
||||
<i class="fas fa-grip-vertical mr-0.5" style="font-size: 9px;"></i>
|
||||
Drag to reorder • First tag's defaults applied
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tag Selection Container - Compact -->
|
||||
<div v-if="availableTags.filter(tag => !selectedTagIds.includes(tag.id)).length > 0"
|
||||
class="border border-[var(--border-secondary)] rounded-md bg-[var(--bg-tertiary)] p-2">
|
||||
<!-- Search Filter -->
|
||||
<div class="mb-1.5">
|
||||
<div class="relative">
|
||||
<input v-model="uploadTagSearchFilter"
|
||||
type="text"
|
||||
:placeholder="t('tagsModal.searchTags')"
|
||||
class="w-full px-2 py-1 pl-6 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]" style="font-size: 9px;"></i>
|
||||
<button v-if="uploadTagSearchFilter"
|
||||
@click="uploadTagSearchFilter = ''"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Available Tags Grid -->
|
||||
<div v-if="filteredAvailableTagsForUpload.length > 0"
|
||||
class="overflow-y-auto"
|
||||
style="max-height: 100px;">
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button v-for="tag in filteredAvailableTagsForUpload"
|
||||
:key="tag.id"
|
||||
@click="addTagToSelection(tag.id)"
|
||||
class="group flex items-center justify-between px-1.5 py-1 rounded border border-[var(--border-secondary)] hover:border-[var(--border-focus)] bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] transition-all">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<i v-if="tag.group_id" class="fas fa-users flex-shrink-0 text-[9px]"
|
||||
:style="{ color: tag.color || '#6B7280' }"></i>
|
||||
<span v-else class="w-2 h-2 rounded-full flex-shrink-0"
|
||||
:style="{ backgroundColor: tag.color || '#6B7280' }"></span>
|
||||
<span class="text-[11px] text-[var(--text-primary)] truncate">
|
||||
<span v-if="tag.group_id" class="opacity-75">${tag.group_name}: </span>${tag.name}
|
||||
</span>
|
||||
</div>
|
||||
<i class="fas fa-plus text-[var(--text-muted)] group-hover:text-[var(--text-accent)] transition-colors" style="font-size: 9px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-2 text-center">
|
||||
<p class="text-[11px] text-[var(--text-muted)]">
|
||||
<i class="fas fa-search mr-1" style="font-size: 9px;"></i>
|
||||
No matching tags
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty States -->
|
||||
<div v-else-if="availableTags.length === 0"
|
||||
class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
|
||||
<p class="text-xs text-[var(--text-muted)] text-center">
|
||||
<i class="fas fa-info-circle mr-1" style="font-size: 10px;"></i>
|
||||
<span v-text="t('help.noTagsCreated')"></span> <a href="/account#tags" class="text-[var(--text-accent)] hover:underline" v-text="t('help.createTags')"></a>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
|
||||
<p class="text-xs text-[var(--text-muted)] text-center">
|
||||
<i class="fas fa-check-circle mr-1" style="font-size: 10px;"></i>
|
||||
All tags selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedTags.some(tag => tag.custom_prompt)" class="text-xs text-[var(--text-muted)] mt-1">
|
||||
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
|
||||
Selected tags include custom summary prompts
|
||||
</p>
|
||||
<p v-if="selectedTags.length > 0 && connectorSupportsDiarization && selectedTags.some(tag => tag.default_language || tag.default_min_speakers || tag.default_max_speakers)"
|
||||
class="text-xs text-[var(--text-muted)] mt-1">
|
||||
<i class="fas fa-cog mr-1 text-[var(--text-accent)]"></i>
|
||||
First tag's ASR settings will be applied: ${selectedTags[0].name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options for diarization-enabled connectors (collapsible) -->
|
||||
<div v-if="connectorSupportsDiarization">
|
||||
<button @click="showAdvancedOptions = !showAdvancedOptions"
|
||||
class="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-tertiary)] rounded-md hover:bg-[var(--bg-accent)] transition-colors text-xs font-medium">
|
||||
<span class="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<i class="fas fa-cog text-[var(--text-muted)]"></i>
|
||||
<span v-text="t('help.advancedAsrOptions')"></span>
|
||||
</span>
|
||||
<i :class="['fas text-[var(--text-muted)]', showAdvancedOptions ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
|
||||
</button>
|
||||
|
||||
<div v-if="showAdvancedOptions" class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] space-y-3">
|
||||
<div>
|
||||
<label for="recordingAsrLanguage" class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Language
|
||||
</label>
|
||||
<select id="recordingAsrLanguage" v-model="asrLanguage"
|
||||
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
|
||||
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value" v-text="lang.label"></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Settings - only show for connectors that support min/max speakers -->
|
||||
<div v-if="connectorSupportsSpeakerCount" class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="recordingAsrMinSpeakers" class="block text-xs font-medium text-[var(--text-secondary)] mb-1" v-text="t('form.minSpeakers')">
|
||||
</label>
|
||||
<input type="number" id="recordingAsrMinSpeakers" v-model="asrMinSpeakers"
|
||||
min="1" max="20" :placeholder="t('form.auto')"
|
||||
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="recordingAsrMaxSpeakers" class="block text-xs font-medium text-[var(--text-secondary)] mb-1" v-text="t('form.maxSpeakers')">
|
||||
</label>
|
||||
<input type="number" id="recordingAsrMaxSpeakers" v-model="asrMaxSpeakers"
|
||||
min="1" max="20" :placeholder="t('form.auto')"
|
||||
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incognito Mode Toggle (only shown if feature is enabled) -->
|
||||
<div v-if="enableIncognitoMode">
|
||||
<button @click="incognitoMode = !incognitoMode"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between px-3 py-2.5 rounded-lg border transition-all duration-200',
|
||||
incognitoMode
|
||||
? 'bg-gradient-to-r from-violet-500/10 to-purple-500/10 border-violet-400/50 dark:border-violet-500/50'
|
||||
: 'bg-[var(--bg-tertiary)] border-[var(--border-secondary)] hover:border-[var(--border-focus)]'
|
||||
]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="[
|
||||
'w-8 h-8 rounded-full flex items-center justify-center transition-all',
|
||||
incognitoMode
|
||||
? 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)]'
|
||||
]">
|
||||
<i class="fas fa-user-secret text-sm"></i>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<span :class="['text-sm font-medium', incognitoMode ? 'text-violet-700 dark:text-violet-300' : 'text-[var(--text-secondary)]']">
|
||||
Incognito Mode
|
||||
</span>
|
||||
<p v-if="!incognitoMode" class="text-[10px] text-[var(--text-muted)]">
|
||||
Process without saving
|
||||
</p>
|
||||
<p v-else class="text-[10px] text-violet-600 dark:text-violet-400">
|
||||
Session only • Not saved to account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[
|
||||
'w-10 h-5 rounded-full relative transition-all duration-200',
|
||||
incognitoMode ? 'bg-gradient-to-r from-violet-500 to-purple-500' : 'bg-[var(--bg-secondary)]'
|
||||
]">
|
||||
<div :class="[
|
||||
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all duration-200',
|
||||
incognitoMode ? 'left-5' : 'left-0.5'
|
||||
]"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM: Action Buttons (always pinned) -->
|
||||
<div class="flex-shrink-0 p-3 md:p-6 bg-[var(--bg-tertiary)] border-t border-[var(--border-primary)]">
|
||||
<div v-if="isRecording" class="text-center">
|
||||
<button @click="stopRecording"
|
||||
class="px-6 md:px-8 py-2.5 md:py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors animate-pulse text-sm md:text-base">
|
||||
<i class="fas fa-stop mr-2"></i>Stop Recording
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isRecording && audioBlobURL" class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<!-- Normal Upload Button -->
|
||||
<button v-if="!incognitoMode || !enableIncognitoMode"
|
||||
@click="uploadRecordedAudio"
|
||||
class="flex-1 px-6 py-3 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-upload"></i>
|
||||
<span v-text="t('help.uploadRecordingNotes')"></span>
|
||||
</button>
|
||||
<!-- Incognito Upload Button -->
|
||||
<button v-else
|
||||
@click="uploadRecordedAudioIncognito"
|
||||
:disabled="incognitoProcessing"
|
||||
class="flex-1 px-6 py-3 bg-gradient-to-r from-violet-500 to-purple-600 text-white rounded-lg hover:from-violet-600 hover:to-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2 shadow-sm">
|
||||
<i :class="incognitoProcessing ? 'fas fa-spinner fa-spin' : 'fas fa-user-secret'"></i>
|
||||
<span v-if="incognitoProcessing">Processing...</span>
|
||||
<span v-else>Process in Incognito</span>
|
||||
</button>
|
||||
<button @click="discardRecording"
|
||||
class="px-6 py-3 bg-[var(--bg-danger)] text-white rounded-lg hover:bg-[var(--bg-danger-hover)] transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span v-text="t('help.discard')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
604
templates/components/sidebar.html
Normal file
604
templates/components/sidebar.html
Normal file
@@ -0,0 +1,604 @@
|
||||
<!-- Mobile Sidebar Backdrop -->
|
||||
<div v-if="!isSidebarCollapsed && isMobileScreen"
|
||||
@click="toggleSidebar"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden">
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside :class="['sidebar', isSidebarCollapsed ? 'collapsed' : '']">
|
||||
<div class="sidebar-content-wrapper">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="p-4 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<!-- Folder Selector (replaces static "Recording" title) -->
|
||||
<div v-if="foldersEnabled && availableFolders.length > 0" class="relative flex-1 min-w-0">
|
||||
<select v-model="filterFolder"
|
||||
class="w-full h-9 pl-8 pr-7 text-base font-semibold rounded-md cursor-pointer appearance-none border-0 bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
style="outline: none !important; box-shadow: none !important; background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 fill=%27%236B7280%27 viewBox=%270 0 16 16%27%3E%3Cpath d=%27M8 10.5l-4-4h8l-4 4z%27/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 8px center;">
|
||||
<option value="">All Recordings</option>
|
||||
<option value="none">Unfiled</option>
|
||||
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
|
||||
${ folder.name }
|
||||
</option>
|
||||
</select>
|
||||
<i class="fas fa-folder absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none text-sm"
|
||||
:style="{ color: filterFolder && filterFolder !== 'none' ? getFolderColor(filterFolder) : 'var(--text-muted)' }"></i>
|
||||
</div>
|
||||
<h2 v-else class="text-lg font-semibold flex-1" v-text="t('nav.recording')"></h2>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<!-- Selection Mode Toggle -->
|
||||
<button v-if="!selectionMode && recordings.length > 0"
|
||||
@click="enterSelectionMode"
|
||||
class="w-9 h-9 flex items-center justify-center bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-md hover:opacity-80 transition-opacity"
|
||||
title="Select multiple">
|
||||
<i class="fas fa-list-check"></i>
|
||||
</button>
|
||||
<button v-if="selectionMode"
|
||||
@click="exitSelectionMode"
|
||||
class="h-9 px-3 flex items-center bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:opacity-80 transition-opacity text-sm"
|
||||
title="Exit selection mode">
|
||||
<i class="fas fa-times mr-1"></i>Done
|
||||
</button>
|
||||
<!-- New button - compact when folders enabled -->
|
||||
<button v-if="!selectionMode && foldersEnabled && availableFolders.length > 0"
|
||||
@click="switchToUploadView"
|
||||
class="w-9 h-9 flex items-center justify-center bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors"
|
||||
title="New Recording">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<button v-else-if="!selectionMode"
|
||||
@click="switchToUploadView"
|
||||
class="h-9 px-3 flex items-center bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors text-sm">
|
||||
<i class="fas fa-plus mr-1"></i><span v-text="t('common.new')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Sort Controls -->
|
||||
<div class="space-y-3">
|
||||
<!-- Filter Toggle Button -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="showAdvancedFilters = !showAdvancedFilters"
|
||||
class="flex-1 h-7 flex items-center justify-between pl-2 pr-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-filter mr-1.5 text-[var(--text-muted)] text-[10px]"></i>
|
||||
<span v-if="!searchQuery && filterTags.length === 0 && filterSpeakers.length === 0 && !filterDatePreset && !filterDateRange.start && !filterDateRange.end && !filterTextQuery && !filterStarred && !filterInbox"
|
||||
v-text="t('sidebar.searchRecordings')">
|
||||
</span>
|
||||
<span v-else class="text-[var(--text-accent)]">
|
||||
Active filters (${ (filterTags.length > 0 ? 1 : 0) + (filterSpeakers.length > 0 ? 1 : 0) + (filterDatePreset || filterDateRange.start || filterDateRange.end ? 1 : 0) + (filterTextQuery ? 1 : 0) + (filterStarred ? 1 : 0) + (filterInbox ? 1 : 0) })
|
||||
</span>
|
||||
</span>
|
||||
<i :class="['fas fa-chevron-down transition-transform text-[var(--text-muted)] text-[10px]', showAdvancedFilters ? 'rotate-180' : '']"></i>
|
||||
</button>
|
||||
<button v-if="searchQuery || filterTags.length > 0 || filterSpeakers.length > 0 || filterDatePreset || filterDateRange.start || filterDateRange.end || filterTextQuery || filterStarred || filterInbox"
|
||||
@click="clearAllFilters"
|
||||
class="w-7 h-7 flex items-center justify-center bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
:title="t('buttons.clearAllFilters')">
|
||||
<i class="fas fa-times text-[var(--text-muted)] text-[10px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters Panel -->
|
||||
<div v-show="showAdvancedFilters" class="p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg space-y-3">
|
||||
<!-- Text Search -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1" v-text="t('sidebar.filters')"></label>
|
||||
<div class="relative">
|
||||
<input v-model="filterTextQuery"
|
||||
type="text"
|
||||
:placeholder="t('sidebar.searchRecordings')"
|
||||
class="w-full px-3 py-1.5 pl-8 pr-8 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
<i class="fas fa-search absolute left-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] text-xs"></i>
|
||||
<button v-if="filterTextQuery"
|
||||
@click="filterTextQuery = ''"
|
||||
class="absolute right-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)] text-xs"
|
||||
:title="t('buttons.clearSearchText')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Filters (Starred/Inbox) -->
|
||||
<div class="flex gap-2">
|
||||
<button @click="filterStarred = !filterStarred"
|
||||
:class="[
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all border',
|
||||
filterStarred
|
||||
? 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400'
|
||||
: 'bg-[var(--bg-input)] border-[var(--border-secondary)] text-[var(--text-muted)] hover:border-yellow-500/30 hover:text-yellow-400'
|
||||
]">
|
||||
<i class="fas fa-star" style="font-size: 10px;"></i>
|
||||
<span v-text="t('sidebar.starred')"></span>
|
||||
</button>
|
||||
<button @click="filterInbox = !filterInbox"
|
||||
:class="[
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all border',
|
||||
filterInbox
|
||||
? 'bg-blue-500/20 border-blue-500/50 text-blue-400'
|
||||
: 'bg-[var(--bg-input)] border-[var(--border-secondary)] text-[var(--text-muted)] hover:border-blue-500/30 hover:text-blue-400'
|
||||
]">
|
||||
<i class="fas fa-inbox" style="font-size: 10px;"></i>
|
||||
<span v-text="t('sidebar.inbox')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag Filter -->
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg p-2.5 border border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<label class="text-xs font-medium text-[var(--text-muted)] whitespace-nowrap" v-text="t('tags.filterByTag')"></label>
|
||||
<div class="relative flex-1 max-w-[140px]" v-if="availableTags.length > 5">
|
||||
<input v-model="filterTagSearch"
|
||||
type="text"
|
||||
:placeholder="t('tags.searchTags')"
|
||||
class="w-full px-2 py-1 pl-7 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]" style="font-size: 10px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-24 overflow-y-auto scrollbar-thin pr-1">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="tag in filteredTagsForFilter"
|
||||
:key="tag.id"
|
||||
@click="filterTags.includes(tag.id) ? filterTags = filterTags.filter(id => id !== tag.id) : filterTags.push(tag.id)"
|
||||
:class="[
|
||||
'px-2 py-1 rounded-full text-xs font-medium transition-all',
|
||||
filterTags.includes(tag.id)
|
||||
? 'bg-[var(--bg-accent)] text-[var(--text-accent)] ring-1 ring-[var(--border-accent)]'
|
||||
: 'bg-[var(--bg-input)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
|
||||
]">
|
||||
<span class="inline-block w-2 h-2 rounded-full mr-1" :style="{ backgroundColor: tag.color || '#6B7280' }"></span>
|
||||
${ tag.name }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="availableTags.length === 0" class="text-xs text-[var(--text-muted)] italic" v-text="t('tags.noTags')"></p>
|
||||
<p v-else-if="filteredTagsForFilter.length === 0" class="text-xs text-[var(--text-muted)] italic py-1" v-text="t('tags.noMatchingTags')"></p>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Filter -->
|
||||
<div v-if="availableSpeakers.length > 0" class="bg-[var(--bg-secondary)] rounded-lg p-2.5 border border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<label class="text-xs font-medium text-[var(--text-muted)] whitespace-nowrap" v-text="t('speakers.filterBySpeaker')"></label>
|
||||
<div class="relative flex-1 max-w-[140px]" v-if="availableSpeakers.length > 5">
|
||||
<input v-model="filterSpeakerSearch"
|
||||
type="text"
|
||||
:placeholder="t('speakers.searchSpeakers')"
|
||||
class="w-full px-2 py-1 pl-7 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]" style="font-size: 10px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-24 overflow-y-auto scrollbar-thin pr-1">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="speaker in filteredSpeakersForFilter"
|
||||
:key="speaker.id"
|
||||
@click="filterSpeakers.includes(speaker.name) ? filterSpeakers = filterSpeakers.filter(n => n !== speaker.name) : filterSpeakers.push(speaker.name)"
|
||||
:class="[
|
||||
'px-2 py-1 rounded-full text-xs font-medium transition-all',
|
||||
filterSpeakers.includes(speaker.name)
|
||||
? 'bg-[var(--bg-accent)] text-[var(--text-accent)] ring-1 ring-[var(--border-accent)]'
|
||||
: 'bg-[var(--bg-input)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
|
||||
]">
|
||||
<i class="fas fa-user mr-1" style="font-size: 9px;"></i>
|
||||
${ speaker.name }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="filteredSpeakersForFilter.length === 0 && filterSpeakerSearch" class="text-xs text-[var(--text-muted)] italic py-1" v-text="t('speakers.noMatchingSpeakers')"></p>
|
||||
</div>
|
||||
|
||||
<!-- Date Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1" v-text="t('sidebar.dateRange')"></label>
|
||||
<div class="grid grid-cols-3 gap-1.5 mb-2">
|
||||
<button v-for="preset in datePresetOptions"
|
||||
:key="preset.value"
|
||||
@click="filterDatePreset = filterDatePreset === preset.value ? '' : preset.value; filterDateRange = { start: '', end: '' }"
|
||||
:class="[
|
||||
'px-2 py-1 rounded-md text-xs transition-all',
|
||||
filterDatePreset === preset.value
|
||||
? 'bg-[var(--bg-accent)] text-[var(--text-accent)]'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-input)]'
|
||||
]"
|
||||
:title="preset.label">
|
||||
${ preset.label }
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<input v-model="filterDateRange.start"
|
||||
@change="filterDatePreset = ''"
|
||||
type="date"
|
||||
class="w-full px-2 py-1 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]"
|
||||
:placeholder="t('form.dateFrom')">
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="filterDateRange.end"
|
||||
@change="filterDatePreset = ''"
|
||||
type="date"
|
||||
class="w-full px-2 py-1 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]"
|
||||
:placeholder="t('form.dateTo')">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact Controls Row -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Sort Toggle -->
|
||||
<button @click="sortBy = sortBy === 'created_at' ? 'meeting_date' : 'created_at'"
|
||||
:class="[
|
||||
'flex-1 flex items-center justify-center gap-1 px-2 py-1.5 border rounded-md text-xs transition-colors',
|
||||
'bg-[var(--bg-input)] border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]'
|
||||
]"
|
||||
:title="sortBy === 'meeting_date' ? t('sidebar.sortByMeetingDate') : t('sidebar.sortByDate')">
|
||||
<i :class="['fas', sortBy === 'meeting_date' ? 'fa-calendar' : 'fa-upload']"></i>
|
||||
<span class="hidden sm:inline">Sort</span>
|
||||
<i class="fas fa-exchange-alt text-[var(--text-muted)] text-[10px]"></i>
|
||||
</button>
|
||||
|
||||
<!-- Archived Toggle (only show when audio-only deletion mode is active) -->
|
||||
<button v-if="enableArchiveToggle" @click="showArchivedRecordings = !showArchivedRecordings"
|
||||
:class="[
|
||||
'flex-1 flex items-center justify-center gap-1 px-2 py-1.5 border rounded-md text-xs transition-colors',
|
||||
showArchivedRecordings
|
||||
? 'bg-[var(--bg-accent)] border-[var(--border-accent)] text-[var(--text-accent)]'
|
||||
: 'bg-[var(--bg-input)] border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]'
|
||||
]"
|
||||
:title="t('sidebar.archivedRecordings')">
|
||||
<i class="fas fa-archive"></i>
|
||||
<span class="hidden sm:inline">Archived</span>
|
||||
<i :class="['fas text-[10px]', showArchivedRecordings ? 'fa-toggle-on' : 'fa-toggle-off']"></i>
|
||||
</button>
|
||||
|
||||
<!-- Shared Toggle -->
|
||||
<button v-if="enableInternalSharing" @click="showSharedWithMe = !showSharedWithMe"
|
||||
:class="[
|
||||
'flex-1 flex items-center justify-center gap-1 px-2 py-1.5 border rounded-md text-xs transition-colors',
|
||||
showSharedWithMe
|
||||
? 'bg-[var(--bg-accent)] border-[var(--border-accent)] text-[var(--text-accent)]'
|
||||
: 'bg-[var(--bg-input)] border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]'
|
||||
]"
|
||||
:title="t('sidebar.sharedWithMe')">
|
||||
<i class="fas fa-users"></i>
|
||||
<span class="hidden sm:inline">Shared</span>
|
||||
<i :class="['fas text-[10px]', showSharedWithMe ? 'fa-toggle-on' : 'fa-toggle-off']"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recordings List -->
|
||||
<div class="flex-1 overflow-y-auto p-4"
|
||||
@scroll="(e) => {
|
||||
const element = e.target;
|
||||
const threshold = 100; // Load more when 100px from bottom
|
||||
if (element.scrollHeight - element.scrollTop - element.clientHeight < threshold) {
|
||||
loadMoreRecordings();
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Incognito Recording (styled like a regular recording item with a subtle indicator) -->
|
||||
<div v-if="enableIncognitoMode && incognitoRecording"
|
||||
@click="selectIncognitoRecording"
|
||||
:class="[
|
||||
'group mb-3 p-3 rounded-lg cursor-pointer transition-all duration-200',
|
||||
selectedRecording?.id === 'incognito'
|
||||
? 'bg-[var(--bg-accent)] border-l-4 border-[var(--border-accent)]'
|
||||
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border-l-4 border-transparent hover:border-[var(--border-secondary)]'
|
||||
]">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-sm text-[var(--text-primary)] truncate">
|
||||
${ incognitoRecording.title || 'Incognito Recording' }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- Incognito pill badge (matches tag style with contrasting border) -->
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium bg-violet-500/15 dark:bg-violet-500/25 text-violet-700 dark:text-violet-300 ring-1 ring-violet-400/50 dark:ring-violet-500/50">
|
||||
<i class="fas fa-user-secret mr-1" style="font-size: 9px;"></i>
|
||||
Incognito
|
||||
</span>
|
||||
<!-- Duration pill -->
|
||||
<span v-if="incognitoRecording.audio_duration_seconds" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)]">
|
||||
<i class="fas fa-clock mr-1" style="font-size: 8px;"></i>
|
||||
${ Math.floor(incognitoRecording.audio_duration_seconds / 60) }:${ String(incognitoRecording.audio_duration_seconds % 60).padStart(2, '0') }
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-[var(--text-muted)] mt-1.5 opacity-70">
|
||||
<i class="fas fa-eye-slash mr-1"></i>
|
||||
Session only
|
||||
</p>
|
||||
</div>
|
||||
<button @click.stop="clearIncognitoRecordingWithConfirm"
|
||||
class="opacity-0 group-hover:opacity-100 p-1.5 text-[var(--text-muted)] hover:text-red-500 hover:bg-red-500/10 rounded transition-all"
|
||||
title="Discard">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingRecordings && recordings.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||||
<p class="mt-2 text-[var(--text-muted)]" v-text="t('help.loadingRecordings')"></p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="recordings.length === 0 && !isLoadingRecordings" class="text-center py-8">
|
||||
<i class="fas fa-microphone-slash text-3xl text-[var(--text-muted)] mb-3"></i>
|
||||
<p class="text-[var(--text-muted)]" v-text="t('sidebar.noRecordings')"></p>
|
||||
<p v-if="searchQuery.trim()" class="text-sm text-[var(--text-muted)] mt-1">
|
||||
<span v-text="t('help.tryAdjustingSearch')"></span> <button @click="clearAllFilters()" class="text-[var(--text-accent)] hover:underline" v-text="t('help.clearFilters')"></button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="recordings.length > 0" class="space-y-2">
|
||||
<!-- Selection Mode Controls -->
|
||||
<div v-if="selectionMode" class="flex items-center justify-between px-1 py-2 mb-2 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="allVisibleSelected ? clearSelection() : selectAll()"
|
||||
class="text-xs text-[var(--text-accent)] hover:underline">
|
||||
${ allVisibleSelected ? 'Clear all' : 'Select all' }
|
||||
</button>
|
||||
<span class="text-xs text-[var(--text-muted)]">
|
||||
${ selectedCount } selected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="group in groupedRecordings" :key="group.title" class="mb-6">
|
||||
<h3 class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide mb-2">
|
||||
${group.title}
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
<div v-for="recording in group.items"
|
||||
:key="recording.id"
|
||||
@click="selectionMode ? toggleSelection(recording.id) : selectRecording(recording)"
|
||||
:class="[
|
||||
'p-3 rounded-lg cursor-pointer transition-all duration-200 border-2',
|
||||
selectionMode && isSelected(recording.id)
|
||||
? 'bg-[var(--bg-accent)] border-[var(--border-accent)]'
|
||||
: selectedRecording?.id === recording.id && !selectionMode
|
||||
? 'bg-[var(--bg-accent)] border-[var(--border-accent)]'
|
||||
: recording.is_shared
|
||||
? 'bg-[var(--bg-secondary)] border-[var(--bg-tertiary)] hover:bg-[var(--bg-accent-hover)]'
|
||||
: 'bg-[var(--bg-tertiary)] border-transparent hover:bg-[var(--bg-accent-hover)]'
|
||||
]">
|
||||
<!-- Title and Status Row -->
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<!-- Checkbox for selection mode -->
|
||||
<div v-if="selectionMode" @click.stop="toggleSelection(recording.id)" class="flex-shrink-0 mr-2">
|
||||
<input type="checkbox"
|
||||
:checked="isSelected(recording.id)"
|
||||
class="selection-checkbox"
|
||||
@click.stop="toggleSelection(recording.id)">
|
||||
</div>
|
||||
<h4 class="font-medium text-sm truncate flex-1 mr-2" :class="selectedRecording?.id === recording.id && !selectionMode ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]'">
|
||||
${ recording.title || t('common.untitled') }
|
||||
</h4>
|
||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||
<!-- Combined sharing status badge -->
|
||||
<span v-if="recording.is_shared || recording.has_group_tags || recording.shared_with_count > 0 || recording.public_share_count > 0"
|
||||
class="inline-flex items-center justify-center gap-1 h-5 px-2 rounded-full text-[9px] leading-none bg-violet-100 dark:bg-violet-900/50 border border-violet-300 dark:border-violet-600"
|
||||
:title="[
|
||||
recording.is_shared ? t('sharing.sharedBy') + ' ' + (recording.owner_username || t('sharing.unknown')) : '',
|
||||
recording.has_group_tags ? t('sharing.teamRecording') : '',
|
||||
!recording.is_shared && recording.shared_with_count > 0 ? t('sharing.sharedWith') + ' ' + recording.shared_with_count + ' ' + t('sharing.users') : '',
|
||||
!recording.is_shared && recording.public_share_count > 0 ? recording.public_share_count + ' ' + t('sharing.publicLinksGenerated') : ''
|
||||
].filter(s => s).join(' • ')">
|
||||
<i v-if="recording.is_shared" class="fas fa-arrow-down text-purple-600 dark:text-purple-400"></i>
|
||||
<i v-if="recording.has_group_tags" class="fas fa-users text-blue-600 dark:text-blue-400"></i>
|
||||
<i v-if="!recording.is_shared && recording.shared_with_count > 0" class="fas fa-arrow-up text-indigo-600 dark:text-indigo-400"></i>
|
||||
<i v-if="!recording.is_shared && recording.public_share_count > 0" class="fas fa-globe text-teal-600 dark:text-teal-400"></i>
|
||||
</span>
|
||||
|
||||
<!-- Show Audio Deleted badge -->
|
||||
<span v-if="recording.audio_deleted_at"
|
||||
class="inline-flex items-center justify-center h-5 px-1.5 rounded-full text-[9px] leading-none bg-gray-200 dark:bg-gray-700 border border-gray-300 dark:border-gray-600"
|
||||
:title="t('sidebar.archived')">
|
||||
<i class="fas fa-file-audio text-gray-600 dark:text-gray-400"></i>
|
||||
</span>
|
||||
|
||||
<!-- Show Failed status for failed recordings -->
|
||||
<span v-if="recording.status === 'FAILED'"
|
||||
class="inline-flex items-center justify-center h-5 px-1.5 rounded-full text-[9px] leading-none bg-red-100 dark:bg-red-900/50 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
</span>
|
||||
|
||||
<!-- Show Processing status for non-completed recordings -->
|
||||
<span v-else-if="recording.status !== 'COMPLETED'"
|
||||
:class="getStatusClass(recording.status)"
|
||||
class="status-badge">
|
||||
${formatStatus(recording.status)}
|
||||
</span>
|
||||
|
||||
<!-- For completed recordings, show highlight and inbox badges -->
|
||||
<template v-else>
|
||||
<span v-if="recording.is_highlighted"
|
||||
@click.stop="toggleHighlight(recording)"
|
||||
class="inline-flex items-center justify-center h-5 px-1.5 rounded-full text-[9px] leading-none bg-amber-100 dark:bg-amber-900/50 border border-amber-400 dark:border-amber-600 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-800/50"
|
||||
:title="t('sidebar.removeFromHighlighted')">
|
||||
<i class="fas fa-star text-amber-600 dark:text-amber-400"></i>
|
||||
</span>
|
||||
<span v-if="recording.is_inbox"
|
||||
@click.stop="toggleInbox(recording)"
|
||||
class="inline-flex items-center justify-center h-5 px-1.5 rounded-full text-[9px] leading-none bg-blue-100 dark:bg-blue-900/50 border border-blue-400 dark:border-blue-600 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-800/50"
|
||||
:title="t('sidebar.markAsRead')">
|
||||
<i class="fas fa-inbox text-blue-600 dark:text-blue-400"></i>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date and Participants -->
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-[var(--text-muted)]">
|
||||
<!-- Date -->
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
<i class="fas fa-calendar-alt mr-1 text-[var(--text-muted)]" style="font-size: 10px;"></i>
|
||||
${formatShortDate(sortBy === 'meeting_date' ? recording.meeting_date : recording.created_at)}
|
||||
</div>
|
||||
|
||||
<!-- Participants -->
|
||||
<div v-if="recording.participants" class="flex items-center min-w-0 flex-1">
|
||||
<i class="fas fa-users mr-1 text-[var(--text-muted)] flex-shrink-0" style="font-size: 10px;"></i>
|
||||
<span class="truncate">
|
||||
${recording.participants}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="getRecordingTags(recording).length > 0 || recording.duplicate_info" class="flex flex-wrap items-center gap-1 mt-1">
|
||||
<button v-for="tag in getRecordingTags(recording).slice(0, 4)" :key="tag.id"
|
||||
@click.stop="filterByTag(tag)"
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium hover:opacity-80 transition-all cursor-pointer"
|
||||
:style="{ backgroundColor: tag.color, color: getContrastTextColor(tag.color) }"
|
||||
:title="tag.group_id ? ('Group: ' + tag.group_name) : ('Filter by ' + tag.name)">
|
||||
<i v-if="tag.group_id" class="fas fa-users mr-1" style="font-size: 9px; vertical-align: middle; line-height: 0;"></i>
|
||||
<span v-text="tag.name"></span>
|
||||
</button>
|
||||
<span v-if="getRecordingTags(recording).length > 4"
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs text-[var(--text-muted)]"
|
||||
:title="'More tags...'">
|
||||
+${getRecordingTags(recording).length - 4}
|
||||
</span>
|
||||
<button v-if="recording.duplicate_info"
|
||||
@click.stop="openDuplicatesModal(recording.duplicate_info)"
|
||||
class="text-amber-500 hover:text-amber-400 transition-colors"
|
||||
:title="recording.duplicate_info.total_copies + ' ' + (t('upload.copies') || 'copies')">
|
||||
<i class="fas fa-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More Indicator -->
|
||||
<div v-if="isLoadingMore" class="text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin text-lg text-[var(--text-muted)]"></i>
|
||||
<p class="text-sm text-[var(--text-muted)] mt-1" v-text="t('help.loadingMore')"></p>
|
||||
</div>
|
||||
|
||||
<!-- End of Results Indicator -->
|
||||
<div v-else-if="!hasNextPage && totalRecordings > 0" class="text-center py-4 text-xs text-[var(--text-muted)]">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
<span v-text="t('help.allRecordingsLoaded')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Vertical Floating Bulk Action Bar - To the right of sidebar -->
|
||||
<transition name="slide-right">
|
||||
<div v-if="selectionMode && selectedCount > 0 && !isSidebarCollapsed"
|
||||
class="fixed top-1/2 -translate-y-1/2 left-80 z-50 flex flex-col gap-1 p-2 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-r-xl shadow-lg">
|
||||
<!-- Selection count badge -->
|
||||
<div class="text-center text-xs font-medium text-[var(--text-accent)] py-1 border-b border-[var(--border-primary)] mb-1">
|
||||
${ selectedCount }
|
||||
</div>
|
||||
|
||||
<button @click="openBulkTagModal('add')"
|
||||
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
|
||||
title="Add or remove tags">
|
||||
<i class="fas fa-tags text-[var(--text-muted)]"></i>
|
||||
</button>
|
||||
|
||||
<button v-if="foldersEnabled"
|
||||
@click="showBulkFolderModal = true"
|
||||
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
|
||||
title="Move to folder">
|
||||
<i class="fas fa-folder text-emerald-500"></i>
|
||||
</button>
|
||||
|
||||
<button @click="bulkToggleInbox()"
|
||||
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
|
||||
title="Toggle inbox status">
|
||||
<i class="fas fa-inbox text-blue-500"></i>
|
||||
</button>
|
||||
|
||||
<button @click="bulkToggleHighlight()"
|
||||
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
|
||||
title="Toggle highlight">
|
||||
<i class="fas fa-star text-amber-500"></i>
|
||||
</button>
|
||||
|
||||
<button @click="openBulkReprocessModal"
|
||||
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
|
||||
title="Reprocess">
|
||||
<i class="fas fa-redo text-[var(--text-muted)]"></i>
|
||||
</button>
|
||||
|
||||
<div class="border-t border-[var(--border-primary)] my-1"></div>
|
||||
|
||||
<button @click="openBulkDeleteModal"
|
||||
class="w-10 h-10 flex items-center justify-center bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||
title="Delete selected">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
|
||||
<button @click="exitSelectionMode"
|
||||
class="w-10 h-10 flex items-center justify-center text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
|
||||
title="Exit selection mode">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Bulk Folder Assignment Modal -->
|
||||
<div v-if="showBulkFolderModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-[60]" @click.self="showBulkFolderModal = false">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl max-w-sm w-full mx-4 overflow-hidden">
|
||||
<div class="p-4 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
<i class="fas fa-folder mr-2 text-emerald-500"></i>
|
||||
Move to Folder
|
||||
</h3>
|
||||
<button @click="showBulkFolderModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-muted)] mt-1">
|
||||
${ selectedCount } recording(s) selected
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 max-h-64 overflow-y-auto">
|
||||
<!-- No Folder Option -->
|
||||
<button @click="bulkAssignFolder(null)"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left hover:bg-[var(--bg-tertiary)] transition-colors mb-2 border border-[var(--border-secondary)]">
|
||||
<i class="fas fa-folder-minus text-[var(--text-muted)]"></i>
|
||||
<span class="text-sm text-[var(--text-secondary)]">Remove from folder</span>
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div v-if="availableFolders.length > 0" class="border-t border-[var(--border-primary)] my-2"></div>
|
||||
|
||||
<!-- Folder Options -->
|
||||
<button v-for="folder in availableFolders"
|
||||
:key="folder.id"
|
||||
@click="bulkAssignFolder(folder.id)"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left hover:bg-[var(--bg-tertiary)] transition-colors mb-1"
|
||||
:style="{ borderLeft: '3px solid ' + (folder.color || '#10B981') }">
|
||||
<i class="fas fa-folder" :style="{ color: folder.color || '#10B981' }"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-[var(--text-primary)] block truncate">${ folder.name }</span>
|
||||
<span v-if="folder.group_name" class="text-xs text-[var(--text-muted)]">
|
||||
<i class="fas fa-users mr-1" style="font-size: 9px;"></i>${ folder.group_name }
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--text-muted)]">${ folder.recording_count || 0 }</span>
|
||||
</button>
|
||||
|
||||
<!-- Empty State -->
|
||||
<p v-if="availableFolders.length === 0" class="text-sm text-[var(--text-muted)] text-center py-4">
|
||||
No folders created. Create folders in your account settings.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 border-t border-[var(--border-primary)] bg-[var(--bg-tertiary)]">
|
||||
<button @click="showBulkFolderModal = false"
|
||||
class="w-full px-4 py-2 text-sm text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
17
templates/components/token_budget_indicator.html
Normal file
17
templates/components/token_budget_indicator.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!-- Token Budget Indicator Component -->
|
||||
<!-- Only shows if user has a budget limit set -->
|
||||
<div v-if="tokenBudget && tokenBudget.has_budget"
|
||||
class="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors cursor-default"
|
||||
:title="t('adminDashboard.tokenUsage') + ': ' + (tokenBudget.usage || 0).toLocaleString() + ' / ' + (tokenBudget.budget || 0).toLocaleString()">
|
||||
<i class="fas fa-coins text-[10px]"
|
||||
:style="tokenBudget.percentage >= 100 ? {color: '#ef4444'} : tokenBudget.percentage >= 80 ? {color: '#f59e0b'} : {}"></i>
|
||||
<span :style="tokenBudget.percentage >= 100 ? {color: '#ef4444'} : tokenBudget.percentage >= 80 ? {color: '#f59e0b'} : {}">${ tokenBudget.percentage }%</span>
|
||||
<div class="w-12 h-1 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-300 bg-[var(--text-accent)]"
|
||||
:style="{
|
||||
width: Math.min(tokenBudget.percentage, 100) + '%',
|
||||
backgroundColor: tokenBudget.percentage >= 100 ? '#ef4444' : tokenBudget.percentage >= 80 ? '#f59e0b' : null
|
||||
}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
404
templates/components/upload-view.html
Normal file
404
templates/components/upload-view.html
Normal file
@@ -0,0 +1,404 @@
|
||||
<!-- Upload View -->
|
||||
<div v-if="currentView === 'upload'" class="flex-1 flex items-center justify-center p-4 md:p-8 overflow-y-auto">
|
||||
<div class="max-w-lg w-full my-auto">
|
||||
<!-- Compact Header -->
|
||||
<div class="text-center mb-3">
|
||||
<h2 class="text-lg font-semibold flex items-center justify-center gap-2">
|
||||
<i class="fas fa-microphone text-[var(--text-accent)]"></i>
|
||||
<span v-text="t('upload.title')"></span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="bg-[var(--bg-secondary)] p-4 md:p-6 rounded-xl border border-[var(--border-primary)]">
|
||||
<!-- File Upload Area -->
|
||||
<div @drop="handleDrop" @dragover="handleDragOver" @dragleave="handleDragLeave" @click="$refs.fileInput.click()"
|
||||
:class="['border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all duration-300', dragover ? 'border-[var(--border-accent)] bg-[var(--bg-accent)]' : 'border-[var(--border-secondary)] hover:border-[var(--border-accent)]']">
|
||||
<i class="fas fa-cloud-upload-alt text-2xl text-[var(--text-muted)] mb-2"></i>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-1" v-text="t('upload.dropzone')"></p>
|
||||
<p class="text-xs text-[var(--text-muted)]" v-text="t('upload.supportedFormats')"></p>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" @change="handleFileSelect" accept="audio/*,video/*,.mp3,.m4a,.wav,.aac,.ogg,.flac,.wma,.aiff,.opus,.caf,.3gp,.3gpp,.amr,.mp4,.mov,.webm,.mkv,.avi,.m4v,.ts,.mts,.wmv,.flv,.mpeg,.mpg,.ogv,.vob,.asf" multiple class="hidden">
|
||||
|
||||
<!-- Queued Files Display -->
|
||||
<div v-if="pendingQueueFiles.length > 0" class="mt-3">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<h4 class="text-xs font-medium text-[var(--text-secondary)]">
|
||||
<i class="fas fa-list mr-1"></i>
|
||||
${ t('upload.filesToUpload') } (${pendingQueueFiles.length})
|
||||
</h4>
|
||||
</div>
|
||||
<div class="space-y-1 max-h-32 overflow-y-auto">
|
||||
<div v-for="item in pendingQueueFiles" :key="item.clientId"
|
||||
class="flex items-center justify-between px-2 py-1.5 bg-[var(--bg-tertiary)] rounded border border-[var(--border-secondary)]">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<i class="fas fa-file-audio text-[var(--text-muted)] text-xs"></i>
|
||||
<span class="text-xs text-[var(--text-primary)] truncate">${item.file.name}</span>
|
||||
<span class="text-[10px] text-[var(--text-muted)] flex-shrink-0">(${formatFileSize(item.file.size)})</span>
|
||||
</div>
|
||||
<button @click="removeFromQueue(item.clientId)"
|
||||
class="ml-1.5 p-0.5 text-[var(--text-muted)] hover:text-red-500 transition-colors flex-shrink-0"
|
||||
title="Remove from queue">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incognito Mode Toggle (only shown if feature is enabled via ENABLE_INCOGNITO_MODE env var) -->
|
||||
<div v-if="enableIncognitoMode" class="mt-3">
|
||||
<button @click="incognitoMode = !incognitoMode"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between px-3 py-2.5 rounded-lg border transition-all duration-200',
|
||||
incognitoMode
|
||||
? 'bg-gradient-to-r from-violet-500/10 to-purple-500/10 border-violet-400/50 dark:border-violet-500/50'
|
||||
: 'bg-[var(--bg-tertiary)] border-[var(--border-secondary)] hover:border-[var(--border-focus)]'
|
||||
]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="[
|
||||
'w-8 h-8 rounded-full flex items-center justify-center transition-all',
|
||||
incognitoMode
|
||||
? 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)]'
|
||||
]">
|
||||
<i class="fas fa-user-secret text-sm"></i>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<span :class="['text-sm font-medium', incognitoMode ? 'text-violet-700 dark:text-violet-300' : 'text-[var(--text-secondary)]']">
|
||||
${ t('incognito.mode') }
|
||||
</span>
|
||||
<p v-if="!incognitoMode" class="text-[10px] text-[var(--text-muted)]">
|
||||
${ t('incognito.processWithoutSaving') }
|
||||
</p>
|
||||
<p v-else class="text-[10px] text-violet-600 dark:text-violet-400">
|
||||
${ t('incognito.sessionOnly') } • ${ t('incognito.notSavedToAccount') }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[
|
||||
'w-10 h-5 rounded-full relative transition-all duration-200',
|
||||
incognitoMode ? 'bg-gradient-to-r from-violet-500 to-purple-500' : 'bg-[var(--bg-secondary)]'
|
||||
]">
|
||||
<div :class="[
|
||||
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all duration-200',
|
||||
incognitoMode ? 'left-5' : 'left-0.5'
|
||||
]"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Buttons -->
|
||||
<div class="mt-2 space-y-2">
|
||||
<!-- Normal Upload Button (when not in incognito mode or feature disabled) -->
|
||||
<button v-if="!incognitoMode || !enableIncognitoMode"
|
||||
@click="startUpload"
|
||||
class="w-full px-4 py-2.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors flex items-center justify-center gap-2 font-medium text-sm">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<span v-text="t('upload.uploadNFiles', { count: pendingQueueFiles.length })"></span>
|
||||
</button>
|
||||
|
||||
<!-- Incognito Upload Button (only when feature enabled and mode selected) -->
|
||||
<button v-else-if="enableIncognitoMode && incognitoMode"
|
||||
@click="startIncognitoUpload"
|
||||
:disabled="incognitoProcessing || pendingQueueFiles.length !== 1"
|
||||
class="w-full px-4 py-2.5 bg-gradient-to-r from-violet-500 to-purple-600 text-white rounded-lg hover:from-violet-600 hover:to-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2 font-medium text-sm shadow-sm">
|
||||
<i :class="incognitoProcessing ? 'fas fa-spinner fa-spin' : 'fas fa-user-secret'"></i>
|
||||
<span v-if="incognitoProcessing">${ t('incognito.processing') }</span>
|
||||
<span v-else-if="pendingQueueFiles.length !== 1">${ t('incognito.selectExactlyOneFile') }</span>
|
||||
<span v-else>${ t('incognito.processInIncognito') }</span>
|
||||
</button>
|
||||
|
||||
<p v-if="enableIncognitoMode && incognitoMode && pendingQueueFiles.length > 1" class="text-xs text-violet-600 dark:text-violet-400 text-center">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
${ t('incognito.oneFileAtATime') }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider (hidden when files are selected) -->
|
||||
<div v-if="pendingQueueFiles.length === 0" class="my-4 flex items-center">
|
||||
<div class="flex-grow border-t border-[var(--border-secondary)]"></div>
|
||||
<span class="flex-shrink mx-3 text-[10px] text-[var(--text-muted)] uppercase" v-text="t('common.or')"></span>
|
||||
<div class="flex-grow border-t border-[var(--border-secondary)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Recording Options (hidden when files are selected) -->
|
||||
<div v-if="pendingQueueFiles.length === 0" class="space-y-2">
|
||||
<h3 class="text-xs font-medium text-[var(--text-secondary)] text-center" v-text="t('recording.title')"></h3>
|
||||
|
||||
<!-- Microphone Recording -->
|
||||
<button @click="startRecording('microphone')"
|
||||
class="w-full px-3 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2 text-sm">
|
||||
<i class="fas fa-microphone"></i>
|
||||
<span v-text="t('recording.microphone')"></span>
|
||||
</button>
|
||||
|
||||
<!-- System Audio and Both - Side by Side -->
|
||||
<div v-if="canRecordSystemAudio" class="grid grid-cols-2 gap-2">
|
||||
<!-- System Audio Recording -->
|
||||
<button @click="startRecording('system')"
|
||||
class="px-3 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-1.5 text-sm">
|
||||
<i class="fas fa-desktop"></i>
|
||||
<span v-text="t('recording.systemAudio')"></span>
|
||||
</button>
|
||||
|
||||
<!-- Both Audio Sources -->
|
||||
<button @click="startRecording('both')"
|
||||
class="px-3 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-1 text-sm">
|
||||
<i class="fas fa-microphone"></i>
|
||||
<i class="fas fa-desktop"></i>
|
||||
<span class="hidden sm:inline" v-text="t('recording.microphoneAndSystem')"></span>
|
||||
<span class="sm:hidden">${ t('recording.micPlusSys') }</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Help Text - Compact -->
|
||||
<div class="text-[10px] text-[var(--text-muted)] space-y-0.5 mt-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span><strong v-text="t('recording.microphone') + ':'"></strong> <span v-text="t('help.microphoneDesc')"></span></span>
|
||||
<i class="fas fa-microphone text-red-500 flex-shrink-0 ml-1"></i>
|
||||
</div>
|
||||
<div v-if="canRecordSystemAudio" class="flex items-center justify-between">
|
||||
<span><strong v-text="t('recording.systemAudio') + ':'"></strong> <span v-text="t('help.systemAudioDesc')"></span></span>
|
||||
<i class="fas fa-desktop text-blue-500 flex-shrink-0 ml-1"></i>
|
||||
</div>
|
||||
<div v-if="canRecordSystemAudio" class="flex items-center justify-between">
|
||||
<span><strong v-text="t('recording.microphoneAndSystem') + ':'"></strong> <span v-text="t('help.bothAudioDesc')"></span></span>
|
||||
<div class="flex items-center gap-0.5 flex-shrink-0 ml-1">
|
||||
<i class="fas fa-microphone text-purple-500"></i>
|
||||
<i class="fas fa-desktop text-purple-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!canRecordSystemAudio" class="text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
${ t('recording.systemAudioNotSupported') }
|
||||
<button @click="showSystemAudioHelp = true" class="ml-1 text-blue-500 hover:text-blue-600 underline">
|
||||
<span v-text="t('buttons.help')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Selection (only shown if folders are enabled) -->
|
||||
<div v-if="foldersEnabled" class="mt-4">
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
<i class="fas fa-folder mr-1"
|
||||
:style="{ color: selectedFolderId ? getFolderColor(selectedFolderId) : 'var(--text-muted)' }"></i>
|
||||
${ t('form.folder') }
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select v-model="selectedFolderId"
|
||||
class="w-full pl-8 pr-8 py-2 text-sm rounded-lg cursor-pointer focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] appearance-none border border-[var(--border-secondary)] bg-[var(--bg-tertiary)] text-[var(--text-primary)] hover:bg-[var(--bg-input)] transition-colors">
|
||||
<option :value="null">No Folder</option>
|
||||
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
|
||||
${ folder.name }
|
||||
</option>
|
||||
</select>
|
||||
<i class="fas fa-folder absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none"
|
||||
:style="{ color: selectedFolderId ? getFolderColor(selectedFolderId) : 'var(--text-muted)' }"></i>
|
||||
<i class="fas fa-chevron-down absolute right-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] pointer-events-none" style="font-size: 10px;"></i>
|
||||
</div>
|
||||
<p v-if="selectedFolderId && getFolderById(selectedFolderId)?.custom_prompt" class="text-xs text-[var(--text-muted)] mt-1">
|
||||
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
|
||||
${ t('help.folderHasCustomPrompt') }
|
||||
</p>
|
||||
<p v-if="availableFolders.length === 0" class="text-xs text-[var(--text-muted)] mt-1">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
<a href="/account#folders" class="text-[var(--text-accent)] hover:underline">${ t('help.createFolders') }</a> ${ t('help.toOrganizeRecordings') }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tag Selection -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
<i class="fas fa-tags mr-1 text-[var(--text-muted)]"></i>
|
||||
${ t('tags.title') }
|
||||
</label>
|
||||
|
||||
<!-- Selected Tags Display - Compact with Drag Reorder -->
|
||||
<div v-if="selectedTags.length > 0" class="mb-2">
|
||||
<div class="p-1.5 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] max-h-16 overflow-y-auto"
|
||||
@touchmove="handleTagTouchMove">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="(tag, index) in selectedTags" :key="tag.id"
|
||||
:data-tag-index="index"
|
||||
draggable="true"
|
||||
@dragstart="handleTagDragStart(index, $event)"
|
||||
@dragover="handleTagDragOver(index, $event)"
|
||||
@drop="handleTagDrop(index, $event)"
|
||||
@dragend="handleTagDragEnd"
|
||||
@touchstart="handleTagTouchStart(index, $event)"
|
||||
@touchend="handleTagTouchEnd"
|
||||
:class="[
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded-full text-[11px] font-medium transition-all duration-150',
|
||||
draggedTagIndex === index ? 'opacity-50 cursor-grabbing' : 'cursor-grab',
|
||||
dragOverTagIndex === index && draggedTagIndex !== index ? 'ring-2 ring-[var(--ring-focus)] ring-offset-1' : ''
|
||||
]"
|
||||
:style="{ backgroundColor: tag.color, color: getContrastTextColor(tag.color) }"
|
||||
:title="(tag.group_id ? ('Group: ' + tag.group_name) : tag.name) + ' (drag to reorder)'">
|
||||
<span class="opacity-75 mr-0.5 text-[9px]">${index + 1}.</span>
|
||||
<i v-if="tag.group_id" class="fas fa-users mr-1 text-[9px]"></i>
|
||||
<span v-if="tag.group_id" class="opacity-75">${tag.group_name}: </span>
|
||||
<span>${tag.name}</span>
|
||||
<button @click.stop="removeTagFromSelection(tag.id)"
|
||||
class="ml-1 hover:opacity-100 opacity-70">
|
||||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">
|
||||
<i class="fas fa-grip-vertical mr-0.5" style="font-size: 9px;"></i>
|
||||
${ t('help.dragToReorder') } • ${ t('help.firstTagDefaultsApplied') }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tag Selection Container - Compact -->
|
||||
<div v-if="availableTags.filter(tag => !selectedTagIds.includes(tag.id)).length > 0"
|
||||
class="border border-[var(--border-secondary)] rounded-md bg-[var(--bg-tertiary)] p-2">
|
||||
<!-- Search Filter (always visible if tags available) -->
|
||||
<div class="mb-1.5">
|
||||
<div class="relative">
|
||||
<input v-model="uploadTagSearchFilter"
|
||||
type="text"
|
||||
:placeholder="t('tagsModal.searchTags')"
|
||||
class="w-full px-2 py-1 pl-6 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]" style="font-size: 9px;"></i>
|
||||
<button v-if="uploadTagSearchFilter"
|
||||
@click="uploadTagSearchFilter = ''"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Tags Grid - Fixed Height -->
|
||||
<div v-if="filteredAvailableTagsForUpload.length > 0"
|
||||
class="overflow-y-auto"
|
||||
style="max-height: 100px;">
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button v-for="tag in filteredAvailableTagsForUpload"
|
||||
:key="tag.id"
|
||||
@click="addTagToSelection(tag.id)"
|
||||
class="group flex items-center justify-between px-1.5 py-1 rounded border border-[var(--border-secondary)] hover:border-[var(--border-focus)] bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] transition-all">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<i v-if="tag.group_id" class="fas fa-users flex-shrink-0" style="font-size: 9px;" :style="{ color: tag.color || '#6B7280' }"></i>
|
||||
<span v-else class="w-2 h-2 rounded-full flex-shrink-0"
|
||||
:style="{ backgroundColor: tag.color || '#6B7280' }"></span>
|
||||
<span class="text-[11px] text-[var(--text-primary)] truncate">
|
||||
<span v-if="tag.group_id" class="opacity-75">${tag.group_name}: </span>${tag.name}
|
||||
</span>
|
||||
</div>
|
||||
<i class="fas fa-plus text-[var(--text-muted)] group-hover:text-[var(--text-accent)] transition-colors" style="font-size: 9px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-2 text-center">
|
||||
<p class="text-[11px] text-[var(--text-muted)]">
|
||||
<i class="fas fa-search mr-1" style="font-size: 9px;"></i>
|
||||
${ t('help.noMatchingTags') }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty States -->
|
||||
<div v-else-if="availableTags.length === 0"
|
||||
class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
|
||||
<p class="text-xs text-[var(--text-muted)] text-center">
|
||||
<i class="fas fa-info-circle mr-1" style="font-size: 10px;"></i>
|
||||
<span v-text="t('help.noTagsCreated')"></span> <a href="/account#tags" class="text-[var(--text-accent)] hover:underline" v-text="t('help.createTags')"></a>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
|
||||
<p class="text-xs text-[var(--text-muted)] text-center">
|
||||
<i class="fas fa-check-circle mr-1" style="font-size: 10px;"></i>
|
||||
${ t('help.allTagsSelected') }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedTags.some(tag => tag.custom_prompt)" class="text-xs text-[var(--text-muted)] mt-1">
|
||||
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
|
||||
${ t('help.selectedTagsCustomPrompts') }
|
||||
</p>
|
||||
<p v-if="selectedTags.length > 0 && connectorSupportsDiarization && selectedTags.some(tag => tag.default_language || tag.default_min_speakers || tag.default_max_speakers || tag.default_hotwords || tag.default_initial_prompt)"
|
||||
class="text-xs text-[var(--text-muted)] mt-1">
|
||||
<i class="fas fa-cog mr-1 text-[var(--text-accent)]"></i>
|
||||
${ t('help.firstTagAsrSettings') } ${selectedTags[0].name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options for diarization-enabled connectors (collapsible) -->
|
||||
<div v-if="connectorSupportsDiarization" class="mt-4">
|
||||
<button @click="showAdvancedOptions = !showAdvancedOptions"
|
||||
class="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-tertiary)] rounded-md hover:bg-[var(--bg-accent)] transition-colors text-xs font-medium">
|
||||
<span class="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<i class="fas fa-cog text-[var(--text-muted)]"></i>
|
||||
<span v-text="t('help.advancedAsrOptions')"></span>
|
||||
</span>
|
||||
<i :class="['fas text-[var(--text-muted)]', showAdvancedOptions ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
|
||||
</button>
|
||||
|
||||
<div v-show="showAdvancedOptions" class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md space-y-3 border border-[var(--border-secondary)]">
|
||||
<!-- Language Selection -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
${ t('form.transcriptionLanguage') }
|
||||
</label>
|
||||
<select v-model="uploadLanguage"
|
||||
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value" v-text="lang.label"></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Settings - only show for connectors that support min/max speakers -->
|
||||
<div v-if="connectorSupportsSpeakerCount" class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1" v-text="t('form.minSpeakers')">
|
||||
</label>
|
||||
<input v-model.number="uploadMinSpeakers"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1" v-text="t('form.maxSpeakers')">
|
||||
</label>
|
||||
<input v-model.number="uploadMaxSpeakers"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hotwords -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
${ t('form.hotwords') }
|
||||
</label>
|
||||
<input v-model="uploadHotwords"
|
||||
type="text"
|
||||
:placeholder="t('form.hotwordsPlaceholder')"
|
||||
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">${ t('form.hotwordsHelp') }</p>
|
||||
</div>
|
||||
|
||||
<!-- Initial Prompt -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
${ t('form.initialPrompt') }
|
||||
</label>
|
||||
<textarea v-model="uploadInitialPrompt"
|
||||
rows="2"
|
||||
:placeholder="t('form.initialPromptPlaceholder')"
|
||||
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] resize-y"></textarea>
|
||||
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">${ t('form.initialPromptHelp') }</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-[var(--text-muted)] pt-1">
|
||||
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
|
||||
${ t('upload.settingsApplyToAll') }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
1617
templates/group-admin.html
Normal file
1617
templates/group-admin.html
Normal file
File diff suppressed because it is too large
Load Diff
69
templates/includes/loading_overlay.html
Normal file
69
templates/includes/loading_overlay.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!-- Loading Overlay - Prevents FOUC -->
|
||||
<style>
|
||||
/* Inline critical loading styles for instant rendering */
|
||||
.app-loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #1a1b26;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
.app-loading-overlay.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-loading-content {
|
||||
text-align: center;
|
||||
}
|
||||
.app-loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #7aa2f7;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.app-loading-text {
|
||||
color: #a0a0b0;
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
body.app-loading { overflow: hidden; }
|
||||
body.app-loading > *:not(.app-loading-overlay) { opacity: 0; }
|
||||
body.light .app-loading-overlay { background: #ffffff; }
|
||||
body.light .app-loading-spinner {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
border-top-color: #3b82f6;
|
||||
}
|
||||
body.light .app-loading-text { color: #6b7280; }
|
||||
</style>
|
||||
<script>
|
||||
// Immediate loading overlay initialization
|
||||
(function() {
|
||||
function initLoadingOverlay() {
|
||||
if (!document.body) {
|
||||
// Body not ready yet, wait a bit
|
||||
setTimeout(initLoadingOverlay, 10);
|
||||
return;
|
||||
}
|
||||
document.body.classList.add('app-loading');
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'app-loading-overlay';
|
||||
overlay.innerHTML = '<div class="app-loading-content"><div class="app-loading-spinner"></div><div class="app-loading-text">Loading DictIA...</div></div>';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
initLoadingOverlay();
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/loading.js') }}"></script>
|
||||
171
templates/index.html
Normal file
171
templates/index.html
Normal file
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>DictIA - Transcription Audio par IA</title>
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/js/vue.global.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/js/marked.min.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/easymde.min.css') }}">
|
||||
<script src="{{ url_for('static', filename='vendor/js/easymde.min.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='img/icon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='img/icon-16x16.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='img/icon-180x180.png') }}">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
maxHeight: {
|
||||
'85vh': '85vh',
|
||||
'90vh': '90vh'
|
||||
},
|
||||
colors: {
|
||||
primary: 'var(--bg-primary)',
|
||||
secondary: 'var(--bg-secondary)',
|
||||
accent: 'var(--bg-accent)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] transition-colors duration-300">
|
||||
<div id="loader" class="fixed inset-0 bg-gray-800 flex items-center justify-center z-50">
|
||||
<div class="text-white text-lg">Loading...</div>
|
||||
</div>
|
||||
<div id="app" v-cloak
|
||||
data-use-asr-endpoint="{{ use_asr_endpoint }}"
|
||||
data-connector-supports-diarization="{{ connector_supports_diarization }}"
|
||||
data-connector-supports-speaker-count="{{ connector_supports_speaker_count }}"
|
||||
data-enable-archive-toggle="{{ enable_archive_toggle }}"
|
||||
data-enable-internal-sharing="{{ enable_internal_sharing }}"
|
||||
data-current-user-name="{{ (current_user.name or current_user.username) if current_user.is_authenticated else '' }}"
|
||||
data-user-language="{{ user_language }}"
|
||||
class="h-full flex flex-col opacity-0 transition-opacity duration-500">
|
||||
|
||||
<!-- Header -->
|
||||
{% include 'components/header.html' %}
|
||||
|
||||
<div class="flex-1 flex overflow-hidden relative">
|
||||
<!-- Mobile Sidebar Backdrop -->
|
||||
<div v-if="!isSidebarCollapsed && isMobileScreen"
|
||||
@click="toggleSidebar"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden">
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
{% include 'components/sidebar.html' %}
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main :class="['flex-1 flex flex-col overflow-hidden main-content', !isSidebarCollapsed && !isMobileScreen ? 'sidebar-open' : '']">
|
||||
<!-- Custom Banner -->
|
||||
{% include 'components/banner.html' %}
|
||||
<!-- Upload View -->
|
||||
{% include 'components/upload-view.html' %}
|
||||
|
||||
<!-- Recording View -->
|
||||
{% include 'components/recording-view.html' %}
|
||||
|
||||
<!-- Detail View -->
|
||||
{% include 'components/detail-view.html' %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress Popup -->
|
||||
{% include 'components/progress-popup.html' %}
|
||||
|
||||
<!-- Modals -->
|
||||
{% include 'modals/edit-modal.html' %}
|
||||
{% include 'modals/delete-modal.html' %}
|
||||
{% include 'modals/edit-tags-modal.html' %}
|
||||
{% include 'modals/datetime-picker-modal.html' %}
|
||||
{% include 'modals/shares-list-modal.html' %}
|
||||
{% include 'modals/reset-modal.html' %}
|
||||
{% include 'modals/reprocess-modal.html' %}
|
||||
{% include 'modals/speaker-modal.html' %}
|
||||
{% include 'modals/add-speaker-modal.html' %}
|
||||
{% include 'modals/edit-text-modal.html' %}
|
||||
{% include 'modals/text-editor-modal.html' %}
|
||||
{% include 'modals/asr-editor-modal.html' %}
|
||||
{% include 'modals/edit-speakers-modal.html' %}
|
||||
{% include 'modals/edit-participants-modal.html' %}
|
||||
{% include 'modals/color-scheme-modal.html' %}
|
||||
{% include 'modals/system-audio-help-modal.html' %}
|
||||
{% include 'modals/recording-disclaimer-modal.html' %}
|
||||
{% include 'modals/upload-disclaimer-modal.html' %}
|
||||
{% include 'modals/recording-recovery-modal.html' %}
|
||||
{% include 'modals/unified-share-modal.html' %}
|
||||
{% include 'modals/share-delete-modal.html' %}
|
||||
{% include 'modals/duplicates-modal.html' %}
|
||||
{% include 'modals/global-error.html' %}
|
||||
{% include 'modals/toast-container.html' %}
|
||||
|
||||
<!-- Bulk Operations Modals -->
|
||||
{% include 'modals/bulk-delete-modal.html' %}
|
||||
{% include 'modals/bulk-tag-modal.html' %}
|
||||
{% include 'modals/bulk-reprocess-modal.html' %}
|
||||
|
||||
<!-- DictIA Footer — Loi 25 & AGPL-3.0 -->
|
||||
<footer class="text-center py-3 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)] flex-shrink-0">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- CSRF Token Management -->
|
||||
<script src="{{ url_for('static', filename='js/csrf-refresh.js') }}"></script>
|
||||
|
||||
<!-- i18n System -->
|
||||
<script src="{{ url_for('static', filename='js/i18n.js') }}"></script>
|
||||
|
||||
<!-- Initialize i18n immediately with fallback -->
|
||||
<script>
|
||||
// Ensure i18n is initialized before Vue app
|
||||
(async function() {
|
||||
const startTime = Date.now();
|
||||
const maxWait = 3000; // Max 3 seconds for i18n to load
|
||||
|
||||
try {
|
||||
if (window.i18n && window.i18n.init) {
|
||||
const userLang = localStorage.getItem('preferredLanguage') || '{{ user_language }}' || 'en';
|
||||
|
||||
// Race between i18n init and timeout
|
||||
await Promise.race([
|
||||
window.i18n.init(userLang),
|
||||
new Promise((resolve) => setTimeout(resolve, maxWait))
|
||||
]);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log('i18n initialized in', elapsed, 'ms');
|
||||
} else {
|
||||
console.warn('i18n not available, continuing without translations');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize i18n:', error);
|
||||
// Continue anyway - app will use fallback translations
|
||||
}
|
||||
|
||||
// Hide initial loader after i18n attempt
|
||||
const loader = document.getElementById('loader');
|
||||
if (loader) {
|
||||
loader.style.display = 'none';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Service Worker Registration handled by app.modular.js -->
|
||||
|
||||
<!-- Vue.js Application Script -->
|
||||
<script type="module" src="{{ url_for('static', filename='js/app.modular.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
969
templates/inquire.html
Normal file
969
templates/inquire.html
Normal file
@@ -0,0 +1,969 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>Inquire Mode - DictIA</title>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/vue.global.js') }}"></script>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/marked.min.js') }}"></script>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<script src="{{ url_for('static', filename='js/i18n.js') }}"></script>
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='img/icon-32x32.png') }}">
|
||||
|
||||
<!-- Loading overlay to prevent FOUC -->
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: 'var(--bg-primary)',
|
||||
secondary: 'var(--bg-secondary)',
|
||||
accent: 'var(--bg-accent)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Custom checkbox styling for better visual feedback */
|
||||
.filter-checkbox {
|
||||
appearance: none;
|
||||
background-color: var(--bg-input);
|
||||
border: 2px solid var(--border-secondary);
|
||||
border-radius: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-checkbox:checked {
|
||||
background-color: var(--text-accent);
|
||||
border-color: var(--text-accent);
|
||||
}
|
||||
|
||||
.filter-checkbox:checked::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-checkbox:focus {
|
||||
outline: 2px solid var(--text-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.filter-checkbox:hover {
|
||||
border-color: var(--text-accent);
|
||||
}
|
||||
|
||||
/* Enhanced markdown styling for Inquire Mode chat messages */
|
||||
.chat-message .prose h1,
|
||||
.chat-message .prose h2,
|
||||
.chat-message .prose h3,
|
||||
.chat-message .prose h4 {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chat-message .prose h1 { font-size: 1.25em; }
|
||||
.chat-message .prose h2 { font-size: 1.15em; }
|
||||
.chat-message .prose h3 { font-size: 1.05em; }
|
||||
|
||||
.chat-message .prose p {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-message .prose ul,
|
||||
.chat-message .prose ol {
|
||||
color: var(--text-primary);
|
||||
margin: 0.75em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.chat-message .prose li {
|
||||
color: var(--text-primary);
|
||||
margin: 0.25em 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-message .prose strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-message .prose ul { list-style-type: disc; }
|
||||
.chat-message .prose ol { list-style-type: decimal; }
|
||||
|
||||
.chat-message .prose code {
|
||||
background-color: var(--bg-accent);
|
||||
color: var(--text-accent);
|
||||
padding: 0.15em 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] transition-colors duration-300">
|
||||
<div id="inquire-app" v-cloak class="h-full flex flex-col"
|
||||
data-current-user-name="{{ (current_user.name or current_user.username) if current_user.is_authenticated else '' }}">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] px-3 sm:px-4 py-3 flex items-center justify-between flex-shrink-0 z-50">
|
||||
<!-- Left side: Menu and logo -->
|
||||
<div class="flex items-center gap-2 sm:gap-4 min-w-0 flex-shrink">
|
||||
<!-- Mobile Sidebar Toggle -->
|
||||
<button @click="isMobileSidebarOpen = !isMobileSidebarOpen"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200 lg:hidden flex-shrink-0 flex items-center justify-center">
|
||||
<i class="fas fa-bars text-lg"></i>
|
||||
</button>
|
||||
|
||||
<!-- Logo and Title (clickable to go back) -->
|
||||
<a href="/" class="flex items-center gap-2 sm:gap-3 min-w-0 hover:opacity-80 transition-opacity">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="w-6 h-6 sm:w-8 sm:h-8 flex-shrink-0">
|
||||
<h1 class="text-lg sm:text-xl font-bold text-[var(--text-primary)] truncate">DictIA</h1>
|
||||
<span class="text-xs sm:text-sm text-[var(--text-muted)] hidden sm:inline">• Inquire Mode</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Right side: User menu -->
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
{% include 'components/token_budget_indicator.html' %}
|
||||
|
||||
<!-- New Recording Button -->
|
||||
<a href="/"
|
||||
class="px-3 py-1.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm flex items-center gap-2">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span class="hidden sm:inline">${t('nav.newRecording')}</span>
|
||||
</a>
|
||||
|
||||
<!-- User menu -->
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="relative">
|
||||
<button @click="isUserMenuOpen = !isUserMenuOpen"
|
||||
data-user-menu-toggle
|
||||
class="flex items-center gap-1 sm:gap-2 p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
|
||||
:title="t('admin.userMenu')">
|
||||
<i class="fas fa-user-circle text-lg"></i>
|
||||
<span class="hidden lg:inline text-sm">{{ (current_user.name or current_user.username) if current_user.is_authenticated else 'User' }}</span>
|
||||
<i class="fas fa-chevron-down text-xs hidden sm:inline"></i>
|
||||
</button>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<div v-if="isUserMenuOpen"
|
||||
data-user-menu-dropdown
|
||||
class="absolute right-0 mt-2 w-56 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-50">
|
||||
<a href="/" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-home mr-2 w-4 text-center"></i><span v-text="t('nav.home')"></span>
|
||||
</a>
|
||||
<a href="/account" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-cog mr-2 w-4 text-center"></i><span v-text="t('nav.settings')"></span>
|
||||
</a>
|
||||
<a href="/account#tags" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-tags mr-2 w-4 text-center"></i><span v-text="t('help.tagManagement')"></span>
|
||||
</a>
|
||||
<button @click="openSharesList" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||||
<i class="fas fa-share-alt mr-2 w-4 text-center"></i><span v-text="t('modal.sharedTranscripts')"></span>
|
||||
</button>
|
||||
{% if current_user.is_admin or is_group_admin %}
|
||||
<a href="/admin" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-{% if current_user.is_admin %}user-shield{% else %}users-cog{% endif %} mr-2 w-4 text-center"></i>
|
||||
<span v-text="{% if current_user.is_admin %}t('admin.title'){% else %}t('nav.groupManagement'){% endif %}"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="border-t border-[var(--border-primary)] my-1"></div>
|
||||
<button @click="toggleDarkMode" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||||
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'" class="mr-2 w-4 text-center"></i>
|
||||
<span v-text="isDarkMode ? t('nav.lightMode') : t('nav.darkMode')"></span>
|
||||
</button>
|
||||
<button @click="openColorSchemeModal" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||||
<i class="fas fa-palette mr-2 w-4 text-center"></i>
|
||||
<span v-text="t('modal.colorScheme')"></span>
|
||||
</button>
|
||||
<div class="border-t border-[var(--border-primary)] my-1"></div>
|
||||
<a href="/logout" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-sign-out-alt mr-2 w-4 text-center"></i><span v-text="t('nav.signOut')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex overflow-hidden relative">
|
||||
<!-- Mobile Sidebar Overlay -->
|
||||
<div v-if="isMobileSidebarOpen"
|
||||
@click="isMobileSidebarOpen = false"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"></div>
|
||||
|
||||
<!-- Left Panel: Filters -->
|
||||
<div class="w-80 bg-[var(--bg-secondary)] border-r border-[var(--border-primary)] flex flex-col flex-shrink-0 transition-transform duration-300 ease-in-out z-50"
|
||||
:class="[
|
||||
'lg:relative lg:translate-x-0',
|
||||
isMobileSidebarOpen ? 'fixed inset-y-0 left-0 translate-x-0' : 'fixed inset-y-0 left-0 -translate-x-full lg:translate-x-0'
|
||||
]">
|
||||
<!-- Mobile close button -->
|
||||
<div class="lg:hidden flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
||||
<h2 class="text-lg font-bold">${t('inquire.filters')}</h2>
|
||||
<button @click="isMobileSidebarOpen = false"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between lg:block">
|
||||
<h2 class="text-lg font-bold hidden lg:block">${t('inquire.filters')}</h2>
|
||||
<button @click="clearInquireFilters" class="text-xs px-3 py-1 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors flex items-center gap-1 w-full lg:w-auto justify-center">
|
||||
<i class="fas fa-times"></i> ${t('inquire.clearAll')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag Filters -->
|
||||
<div class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-3">
|
||||
<h3 class="font-medium mb-2 flex items-center text-[var(--text-primary)]">
|
||||
<i class="fas fa-tags mr-2 text-[var(--text-accent)]"></i>${t('inquire.tags')}
|
||||
</h3>
|
||||
<!-- Tag search input -->
|
||||
<div class="mb-3">
|
||||
<input type="text" v-model="tagSearchQuery" :placeholder="t('form.searchTags')"
|
||||
class="w-full px-2 py-1 text-sm bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:border-[var(--border-focus)] focus:ring-1 focus:ring-[var(--border-focus)] transition-colors">
|
||||
</div>
|
||||
<div class="space-y-1 max-h-40 overflow-y-auto custom-scrollbar">
|
||||
<label v-for="tag in filteredTags" :key="tag.id" class="flex items-center cursor-pointer hover:bg-[var(--bg-tertiary)] p-2 rounded transition-colors group">
|
||||
<input type="checkbox" :value="tag.id" v-model="inquireFilters.selectedTags"
|
||||
class="filter-checkbox">
|
||||
<span class="ml-2 text-sm font-medium flex-1" :style="{color: tag.color}">${tag.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded-full">${tag.recording_count}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Filters -->
|
||||
<div class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-3">
|
||||
<h3 class="font-medium mb-2 flex items-center text-[var(--text-primary)]">
|
||||
<i class="fas fa-users mr-2 text-[var(--text-accent)]"></i>${t('inquire.speakers')}
|
||||
</h3>
|
||||
<div v-if="availableFilters.speakers.length > 0">
|
||||
<!-- Speaker search input -->
|
||||
<div class="mb-3">
|
||||
<input type="text" v-model="speakerSearchQuery" :placeholder="t('form.searchSpeakers')"
|
||||
class="w-full px-2 py-1 text-sm bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:border-[var(--border-focus)] focus:ring-1 focus:ring-[var(--border-focus)] transition-colors">
|
||||
</div>
|
||||
<div class="space-y-1 max-h-40 overflow-y-auto custom-scrollbar">
|
||||
<label v-for="speaker in filteredSpeakers" :key="speaker" class="flex items-center cursor-pointer hover:bg-[var(--bg-tertiary)] p-2 rounded transition-colors group">
|
||||
<input type="checkbox" :value="speaker" v-model="inquireFilters.selectedSpeakers"
|
||||
class="filter-checkbox">
|
||||
<span class="ml-2 text-sm text-[var(--text-primary)] flex-1">${speaker}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Speakers Message -->
|
||||
<div v-if="availableFilters.speakers.length === 0" class="text-center py-3">
|
||||
<i class="fas fa-users text-xl text-[var(--text-muted)] mb-2"></i>
|
||||
<p class="text-sm text-[var(--text-muted)]">${t('inquire.noSpeakerData')}</p>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">${t('inquire.speakerRequirement')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filters -->
|
||||
<div class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-3">
|
||||
<h3 class="font-medium mb-2 flex items-center text-[var(--text-primary)]">
|
||||
<i class="fas fa-calendar mr-2 text-[var(--text-accent)]"></i>${t('inquire.dateRange')}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--text-muted)] mb-1 font-medium">${t('inquire.from')}</label>
|
||||
<input type="date" v-model="inquireFilters.dateFrom" class="w-full px-2 py-1 text-sm bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:border-[var(--border-focus)] focus:ring-1 focus:ring-[var(--border-focus)] transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--text-muted)] mb-1 font-medium">${t('inquire.to')}</label>
|
||||
<input type="date" v-model="inquireFilters.dateTo" class="w-full px-2 py-1 text-sm bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:border-[var(--border-focus)] focus:ring-1 focus:ring-[var(--border-focus)] transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording Filters - Commented Out -->
|
||||
<!--
|
||||
<div class="flex-1 flex flex-col px-6 pb-6 min-h-0">
|
||||
<div class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-4 flex flex-col flex-1 min-h-0">
|
||||
<h3 class="font-medium mb-3 flex items-center flex-shrink-0 text-[var(--text-primary)]">
|
||||
<i class="fas fa-file-audio mr-2 text-[var(--text-accent)]"></i>Recordings
|
||||
</h3>
|
||||
<div class="space-y-1 flex-1 overflow-y-auto custom-scrollbar pr-2 min-h-0">
|
||||
<label v-for="recording in availableFilters.recordings" :key="recording.id" class="flex items-center cursor-pointer hover:bg-[var(--bg-tertiary)] p-3 rounded-md transition-colors border border-transparent hover:border-[var(--border-secondary)]">
|
||||
<input type="checkbox" :value="recording.id" v-model="inquireFilters.selectedRecordings" class="h-4 w-4 rounded border-[var(--border-secondary)] text-[var(--text-accent)] focus:ring-[var(--border-focus)] flex-shrink-0">
|
||||
<div class="ml-3 flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate text-[var(--text-primary)]" :title="recording.title">${recording.title}</div>
|
||||
<div v-if="recording.meeting_date" class="text-xs text-[var(--text-muted)] mt-1">${formatDate(recording.meeting_date)}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Active Filters Summary -->
|
||||
<div v-if="hasActiveFilters" class="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)] p-3">
|
||||
<h4 class="text-sm font-medium mb-2 text-[var(--text-muted)] flex items-center">
|
||||
<i class="fas fa-filter mr-2"></i>${t('inquire.activeFilters')}
|
||||
</h4>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div v-if="inquireFilters.selectedTags.length" class="flex items-center px-2 py-1 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
|
||||
<span><i class="fas fa-tags mr-1"></i>${inquireFilters.selectedTags.length} ${t('inquire.tagsCount')}</span>
|
||||
</div>
|
||||
<div v-if="inquireFilters.selectedSpeakers.length" class="flex items-center px-2 py-1 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
|
||||
<span><i class="fas fa-users mr-1"></i>${inquireFilters.selectedSpeakers.length} ${t('inquire.speakersCount')}</span>
|
||||
</div>
|
||||
<div v-if="inquireFilters.dateFrom || inquireFilters.dateTo" class="flex items-center px-2 py-1 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
|
||||
<span><i class="fas fa-calendar mr-1"></i>${t('inquire.dateRangeActive')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- End of p-4 space-y-4 div -->
|
||||
</div><!-- End of flex-1 overflow-y-auto div -->
|
||||
</div><!-- End of w-80 sidebar div -->
|
||||
|
||||
<!-- Right Panel: Chat -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden"
|
||||
:class="isMobileSidebarOpen ? 'hidden lg:flex' : 'flex'">
|
||||
<!-- Chat Messages Area -->
|
||||
<div ref="inquireMessagesRef" @scroll="handleScroll" class="flex-1 overflow-y-auto p-6 space-y-4 custom-scrollbar">
|
||||
<!-- Welcome Message -->
|
||||
<div v-if="inquireChatMessages.length === 0" class="text-center py-16">
|
||||
<div class="mb-6 text-[var(--text-accent)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-4">${t('inquire.askQuestions')}</h2>
|
||||
<p class="text-[var(--text-muted)] max-w-lg mx-auto mb-6">
|
||||
${t('inquire.selectFilters')}
|
||||
</p>
|
||||
<div class="bg-[var(--bg-tertiary)] rounded-xl p-4 max-w-md mx-auto">
|
||||
<h4 class="font-medium mb-2">${t('inquire.exampleQuestions')}</h4>
|
||||
<ul class="text-sm text-[var(--text-secondary)] space-y-1 text-left">
|
||||
<li>${t('inquire.exampleQuestion1')}</li>
|
||||
<li>${t('inquire.exampleQuestion2')}</li>
|
||||
<li>${t('inquire.exampleQuestion3')}</li>
|
||||
<li>${t('inquire.exampleQuestion4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<div v-for="(message, index) in inquireChatMessages" :key="index"
|
||||
:class="['flex', message.role === 'user' ? 'justify-end' : 'justify-start']">
|
||||
<div :class="['max-w-3xl px-4 py-4 rounded-xl chat-message',
|
||||
message.role === 'user'
|
||||
? 'bg-[var(--bg-accent)] text-[var(--text-accent)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]']">
|
||||
<div class="prose prose-sm max-w-none text-[var(--text-primary)]"
|
||||
:class="message.role === 'user' ? 'prose-invert' : ''"
|
||||
v-html="message.html || message.content">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator with status -->
|
||||
<div v-if="isInquireChatLoading" class="flex justify-start">
|
||||
<div class="bg-[var(--bg-tertiary)] px-4 py-3 rounded-xl max-w-md">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="animate-spin h-5 w-5 border-2 border-[var(--text-accent)] border-t-transparent rounded-full flex-shrink-0"></div>
|
||||
<span class="text-sm text-[var(--text-muted)]" v-if="showProcessingStatus">${chatProcessingStatus}</span>
|
||||
<span class="text-sm text-[var(--text-muted)]" v-else>Analyzing transcriptions...</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress steps indicator -->
|
||||
<div v-if="showProcessingStatus" class="mt-2 flex space-x-1">
|
||||
<div class="h-1 w-4 bg-[var(--text-accent)] rounded" :class="{ 'animate-pulse': chatProcessingStatus.includes('Analyzing') }"></div>
|
||||
<div class="h-1 w-4 rounded" :class="chatProcessingStatus.includes('Enriching') || chatProcessingStatus.includes('Searching') || chatProcessingStatus.includes('Responding') ? 'bg-[var(--text-accent)]' : 'bg-[var(--border-secondary)]'"></div>
|
||||
<div class="h-1 w-4 rounded" :class="chatProcessingStatus.includes('Searching') || chatProcessingStatus.includes('Responding') ? 'bg-[var(--text-accent)]' : 'bg-[var(--border-secondary)]'"></div>
|
||||
<div class="h-1 w-4 rounded" :class="chatProcessingStatus.includes('Contextualizing') || chatProcessingStatus.includes('Responding') ? 'bg-[var(--text-accent)]' : 'bg-[var(--border-secondary)]'"></div>
|
||||
<div class="h-1 w-4 rounded" :class="chatProcessingStatus.includes('Responding') ? 'bg-[var(--text-accent)] animate-pulse' : 'bg-[var(--border-secondary)]'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Input Area -->
|
||||
<div class="flex-shrink-0 border-t border-[var(--border-primary)] p-6 bg-[var(--bg-secondary)]">
|
||||
<div class="flex gap-4">
|
||||
<textarea v-model="inquireChatInput"
|
||||
@keydown="handleInquireChatKeydown"
|
||||
:placeholder="t('inquire.placeholder')"
|
||||
class="flex-1 px-4 py-3 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] focus:border-transparent text-[var(--text-primary)]"
|
||||
rows="3"></textarea>
|
||||
<button @click="sendInquireChatMessage"
|
||||
:disabled="!inquireChatInput.trim() || isInquireChatLoading"
|
||||
class="px-6 py-3 bg-[var(--bg-button)] text-[var(--text-button)] rounded-xl hover:bg-[var(--bg-button-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[60px]">
|
||||
<i class="fas fa-paper-plane text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-muted)] mt-3 flex items-center justify-between">
|
||||
<span>${t('inquire.sendHint')}</span>
|
||||
<span v-if="hasActiveFilters" class="text-[var(--text-accent)]">
|
||||
<i class="fas fa-filter mr-1"></i>${t('inquire.filtersActive')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toastContainer" class="fixed top-4 right-4 z-50 space-y-2"></div>
|
||||
|
||||
<!-- Global Error Banner -->
|
||||
<div v-if="globalError" class="fixed top-16 left-1/2 transform -translate-x-1/2 z-50 max-w-lg w-full mx-4">
|
||||
<div class="bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-3"></i>
|
||||
<span class="text-sm font-medium">${ globalError }</span>
|
||||
</div>
|
||||
<button @click="globalError = null" class="ml-4 text-white hover:text-gray-200">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Color Scheme Modal -->
|
||||
<div v-if="showColorSchemeModal" class="color-scheme-modal" @click.self="closeColorSchemeModal">
|
||||
<div class="color-scheme-modal-content">
|
||||
<div class="color-scheme-header">
|
||||
<h2 class="color-scheme-title">
|
||||
<i class="fas fa-palette"></i>
|
||||
<span v-text="t('colorScheme.title')"></span>
|
||||
</h2>
|
||||
<p class="color-scheme-subtitle" v-text="t('colorScheme.subtitle')"></p>
|
||||
</div>
|
||||
|
||||
<div class="color-scheme-body">
|
||||
<div class="color-scheme-section">
|
||||
<h3 class="color-scheme-section-title">
|
||||
<i :class="isDarkMode ? 'fas fa-moon' : 'fas fa-sun'"></i>
|
||||
<span v-text="isDarkMode ? t('colorScheme.darkThemes') : t('colorScheme.lightThemes')"></span>
|
||||
</h3>
|
||||
<div class="color-scheme-grid">
|
||||
<div v-for="scheme in colorSchemes[isDarkMode ? 'dark' : 'light']"
|
||||
:key="scheme.id"
|
||||
@click="selectColorScheme(scheme.id)"
|
||||
:class="['color-scheme-option', currentColorScheme === scheme.id ? 'active' : '']">
|
||||
<div class="color-scheme-preview">
|
||||
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-primary color-scheme-preview-segment`"></div>
|
||||
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-secondary color-scheme-preview-segment`"></div>
|
||||
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-tertiary color-scheme-preview-segment`"></div>
|
||||
</div>
|
||||
<div class="color-scheme-name" v-text="t('colorScheme.themes.' + (isDarkMode ? 'dark' : 'light') + '.' + scheme.id + '.name')"></div>
|
||||
<div class="color-scheme-description" v-text="t('colorScheme.themes.' + (isDarkMode ? 'dark' : 'light') + '.' + scheme.id + '.description')"></div>
|
||||
<div v-if="currentColorScheme === scheme.id" class="color-scheme-check">
|
||||
<i class="fas fa-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-scheme-footer">
|
||||
<button @click="resetColorScheme" class="color-scheme-reset-btn">
|
||||
<i class="fas fa-undo mr-2"></i><span v-text="t('colorScheme.resetToDefault')"></span>
|
||||
</button>
|
||||
<button @click="closeColorSchemeModal" class="color-scheme-close-btn" v-text="t('common.close')">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shares List Modal -->
|
||||
<div v-if="showSharesListModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<div class="p-6 border-b border-[var(--border-primary)] flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.sharedTranscripts')"></h3>
|
||||
<button @click="closeSharesList" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4 overflow-y-auto">
|
||||
<div v-if="isLoadingShares" class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||||
</div>
|
||||
<div v-else-if="userShares.length === 0" class="text-center text-[var(--text-muted)]">
|
||||
<span v-text="t('sharedTranscripts.noSharedTranscripts')"></span>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="share in userShares" :key="share.id" class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-semibold">${share.recording_title}</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">${ t('help.sharedOn') }: ${share.created_at}</p>
|
||||
</div>
|
||||
<button @click="deleteShare(share.id)" class="text-red-500 hover:text-red-700 p-1"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input :value="'{{ request.url_root }}share/' + share.public_id" readonly class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg text-sm">
|
||||
<button @click="copyShareLink(share.public_id)" class="mt-2 px-3 py-1 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg text-sm hover:bg-[var(--bg-button-hover)]">
|
||||
<i class="fas fa-copy mr-2"></i>Copy Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSRF Token Management -->
|
||||
<script src="{{ url_for('static', filename='js/csrf-refresh.js') }}"></script>
|
||||
|
||||
<!-- Shared Components -->
|
||||
<script src="{{ url_for('static', filename='js/shared-components.js') }}"></script>
|
||||
|
||||
<!-- Inquire Mode Vue App -->
|
||||
<script>
|
||||
const { createApp, ref, reactive, computed, onMounted, onUnmounted, nextTick } = Vue;
|
||||
|
||||
// Initialize i18n
|
||||
let t = (key) => key; // Default fallback
|
||||
|
||||
async function initializeI18n() {
|
||||
const userLanguage = "{{ user_language }}";
|
||||
|
||||
// Initialize the global i18n instance
|
||||
if (window.i18n) {
|
||||
await window.i18n.init(userLanguage);
|
||||
|
||||
// Create a shorthand translation function
|
||||
t = (key, params) => window.i18n.t(key, params);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await initializeI18n();
|
||||
|
||||
const csrfToken = ref(document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'));
|
||||
const i18nInstance = window.i18n;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
// --- State ---
|
||||
const inquireFilters = reactive({
|
||||
selectedTags: [],
|
||||
selectedSpeakers: [],
|
||||
selectedRecordings: [],
|
||||
dateFrom: null,
|
||||
dateTo: null
|
||||
});
|
||||
|
||||
// Search queries for filters
|
||||
const tagSearchQuery = ref('');
|
||||
const speakerSearchQuery = ref('');
|
||||
|
||||
// Use shared components
|
||||
const { isDarkMode, toggleDarkMode, initializeDarkMode } = window.SharedComponents.useDarkMode();
|
||||
const {
|
||||
showColorSchemeModal,
|
||||
currentColorScheme,
|
||||
colorSchemes,
|
||||
openColorSchemeModal,
|
||||
closeColorSchemeModal,
|
||||
selectColorScheme,
|
||||
resetColorScheme,
|
||||
applyColorScheme,
|
||||
initializeColorScheme
|
||||
} = window.SharedComponents.useColorScheme();
|
||||
const {
|
||||
showSharesListModal,
|
||||
userShares,
|
||||
isLoadingShares,
|
||||
openSharesList,
|
||||
closeSharesList,
|
||||
copyShareLink,
|
||||
deleteShare
|
||||
} = window.SharedComponents.useSharesModal();
|
||||
const { isUserMenuOpen, toggleUserMenu, closeUserMenu } = window.SharedComponents.useUserMenu();
|
||||
const isMobileSidebarOpen = ref(false);
|
||||
|
||||
const availableFilters = reactive({
|
||||
tags: [],
|
||||
speakers: [],
|
||||
recordings: []
|
||||
});
|
||||
|
||||
const inquireChatMessages = ref([]);
|
||||
const inquireChatInput = ref('');
|
||||
const isInquireChatLoading = ref(false);
|
||||
const chatProcessingStatus = ref('');
|
||||
const showProcessingStatus = ref(false);
|
||||
const inquireMessagesRef = ref(null);
|
||||
const globalError = ref(null);
|
||||
const shouldAutoScroll = ref(true); // Track if we should auto-scroll
|
||||
const tokenBudget = ref({
|
||||
has_budget: false,
|
||||
budget: null,
|
||||
usage: 0,
|
||||
percentage: 0
|
||||
});
|
||||
|
||||
// --- Computed ---
|
||||
const hasActiveFilters = computed(() => {
|
||||
return inquireFilters.selectedTags.length > 0 ||
|
||||
inquireFilters.selectedSpeakers.length > 0 ||
|
||||
inquireFilters.selectedRecordings.length > 0 ||
|
||||
inquireFilters.dateFrom ||
|
||||
inquireFilters.dateTo;
|
||||
});
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
if (!tagSearchQuery.value) return availableFilters.tags;
|
||||
return availableFilters.tags.filter(tag =>
|
||||
tag.name.toLowerCase().includes(tagSearchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredSpeakers = computed(() => {
|
||||
if (!speakerSearchQuery.value) return availableFilters.speakers;
|
||||
return availableFilters.speakers.filter(speaker =>
|
||||
speaker.toLowerCase().includes(speakerSearchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// --- Functions ---
|
||||
const setGlobalError = (message, duration = 7000) => {
|
||||
globalError.value = message;
|
||||
if (duration > 0) {
|
||||
setTimeout(() => { if (globalError.value === message) globalError.value = null; }, duration);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTokenBudget = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/token-budget');
|
||||
if (response.ok) {
|
||||
tokenBudget.value = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading token budget:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvailableFilters = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/inquire/available_filters');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
availableFilters.tags = data.tags;
|
||||
availableFilters.speakers = data.speakers;
|
||||
availableFilters.recordings = data.recordings;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading available filters:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendInquireChatMessage = async () => {
|
||||
if (!inquireChatInput.value.trim() || isInquireChatLoading.value) return;
|
||||
|
||||
const userMessage = inquireChatInput.value.trim();
|
||||
inquireChatMessages.value.push({ role: 'user', content: userMessage, html: userMessage });
|
||||
inquireChatInput.value = '';
|
||||
isInquireChatLoading.value = true;
|
||||
showProcessingStatus.value = true;
|
||||
chatProcessingStatus.value = 'Initializing...';
|
||||
|
||||
// Always scroll to bottom when user sends a message
|
||||
shouldAutoScroll.value = true;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/inquire/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken.value
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
message_history: inquireChatMessages.value.slice(0, -1),
|
||||
filter_tags: inquireFilters.selectedTags,
|
||||
filter_speakers: inquireFilters.selectedSpeakers,
|
||||
filter_recording_ids: inquireFilters.selectedRecordings,
|
||||
filter_date_from: inquireFilters.dateFrom,
|
||||
filter_date_to: inquireFilters.dateTo,
|
||||
context_chunks: 8
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Chat request failed');
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let assistantMessage = reactive({ role: 'assistant', content: '', html: '' });
|
||||
let messageAdded = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.status && data.message) {
|
||||
// Update processing status
|
||||
chatProcessingStatus.value = data.message;
|
||||
} else if (data.delta) {
|
||||
// Hide status and start showing response
|
||||
if (!messageAdded) {
|
||||
inquireChatMessages.value.push(assistantMessage);
|
||||
messageAdded = true;
|
||||
showProcessingStatus.value = false;
|
||||
}
|
||||
assistantMessage.content += data.delta;
|
||||
assistantMessage.html = marked.parse(assistantMessage.content);
|
||||
// Smart scroll during streaming
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} else if (data.end_of_stream) {
|
||||
return;
|
||||
} else if (data.error) {
|
||||
if (data.budget_exceeded) {
|
||||
throw new Error(t('adminDashboard.tokenBudgetExceeded'));
|
||||
}
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse SSE data:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in inquire chat:', error);
|
||||
setGlobalError(error.message || 'Chat failed. Please try again.', 10000);
|
||||
} finally {
|
||||
isInquireChatLoading.value = false;
|
||||
showProcessingStatus.value = false;
|
||||
loadTokenBudget(); // Refresh token usage after chat
|
||||
await nextTick();
|
||||
scrollToBottom(); // Use smart scrolling
|
||||
}
|
||||
};
|
||||
|
||||
const handleInquireChatKeydown = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.ctrlKey || event.shiftKey) {
|
||||
return;
|
||||
} else {
|
||||
event.preventDefault();
|
||||
sendInquireChatMessage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearInquireFilters = () => {
|
||||
inquireFilters.selectedTags = [];
|
||||
inquireFilters.selectedSpeakers = [];
|
||||
inquireFilters.selectedRecordings = [];
|
||||
inquireFilters.dateFrom = null;
|
||||
inquireFilters.dateTo = null;
|
||||
inquireChatMessages.value = [];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Smart scrolling functions
|
||||
const isNearBottom = () => {
|
||||
if (!inquireMessagesRef.value) return true;
|
||||
const container = inquireMessagesRef.value;
|
||||
const threshold = 100; // pixels from bottom
|
||||
return container.scrollTop + container.clientHeight >= container.scrollHeight - threshold;
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (inquireMessagesRef.value && shouldAutoScroll.value) {
|
||||
inquireMessagesRef.value.scrollTop = inquireMessagesRef.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (inquireMessagesRef.value) {
|
||||
shouldAutoScroll.value = isNearBottom();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Theme Management (from main app.js) ---
|
||||
const applyThemeFromStorage = () => {
|
||||
const savedDarkMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = savedDarkMode !== null ? savedDarkMode === 'true' : prefersDark;
|
||||
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const currentMode = isDark ? 'dark' : 'light';
|
||||
const themeClass = savedScheme === 'blue' ? '' : `theme-${currentMode}-${savedScheme}`;
|
||||
|
||||
// Remove all existing theme classes
|
||||
document.documentElement.className = document.documentElement.className
|
||||
.replace(/theme-(?:light|dark)-\w+/g, '');
|
||||
|
||||
if (themeClass) {
|
||||
document.documentElement.classList.add(themeClass);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up global click handler to close dropdowns when clicking outside
|
||||
* Provides elegant UX by closing menus when users click elsewhere
|
||||
*/
|
||||
const setupGlobalClickHandler = () => {
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
|
||||
// Close user menu if clicking outside of it
|
||||
if (isUserMenuOpen.value) {
|
||||
const userMenuButton = target.closest('[data-user-menu-toggle]');
|
||||
const userMenuDropdown = target.closest('[data-user-menu-dropdown]');
|
||||
|
||||
if (!userMenuButton && !userMenuDropdown) {
|
||||
isUserMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMounted(async () => {
|
||||
// Initialize dark mode and color scheme
|
||||
initializeDarkMode();
|
||||
initializeColorScheme(isDarkMode.value);
|
||||
setupGlobalClickHandler();
|
||||
|
||||
// Watch dark mode changes to update color scheme
|
||||
Vue.watch(isDarkMode, (newValue) => {
|
||||
initializeColorScheme(newValue);
|
||||
});
|
||||
|
||||
// Apply theme from localStorage on page load
|
||||
applyThemeFromStorage();
|
||||
|
||||
loadAvailableFilters();
|
||||
loadTokenBudget();
|
||||
|
||||
// Add scroll listener for smart auto-scroll after DOM is ready
|
||||
await nextTick();
|
||||
if (inquireMessagesRef.value) {
|
||||
inquireMessagesRef.value.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (inquireMessagesRef.value) {
|
||||
inquireMessagesRef.value.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
// i18n - bind to i18n instance to maintain context
|
||||
t: (key, params) => i18nInstance ? i18nInstance.t(key, params) : key,
|
||||
|
||||
// State
|
||||
inquireFilters,
|
||||
availableFilters,
|
||||
inquireChatMessages,
|
||||
inquireChatInput,
|
||||
isInquireChatLoading,
|
||||
inquireMessagesRef,
|
||||
globalError,
|
||||
tokenBudget,
|
||||
isUserMenuOpen,
|
||||
isMobileSidebarOpen,
|
||||
chatProcessingStatus,
|
||||
showProcessingStatus,
|
||||
tagSearchQuery,
|
||||
speakerSearchQuery,
|
||||
|
||||
// Dark Mode
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
|
||||
// Color Scheme
|
||||
showColorSchemeModal,
|
||||
currentColorScheme,
|
||||
colorSchemes,
|
||||
openColorSchemeModal,
|
||||
closeColorSchemeModal,
|
||||
selectColorScheme,
|
||||
resetColorScheme,
|
||||
|
||||
// Shares Modal
|
||||
showSharesListModal,
|
||||
userShares,
|
||||
isLoadingShares,
|
||||
openSharesList,
|
||||
closeSharesList,
|
||||
copyShareLink,
|
||||
deleteShare,
|
||||
|
||||
// Computed
|
||||
hasActiveFilters,
|
||||
filteredTags,
|
||||
filteredSpeakers,
|
||||
|
||||
// Functions
|
||||
sendInquireChatMessage,
|
||||
handleInquireChatKeydown,
|
||||
clearInquireFilters,
|
||||
loadAvailableFilters,
|
||||
setGlobalError,
|
||||
formatDate,
|
||||
handleScroll
|
||||
};
|
||||
},
|
||||
delimiters: ['${', '}']
|
||||
}).mount('#inquire-app');
|
||||
|
||||
// Hide loading overlay after app mounts
|
||||
Vue.nextTick(() => {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
178
templates/login.html
Normal file
178
templates/login.html
Normal file
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>{{ title }} - DictIA</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
|
||||
<!-- Loading overlay to prevent FOUC -->
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
|
||||
<script>
|
||||
// Function to apply the theme based on localStorage
|
||||
function applyTheme() {
|
||||
// Guard against early execution
|
||||
if (!document.documentElement) return;
|
||||
|
||||
// Apply dark mode
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Apply color scheme
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||||
|
||||
// Remove all other theme classes
|
||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
||||
themeClasses.forEach(theme => {
|
||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
||||
});
|
||||
|
||||
// Add the correct theme class
|
||||
if (savedScheme !== 'blue') {
|
||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
||||
DictIA
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow flex items-center justify-center">
|
||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Connexion</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if sso_enabled %}
|
||||
<div class="flex flex-col space-y-3 {% if not password_login_disabled %}mb-6{% endif %}">
|
||||
<a href="{{ url_for('auth.sso_login') }}" class="w-full inline-flex items-center justify-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||||
<i class="fas fa-cloud mr-2"></i> Se connecter avec {{ sso_provider_name }}
|
||||
</a>
|
||||
{% if not password_login_disabled %}
|
||||
<div class="flex items-center text-xs text-[var(--text-muted)]">
|
||||
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
|
||||
<span class="mx-3 uppercase tracking-wide">ou</span>
|
||||
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if password_login_disabled %}
|
||||
<div class="mt-4 text-center">
|
||||
<button type="button" onclick="document.getElementById('admin-login-form').classList.toggle('hidden')" class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
||||
<i class="fas fa-lock mr-1"></i> Connexion administrateur
|
||||
</button>
|
||||
</div>
|
||||
<form id="admin-login-form" method="POST" action="{{ url_for('auth.login') }}" class="hidden mt-4">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-4">
|
||||
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]", placeholder="Email administrateur") }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]", placeholder="Mot de passe") }}
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]">
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
||||
{% if form.email.errors %}
|
||||
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
||||
{% for error in form.email.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
||||
{% if form.password.errors %}
|
||||
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
||||
{% for error in form.password.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center">
|
||||
{{ form.remember(class="h-4 w-4 text-[var(--text-accent)] focus:ring-[var(--ring-focus)] border-[var(--border-secondary)] rounded") }}
|
||||
{{ form.remember.label(class="ml-2 block text-sm text-[var(--text-secondary)]") }}
|
||||
</div>
|
||||
<a href="{{ url_for('auth.forgot_password') }}" class="text-sm text-[var(--text-accent)] hover:underline">Mot de passe oublié ?</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-4">
|
||||
{{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }}
|
||||
|
||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
||||
<span>Pas encore de compte ?</span>
|
||||
<a href="{{ url_for('auth.register') }}" class="font-medium text-[var(--text-accent)] hover:underline">S'inscrire</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Hide loading overlay when page is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
77
templates/modals/add-speaker-modal.html
Normal file
77
templates/modals/add-speaker-modal.html
Normal file
@@ -0,0 +1,77 @@
|
||||
<!-- Add Speaker Modal -->
|
||||
<div v-if="showAddSpeakerModal" @click.self="closeAddSpeakerModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-bold text-[var(--text-primary)]" v-text="t('modal.addSpeaker')"></h3>
|
||||
<button @click="closeAddSpeakerModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
|
||||
<!-- "This is Me" checkbox -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors">
|
||||
<input type="checkbox" v-model="newSpeakerIsMe" class="speaker-checkbox">
|
||||
<span class="ml-2 select-none" v-text="t('help.me')"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Speaker name input with autocomplete (disabled if "This is Me" is checked) -->
|
||||
<div class="mb-6 relative">
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2" v-text="t('help.speakerName')">
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="newSpeakerName"
|
||||
@input="searchNewSpeaker"
|
||||
@focus="showNewSpeakerSuggestions = true"
|
||||
@blur="hideNewSpeakerSuggestionsDelayed"
|
||||
:disabled="newSpeakerIsMe"
|
||||
:placeholder="newSpeakerIsMe ? currentUserName : t('help.enterSpeakerName')"
|
||||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed"
|
||||
@keyup.enter="addNewSpeaker"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="loadingNewSpeakerSuggestions && !newSpeakerIsMe" class="absolute right-3 top-[42px] transform -translate-y-1/2">
|
||||
<i class="fas fa-spinner fa-spin text-[var(--text-muted)] text-sm"></i>
|
||||
</div>
|
||||
|
||||
<!-- Suggestions dropdown -->
|
||||
<div v-if="showNewSpeakerSuggestions && newSpeakerSuggestions.length > 0 && !newSpeakerIsMe"
|
||||
@click.stop
|
||||
class="absolute z-10 w-full mt-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
<div class="py-1">
|
||||
<div v-for="suggestion in newSpeakerSuggestions"
|
||||
:key="suggestion.id"
|
||||
@click="selectNewSpeakerSuggestion(suggestion)"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)] flex items-center justify-between">
|
||||
<div class="flex-grow">
|
||||
<div class="text-sm font-medium text-[var(--text-primary)]">${ suggestion.name }</div>
|
||||
<div class="text-xs text-[var(--text-muted)]">
|
||||
Used ${ suggestion.use_count } time${ suggestion.use_count !== 1 ? 's' : '' }
|
||||
<span v-if="suggestion.last_used">
|
||||
• Last: ${ new Date(suggestion.last_used).toLocaleDateString() }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-user text-[var(--text-muted)] ml-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button @click="closeAddSpeakerModal" class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors" v-text="t('common.cancel')">
|
||||
</button>
|
||||
<button @click="addNewSpeaker" class="px-4 py-2 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-lg hover:bg-[var(--bg-accent-hover)] transition-colors" v-text="t('buttons.addSpeaker')">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
154
templates/modals/asr-editor-modal.html
Normal file
154
templates/modals/asr-editor-modal.html
Normal file
@@ -0,0 +1,154 @@
|
||||
<!-- ASR Editor Modal -->
|
||||
<div v-if="showAsrEditorModal" @click.self="closeAsrEditorModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div @click="closeAllSpeakerSuggestions" class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-6xl flex flex-col max-h-[90vh]">
|
||||
<div class="p-5 border-b border-[var(--border-primary)] flex-shrink-0 flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold text-[var(--text-primary)]" v-text="t('modal.editAsrTranscription')"></h3>
|
||||
<button @click="closeAsrEditorModal" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio Player Section -->
|
||||
<div class="px-6 py-4 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<!-- Show message if audio has been deleted -->
|
||||
<div v-if="selectedRecording.audio_deleted_at"
|
||||
class="bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-[var(--text-secondary)] px-4 py-3 rounded-lg flex items-center gap-2 text-sm">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span v-text="t('help.audioDeletedMessage')"></span>
|
||||
</div>
|
||||
<!-- Show message for incognito recordings -->
|
||||
<div v-else-if="selectedRecording.incognito"
|
||||
class="bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-[var(--text-secondary)] px-4 py-3 rounded-lg flex items-center gap-2 text-sm">
|
||||
<i class="fas fa-user-secret"></i>
|
||||
<span>Audio not stored in incognito mode</span>
|
||||
</div>
|
||||
<!-- Custom Audio Player (Independent from main player) -->
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<audio ref="asrEditorAudioRef" class="hidden"
|
||||
:key="'asr-editor-' + selectedRecording.id"
|
||||
:src="'/audio/' + selectedRecording.id"
|
||||
:volume="playerVolume"
|
||||
@play="handleModalAudioPlayPause"
|
||||
@pause="handleModalAudioPlayPause"
|
||||
@timeupdate="handleModalAudioTimeUpdate"
|
||||
@loadedmetadata="handleModalAudioLoadedMetadata"
|
||||
@ended="modalAudioIsPlaying = false">
|
||||
</audio>
|
||||
<!-- Play/Pause -->
|
||||
<button @click="$refs.asrEditorAudioRef?.paused ? $refs.asrEditorAudioRef.play() : $refs.asrEditorAudioRef.pause()"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-[var(--bg-accent)] hover:bg-[var(--bg-accent-hover)] text-white transition-all flex-shrink-0 shadow-sm"
|
||||
:title="modalAudioIsPlaying ? 'Pause' : 'Play'">
|
||||
<i :class="modalAudioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-sm" :style="!modalAudioIsPlaying ? 'margin-left: 2px' : ''"></i>
|
||||
</button>
|
||||
<!-- Time -->
|
||||
<div class="flex flex-col items-end flex-shrink-0 leading-none">
|
||||
<span class="text-sm text-[var(--text-primary)] font-mono">${ formatAudioTime(modalAudioCurrentTime) }</span>
|
||||
<span class="text-xs text-[var(--text-muted)] font-mono">${ formatAudioTime(modalAudioDuration) }</span>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="flex-1 h-2 bg-[var(--bg-tertiary)] rounded-full cursor-pointer group relative"
|
||||
@click="(e) => { const rect = e.currentTarget.getBoundingClientRect(); const pct = (e.clientX - rect.left) / rect.width; if ($refs.asrEditorAudioRef) $refs.asrEditorAudioRef.currentTime = pct * modalAudioDuration; }">
|
||||
<div class="h-full bg-[var(--bg-accent)] rounded-full transition-all duration-100"
|
||||
:style="{ width: modalAudioProgressPercent + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Volume -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button @click="$refs.asrEditorAudioRef && ($refs.asrEditorAudioRef.muted = !$refs.asrEditorAudioRef.muted)"
|
||||
class="w-8 h-8 flex items-center justify-center rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all">
|
||||
<i :class="playerVolume === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'" class="text-sm"></i>
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.05" :value="playerVolume"
|
||||
@input="(e) => { if ($refs.asrEditorAudioRef) $refs.asrEditorAudioRef.volume = parseFloat(e.target.value); }"
|
||||
class="volume-slider w-20 h-1.5 rounded-full cursor-pointer">
|
||||
</div>
|
||||
<!-- Speed Control (compact - tap to cycle) -->
|
||||
<button @click="cycleModalPlaybackRate(); if ($refs.asrEditorAudioRef) $refs.asrEditorAudioRef.playbackRate = modalPlaybackRate"
|
||||
class="w-8 h-8 flex items-center justify-center rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all flex-shrink-0"
|
||||
title="Playback speed (tap to change)">
|
||||
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(modalPlaybackRate) }</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="asrEditorRef" class="flex-grow overflow-y-auto custom-scrollbar" @scroll="closeAllSpeakerSuggestions(); onAsrEditorScroll($event)">
|
||||
<table class="min-w-full asr-editor-table border border-[var(--border-primary)]">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr class="text-xs uppercase text-[var(--text-muted)]">
|
||||
<th class="w-40 text-left py-2 px-3 font-medium border border-[var(--border-primary)] bg-[var(--bg-tertiary)]" v-text="t('help.speakerCount')"></th>
|
||||
<th class="w-20 text-left py-2 px-3 font-medium border border-[var(--border-primary)] bg-[var(--bg-tertiary)]" v-text="t('help.startTime')"></th>
|
||||
<th class="w-20 text-left py-2 px-3 font-medium border border-[var(--border-primary)] bg-[var(--bg-tertiary)]" v-text="t('help.endTime')"></th>
|
||||
<th class="text-left py-2 px-3 font-medium border border-[var(--border-primary)] bg-[var(--bg-tertiary)]" v-text="t('help.sentence')"></th>
|
||||
<th class="w-24 text-center py-2 px-1 font-medium border border-[var(--border-primary)] bg-[var(--bg-tertiary)]"><span class="sr-only" v-text="t('help.actions')"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Virtual scroll spacer (top) -->
|
||||
<tr v-if="asrEditorSpacerBefore > 0"><td colspan="5" :style="{ height: asrEditorSpacerBefore + 'px', padding: 0 }"></td></tr>
|
||||
|
||||
<tr v-for="segment in asrEditorVisibleSegments" :key="`asr-${segment._originalIndex}-${segment.start_time}`" :data-segment-index="segment._originalIndex" class="hover:bg-[var(--bg-tertiary)] transition-colors border-b border-[var(--border-primary)]">
|
||||
<td class="border-r border-[var(--border-primary)] align-middle">
|
||||
<div class="relative h-full">
|
||||
<div class="flex items-center h-full">
|
||||
<input type="text" v-model="editingSegments[segment._originalIndex].speaker"
|
||||
@input="filterSpeakerSuggestions(segment._originalIndex); openSpeakerSuggestions(segment._originalIndex)"
|
||||
@focus="openSpeakerSuggestions(segment._originalIndex)"
|
||||
class="w-full h-full px-3 py-2.5 pr-7 bg-transparent border-0 focus:outline-none text-sm text-[var(--text-primary)]" />
|
||||
<button type="button" @click.stop="openSpeakerSuggestions(segment._originalIndex)"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] cursor-pointer">
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fixed position dropdown -->
|
||||
<teleport to="body">
|
||||
<div v-if="isDropdownOpen(segment._originalIndex)"
|
||||
class="fixed z-[100] w-40 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||||
:style="getDropdownPosition(segment._originalIndex)">
|
||||
<div v-for="speaker in editingSegments[segment._originalIndex].filteredSpeakers" :key="speaker" @mousedown.prevent="selectSpeaker(segment._originalIndex, speaker)" class="px-3 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)] text-sm">${speaker}</div>
|
||||
<div @mousedown.prevent="openEditSpeakersModal" class="px-3 py-2 cursor-pointer hover:bg-[var(--bg-accent)] text-sm text-[var(--text-accent)] border-t border-[var(--border-primary)] flex items-center">
|
||||
<i class="fas fa-user-edit mr-2"></i> <span v-text="t('buttons.editSpeakers')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</td>
|
||||
<td class="border-r border-[var(--border-primary)] align-middle">
|
||||
<input type="number" step="0.01" v-model.number="editingSegments[segment._originalIndex].start_time" class="w-full px-3 py-2.5 bg-transparent border-0 focus:outline-none text-sm text-[var(--text-primary)] text-center" />
|
||||
</td>
|
||||
<td class="border-r border-[var(--border-primary)] align-middle">
|
||||
<input type="number" step="0.01" v-model.number="editingSegments[segment._originalIndex].end_time" class="w-full px-3 py-2.5 bg-transparent border-0 focus:outline-none text-sm text-[var(--text-primary)] text-center" />
|
||||
</td>
|
||||
<td class="border-r border-[var(--border-primary)] align-top">
|
||||
<textarea v-model="editingSegments[segment._originalIndex].sentence" rows="1" class="w-full px-3 py-2.5 bg-transparent border-0 focus:outline-none text-sm text-[var(--text-primary)] resize-none max-h-20 overflow-y-auto custom-scrollbar block" @input="autoResizeTextarea($event)"></textarea>
|
||||
</td>
|
||||
<td class="px-1 text-center align-middle">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button @click="$refs.asrEditorAudioRef && ($refs.asrEditorAudioRef.currentTime = segment.start_time, $refs.asrEditorAudioRef.play())" class="p-1.5 rounded hover:bg-[var(--bg-accent)] text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors" :title="t('help.playFromHere')">
|
||||
<i class="fas fa-play text-xs"></i>
|
||||
</button>
|
||||
<button @click="addSegmentBelow(segment._originalIndex)" class="p-1.5 rounded hover:bg-green-600/20 text-[var(--text-muted)] hover:text-green-500 transition-colors" :title="t('help.addSegmentBelow')">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
</button>
|
||||
<button @click="removeSegment(segment._originalIndex)" class="p-1.5 rounded hover:bg-red-600/20 text-[var(--text-muted)] hover:text-red-500 transition-colors" :title="t('help.deleteSegment')">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Virtual scroll spacer (bottom) -->
|
||||
<tr v-if="asrEditorSpacerAfter > 0"><td colspan="5" :style="{ height: asrEditorSpacerAfter + 'px', padding: 0 }"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="py-4 flex justify-center">
|
||||
<button @click="addSegment" class="px-4 py-2 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-lg hover:bg-[var(--bg-accent-hover)] flex items-center text-sm">
|
||||
<i class="fas fa-plus mr-2"></i> <span v-text="t('buttons.addSegment')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-[var(--bg-tertiary)] px-6 py-4 flex justify-end space-x-3 border-t border-[var(--border-primary)] flex-shrink-0 rounded-b-xl">
|
||||
<button @click="closeAsrEditorModal" class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]" v-text="t('common.cancel')"></button>
|
||||
<button @click="saveAsrTranscription" class="px-4 py-2 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-lg hover:bg-[var(--bg-accent-hover)]" v-text="t('buttons.saveChanges')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
79
templates/modals/bulk-action-bar.html
Normal file
79
templates/modals/bulk-action-bar.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- Bulk Action Bar - Fixed at bottom of sidebar when in selection mode -->
|
||||
<transition name="slide-up">
|
||||
<div v-if="selectionMode && selectedCount > 0"
|
||||
class="fixed bottom-0 left-0 w-80 bg-[var(--bg-secondary)] border-t border-r border-[var(--border-primary)] shadow-lg z-50 transform transition-transform duration-300"
|
||||
:class="[
|
||||
{ 'translate-y-full': bulkActionInProgress },
|
||||
isSidebarCollapsed ? '-translate-x-full' : ''
|
||||
]">
|
||||
<div class="px-3 py-2">
|
||||
<!-- Compact vertical layout for sidebar width -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Selection info row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">
|
||||
${ selectedCount } selected
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="allVisibleSelected ? clearSelection() : selectAll()"
|
||||
class="text-xs text-[var(--text-accent)] hover:underline">
|
||||
${ allVisibleSelected ? 'Clear' : 'All' }
|
||||
</button>
|
||||
<button @click="exitSelectionMode"
|
||||
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded transition-colors"
|
||||
title="Exit selection mode">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons row -->
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<button @click="openBulkTagModal('add')"
|
||||
class="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-md transition-colors flex items-center justify-center gap-1"
|
||||
title="Add or remove tags">
|
||||
<i class="fas fa-tags text-[var(--text-muted)]"></i>
|
||||
<span>Tags</span>
|
||||
</button>
|
||||
|
||||
<button @click="bulkToggleInbox()"
|
||||
class="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-md transition-colors flex items-center justify-center gap-1"
|
||||
title="Toggle inbox status">
|
||||
<i class="fas fa-inbox text-blue-500"></i>
|
||||
<span>Inbox</span>
|
||||
</button>
|
||||
|
||||
<button @click="bulkToggleHighlight()"
|
||||
class="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-md transition-colors flex items-center justify-center gap-1"
|
||||
title="Toggle highlight">
|
||||
<i class="fas fa-star text-amber-500"></i>
|
||||
<span>Star</span>
|
||||
</button>
|
||||
|
||||
<button @click="openBulkReprocessModal"
|
||||
class="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-md transition-colors flex items-center justify-center gap-1"
|
||||
title="Reprocess">
|
||||
<i class="fas fa-redo text-[var(--text-muted)]"></i>
|
||||
<span>Redo</span>
|
||||
</button>
|
||||
|
||||
<button @click="openBulkDeleteModal"
|
||||
class="flex-1 px-2 py-1.5 text-xs bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors flex items-center justify-center gap-1"
|
||||
title="Delete selected">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="bulkActionInProgress"
|
||||
class="absolute inset-0 bg-[var(--bg-secondary)] bg-opacity-80 flex items-center justify-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-spinner fa-spin text-[var(--text-accent)]"></i>
|
||||
<span class="text-xs text-[var(--text-secondary)]">Processing...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
48
templates/modals/bulk-delete-modal.html
Normal file
48
templates/modals/bulk-delete-modal.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!-- Bulk Delete Confirmation Modal -->
|
||||
<div v-if="showBulkDeleteModal" @click.self="closeBulkDeleteModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[100] p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-xl text-red-600 dark:text-red-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Delete ${ selectedCount } Recording${ selectedCount !== 1 ? 's' : '' }</h3>
|
||||
<p class="text-sm text-[var(--text-muted)]">This action cannot be undone</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeBulkDeleteModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-[var(--bg-tertiary)] rounded-lg p-4 mb-6">
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-3">
|
||||
You are about to permanently delete:
|
||||
</p>
|
||||
<ul class="space-y-1 max-h-32 overflow-y-auto">
|
||||
<li v-for="recording in selectedRecordings.slice(0, 5)" :key="recording.id" class="text-sm text-[var(--text-primary)] flex items-center gap-2">
|
||||
<i class="fas fa-file-audio text-[var(--text-muted)] text-xs"></i>
|
||||
<span class="truncate">${ recording.title || 'Untitled' }</span>
|
||||
</li>
|
||||
<li v-if="selectedRecordings.length > 5" class="text-sm text-[var(--text-muted)] italic">
|
||||
...and ${ selectedRecordings.length - 5 } more
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeBulkDeleteModal"
|
||||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="executeBulkDelete"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<i class="fas fa-trash"></i>
|
||||
Delete All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
89
templates/modals/bulk-reprocess-modal.html
Normal file
89
templates/modals/bulk-reprocess-modal.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!-- Bulk Reprocess Modal -->
|
||||
<div v-if="showBulkReprocessModal" @click.self="closeBulkReprocessModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[100] p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-sync-alt text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Bulk Reprocess</h3>
|
||||
<p class="text-sm text-[var(--text-muted)]">${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } selected</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeBulkReprocessModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Reprocess Type Selection -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-3">What do you want to reprocess?</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-secondary)] cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:class="{ 'border-[var(--border-accent)] bg-[var(--bg-accent)]': bulkReprocessType === 'summary' }">
|
||||
<input type="radio" v-model="bulkReprocessType" value="summary" class="mt-1">
|
||||
<div>
|
||||
<span class="font-medium text-[var(--text-primary)]">Summary Only</span>
|
||||
<p class="text-sm text-[var(--text-muted)] mt-0.5">
|
||||
Regenerate title and summary from existing transcription. Faster option.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-secondary)] cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
:class="{ 'border-[var(--border-accent)] bg-[var(--bg-accent)]': bulkReprocessType === 'transcription' }">
|
||||
<input type="radio" v-model="bulkReprocessType" value="transcription" class="mt-1">
|
||||
<div>
|
||||
<span class="font-medium text-[var(--text-primary)]">Full Transcription</span>
|
||||
<p class="text-sm text-[var(--text-muted)] mt-0.5">
|
||||
Re-transcribe audio and regenerate everything. Takes longer.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg mb-6">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 dark:text-amber-400 mt-0.5"></i>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-amber-800 dark:text-amber-200">Note</p>
|
||||
<p class="text-amber-700 dark:text-amber-300 mt-0.5" v-if="bulkReprocessType === 'summary'">
|
||||
This will overwrite any manual edits to titles and summaries.
|
||||
</p>
|
||||
<p class="text-amber-700 dark:text-amber-300 mt-0.5" v-else>
|
||||
This will overwrite all transcriptions, titles, and summaries. Manual edits will be lost.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording count info -->
|
||||
<div class="text-sm text-[var(--text-muted)] mb-4">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
<span v-if="bulkReprocessType === 'summary'">
|
||||
${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } will be queued for summary regeneration.
|
||||
</span>
|
||||
<span v-else>
|
||||
${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } will be queued for full reprocessing.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3">
|
||||
<button @click="closeBulkReprocessModal"
|
||||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="executeBulkReprocess"
|
||||
class="px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Start Reprocessing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
101
templates/modals/bulk-tag-modal.html
Normal file
101
templates/modals/bulk-tag-modal.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<!-- Bulk Tag Modal -->
|
||||
<div v-if="showBulkTagModal" @click.self="closeBulkTagModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[100] p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||
<div class="p-6 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-[var(--bg-accent)] rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-tags text-[var(--text-accent)]"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Bulk Tag Update</h3>
|
||||
<p class="text-sm text-[var(--text-muted)]">${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } selected</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeBulkTagModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<!-- Action Toggle -->
|
||||
<div class="flex rounded-lg bg-[var(--bg-tertiary)] p-1 mb-4">
|
||||
<button @click="bulkTagAction = 'add'"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
bulkTagAction === 'add'
|
||||
? 'bg-[var(--bg-button)] text-[var(--text-button)]'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
]">
|
||||
<i class="fas fa-plus mr-2"></i>Add Tag
|
||||
</button>
|
||||
<button @click="bulkTagAction = 'remove'"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
bulkTagAction === 'remove'
|
||||
? 'bg-[var(--bg-button)] text-[var(--text-button)]'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
]">
|
||||
<i class="fas fa-minus mr-2"></i>Remove Tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Select a tag to ${ bulkTagAction }
|
||||
</label>
|
||||
<div class="max-h-64 overflow-y-auto space-y-2 border border-[var(--border-secondary)] rounded-lg p-3 bg-[var(--bg-input)]">
|
||||
<button v-for="tag in availableTags" :key="tag.id"
|
||||
@click="bulkTagSelectedId = tag.id"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors',
|
||||
bulkTagSelectedId == tag.id
|
||||
? 'bg-[var(--bg-accent)] ring-2 ring-[var(--border-accent)]'
|
||||
: 'hover:bg-[var(--bg-tertiary)]'
|
||||
]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: tag.color }"></span>
|
||||
<span class="text-sm text-[var(--text-primary)]">${ tag.name }</span>
|
||||
<i v-if="tag.group_id" class="fas fa-users text-xs text-[var(--text-muted)]" title="Group tag"></i>
|
||||
</div>
|
||||
<i v-if="bulkTagSelectedId == tag.id" class="fas fa-check text-[var(--text-accent)]"></i>
|
||||
</button>
|
||||
<p v-if="availableTags.length === 0" class="text-sm text-[var(--text-muted)] text-center py-4">
|
||||
No tags available. Create tags in settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info message -->
|
||||
<div class="mt-4 p-3 bg-[var(--bg-tertiary)] rounded-lg text-sm text-[var(--text-muted)]">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span v-if="bulkTagAction === 'add'">
|
||||
The selected tag will be added to all ${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' }.
|
||||
</span>
|
||||
<span v-else>
|
||||
The selected tag will be removed from all ${ selectedCount } recording${ selectedCount !== 1 ? 's' : '' } that have it.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3 flex-shrink-0">
|
||||
<button @click="closeBulkTagModal"
|
||||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="executeBulkTag"
|
||||
:disabled="!bulkTagSelectedId"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg transition-colors flex items-center gap-2',
|
||||
bulkTagSelectedId
|
||||
? 'bg-[var(--bg-button)] text-[var(--text-button)] hover:bg-[var(--bg-button-hover)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-muted)] cursor-not-allowed'
|
||||
]">
|
||||
<i :class="['fas', bulkTagAction === 'add' ? 'fa-plus' : 'fa-minus']"></i>
|
||||
${ bulkTagAction === 'add' ? 'Add' : 'Remove' } Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
51
templates/modals/color-scheme-modal.html
Normal file
51
templates/modals/color-scheme-modal.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- Color Scheme Modal -->
|
||||
<div v-if="showColorSchemeModal" class="color-scheme-modal" @click.self="closeColorSchemeModal">
|
||||
<div class="color-scheme-modal-content">
|
||||
<div class="color-scheme-header">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="color-scheme-title">
|
||||
<i class="fas fa-palette"></i>
|
||||
<span v-text="t('colorScheme.title')"></span>
|
||||
</h2>
|
||||
<button @click="closeColorSchemeModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="color-scheme-subtitle" v-text="t('colorScheme.subtitle')"></p>
|
||||
</div>
|
||||
|
||||
<div class="color-scheme-body">
|
||||
<div class="color-scheme-section">
|
||||
<h3 class="color-scheme-section-title">
|
||||
<i :class="isDarkMode ? 'fas fa-moon' : 'fas fa-sun'"></i>
|
||||
<span v-text="isDarkMode ? t('colorScheme.darkThemes') : t('colorScheme.lightThemes')"></span>
|
||||
</h3>
|
||||
<div class="color-scheme-grid">
|
||||
<div v-for="scheme in colorSchemes[isDarkMode ? 'dark' : 'light']"
|
||||
:key="scheme.id"
|
||||
@click="selectColorScheme(scheme.id)"
|
||||
:class="['color-scheme-option', currentColorScheme === scheme.id ? 'active' : '']">
|
||||
<div class="color-scheme-preview">
|
||||
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-primary color-scheme-preview-segment`"></div>
|
||||
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-secondary color-scheme-preview-segment`"></div>
|
||||
<div :class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-tertiary color-scheme-preview-segment`"></div>
|
||||
</div>
|
||||
<div class="color-scheme-name" v-text="t('colorScheme.themes.' + (isDarkMode ? 'dark' : 'light') + '.' + scheme.id + '.name')"></div>
|
||||
<div class="color-scheme-description" v-text="t('colorScheme.themes.' + (isDarkMode ? 'dark' : 'light') + '.' + scheme.id + '.description')"></div>
|
||||
<div v-if="currentColorScheme === scheme.id" class="color-scheme-check">
|
||||
<i class="fas fa-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-scheme-footer">
|
||||
<button @click="resetColorScheme" class="color-scheme-reset-btn">
|
||||
<i class="fas fa-undo mr-2"></i><span v-text="t('colorScheme.resetToDefault')"></span>
|
||||
</button>
|
||||
<button @click="closeColorSchemeModal" class="color-scheme-close-btn" v-text="t('common.close')">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
132
templates/modals/datetime-picker-modal.html
Normal file
132
templates/modals/datetime-picker-modal.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!-- DateTime Picker Modal -->
|
||||
<div v-if="showDateTimePicker" @click.self="closeDateTimePicker" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-sm overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-[var(--border-primary)] bg-[var(--bg-tertiary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
<i class="fas fa-calendar-alt mr-2 text-[var(--text-accent)]"></i>
|
||||
Select Date & Time
|
||||
</h3>
|
||||
<button @click="closeDateTimePicker" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors p-1">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Section -->
|
||||
<div class="p-4">
|
||||
<!-- Month/Year Navigation -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button @click="prevMonth" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<select v-model="pickerMonth" @change="updatePickerView"
|
||||
class="pl-3 pr-8 py-1.5 border border-[var(--border-secondary)] rounded-lg text-sm bg-[var(--bg-input)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
<option v-for="(month, index) in monthNames" :key="index" :value="index">${month}</option>
|
||||
</select>
|
||||
<select v-model="pickerYear" @change="updatePickerView"
|
||||
class="pl-3 pr-8 py-1.5 border border-[var(--border-secondary)] rounded-lg text-sm bg-[var(--bg-input)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
<option v-for="year in availableYears" :key="year" :value="year">${year}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="nextMonth" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Day Names Header -->
|
||||
<div class="grid grid-cols-7 gap-1 mb-2">
|
||||
<div v-for="day in dayNames" :key="day" class="text-center text-xs font-medium text-[var(--text-muted)] py-1">
|
||||
${day}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<button v-for="(day, index) in calendarDays" :key="index"
|
||||
@click="day.inMonth ? selectDate(day.date) : null"
|
||||
:disabled="!day.inMonth"
|
||||
:class="[
|
||||
'aspect-square flex items-center justify-center rounded-lg text-sm transition-all',
|
||||
day.inMonth ? 'hover:bg-[var(--bg-tertiary)]' : 'opacity-30 cursor-default',
|
||||
day.isSelected ? 'bg-[var(--text-accent)] text-white font-bold' : '',
|
||||
day.isToday && !day.isSelected ? 'ring-2 ring-[var(--text-accent)] ring-inset' : '',
|
||||
day.inMonth && !day.isSelected ? 'text-[var(--text-primary)]' : ''
|
||||
]">
|
||||
${day.day}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time Selection -->
|
||||
<div class="mt-4 pt-4 border-t border-[var(--border-secondary)]">
|
||||
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2">
|
||||
<i class="fas fa-clock mr-1"></i> Time
|
||||
</label>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<select v-model="pickerHour"
|
||||
class="pl-3 pr-8 py-2 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-input)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
<option v-for="h in hours12" :key="h.value" :value="h.value">${h.label}</option>
|
||||
</select>
|
||||
<span class="text-xl font-bold text-[var(--text-muted)]">:</span>
|
||||
<select v-model="pickerMinute"
|
||||
class="pl-3 pr-8 py-2 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-input)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
<option v-for="m in minutes" :key="m" :value="m">${m.toString().padStart(2, '0')}</option>
|
||||
</select>
|
||||
<select v-model="pickerAmPm"
|
||||
class="pl-3 pr-8 py-2 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-input)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
<option value="AM">AM</option>
|
||||
<option value="PM">PM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Time Presets -->
|
||||
<div class="mt-3 flex flex-wrap gap-1.5 justify-center">
|
||||
<button @click="pickerHour = 9; pickerMinute = 0; pickerAmPm = 'AM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">9 AM</button>
|
||||
<button @click="pickerHour = 10; pickerMinute = 0; pickerAmPm = 'AM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">10 AM</button>
|
||||
<button @click="pickerHour = 11; pickerMinute = 0; pickerAmPm = 'AM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">11 AM</button>
|
||||
<button @click="pickerHour = 12; pickerMinute = 0; pickerAmPm = 'PM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">12 PM</button>
|
||||
<button @click="pickerHour = 1; pickerMinute = 0; pickerAmPm = 'PM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">1 PM</button>
|
||||
<button @click="pickerHour = 2; pickerMinute = 0; pickerAmPm = 'PM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">2 PM</button>
|
||||
<button @click="pickerHour = 3; pickerMinute = 0; pickerAmPm = 'PM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">3 PM</button>
|
||||
<button @click="pickerHour = 4; pickerMinute = 0; pickerAmPm = 'PM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">4 PM</button>
|
||||
<button @click="pickerHour = 5; pickerMinute = 0; pickerAmPm = 'PM'" class="px-2 py-1 text-xs bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded hover:bg-[var(--bg-input)] hover:text-[var(--text-primary)] transition-colors">5 PM</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-3 flex flex-wrap gap-2 justify-center">
|
||||
<button @click="setToNow" class="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-input)] transition-colors">
|
||||
<i class="fas fa-bolt mr-1"></i> Now
|
||||
</button>
|
||||
<button @click="setToToday" class="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-input)] transition-colors">
|
||||
<i class="fas fa-calendar-day mr-1"></i> Today
|
||||
</button>
|
||||
<button @click="clearDateTime" class="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-input)] transition-colors">
|
||||
<i class="fas fa-eraser mr-1"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-[var(--border-primary)] bg-[var(--bg-tertiary)]">
|
||||
<div class="text-sm text-[var(--text-muted)] mb-3 text-center">
|
||||
<span v-if="pickerSelectedDate" class="font-medium text-[var(--text-primary)]">
|
||||
${formatPickerPreview()}
|
||||
</span>
|
||||
<span v-else class="italic">No date selected</span>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="closeDateTimePicker"
|
||||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="applyDateTime"
|
||||
class="px-4 py-2 bg-[var(--text-accent)] text-white rounded-lg hover:opacity-90 transition-opacity font-medium whitespace-nowrap">
|
||||
<i class="fas fa-check mr-1"></i>Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
32
templates/modals/delete-modal.html
Normal file
32
templates/modals/delete-modal.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" @click.self="cancelDelete" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<i class="fas fa-exclamation-triangle text-3xl text-[var(--text-danger)]"></i>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.deleteRecording')"></h3>
|
||||
<p class="text-[var(--text-muted)]" v-text="t('help.thisActionCannotBeUndone')"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="cancelDelete" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[var(--text-secondary)] mb-6">
|
||||
Are you sure you want to delete "${recordingToDelete?.title || 'this recording'}"?
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="cancelDelete"
|
||||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="deleteRecording"
|
||||
class="px-4 py-2 bg-[var(--bg-danger)] text-white rounded-lg hover:bg-[var(--bg-danger-hover)] transition-colors">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
40
templates/modals/duplicates-modal.html
Normal file
40
templates/modals/duplicates-modal.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!-- Duplicates List Modal -->
|
||||
<div v-if="showDuplicatesModal && duplicatesModalData" @click.self="showDuplicatesModal = false" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="flex items-center justify-center h-9 w-9 rounded-full bg-amber-100 dark:bg-amber-900/50">
|
||||
<i class="fas fa-copy text-amber-500 dark:text-amber-400"></i>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">
|
||||
${ duplicatesModalData.total_copies } ${ t('upload.copies') || 'copies' }
|
||||
</h3>
|
||||
</div>
|
||||
<button @click="showDuplicatesModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<button v-for="copy in duplicatesModalData.copies" :key="copy.id"
|
||||
@click="navigateToDuplicate(copy.id)"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||
:class="copy.is_self
|
||||
? 'bg-[var(--bg-accent-light)] border border-[var(--border-focus)]'
|
||||
: 'hover:bg-[var(--bg-tertiary)]'">
|
||||
<i class="fas fa-file-audio text-sm flex-shrink-0"
|
||||
:class="copy.is_self ? 'text-[var(--text-accent)]' : 'text-[var(--text-muted)]'"></i>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate"
|
||||
:class="copy.is_self ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]'">
|
||||
${ copy.title }
|
||||
<span v-if="copy.is_self" class="text-xs font-normal text-[var(--text-muted)] ml-1">(current)</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-muted)]">${ copy.created_at }</div>
|
||||
</div>
|
||||
<i v-if="!copy.is_self" class="fas fa-chevron-right text-xs text-[var(--text-muted)]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
50
templates/modals/edit-modal.html
Normal file
50
templates/modals/edit-modal.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!-- Edit Modal -->
|
||||
<div v-if="showEditModal" @click.self="cancelEdit" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-lg">
|
||||
<div class="p-6 border-b border-[var(--border-primary)] flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.editRecording')"></h3>
|
||||
<button @click="cancelEdit" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" v-text="t('form.title')"></label>
|
||||
<input v-model="editingRecording.title"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" v-text="t('form.participants')"></label>
|
||||
<input v-model="editingRecording.participants"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" v-text="t('form.meetingDate')"></label>
|
||||
<div @click="openDateTimePicker('edit_modal_meeting_date', editingRecording.meeting_date, (isoString) => { editingRecording.meeting_date = isoString; })"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg cursor-pointer hover:border-[var(--border-focus)] transition-colors flex items-center justify-between">
|
||||
<span :class="editingRecording.meeting_date ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'">
|
||||
${editingRecording.meeting_date ? formatDisplayDate(editingRecording.meeting_date) : 'Select date and time...'}
|
||||
</span>
|
||||
<i class="fas fa-calendar-alt text-[var(--text-muted)]"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" v-text="t('form.notes')"></label>
|
||||
<textarea v-model="editingRecording.notes"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3">
|
||||
<button @click="cancelEdit"
|
||||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveEdit"
|
||||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
61
templates/modals/edit-participants-modal.html
Normal file
61
templates/modals/edit-participants-modal.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- Edit Participants Modal -->
|
||||
<div v-if="showEditParticipantsModal" @click.self="closeEditParticipantsModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div @click="closeAllParticipantSuggestions" class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||
<div class="p-6 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.editParticipants')"></h3>
|
||||
<button @click="closeEditParticipantsModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1" @scroll="closeAllParticipantSuggestions">
|
||||
<p class="text-sm text-[var(--text-muted)] mb-4">Manage participants for this recording.</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="(participant, index) in editingParticipantsList" :key="index" class="relative">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 relative" @click.stop>
|
||||
<input v-model="participant.name"
|
||||
@input="filterParticipantSuggestions(index)"
|
||||
@focus="filterParticipantSuggestions(index)"
|
||||
@blur="closeParticipantSuggestionsDelayed(index)"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm"
|
||||
placeholder="Participant name...">
|
||||
</div>
|
||||
<!-- Teleported Suggestions dropdown -->
|
||||
<teleport to="body">
|
||||
<div v-if="editingParticipantSuggestions[index]?.length > 0"
|
||||
class="fixed z-[100] bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg max-h-32 overflow-y-auto"
|
||||
:style="getParticipantDropdownPosition(index)">
|
||||
<button v-for="suggestion in editingParticipantSuggestions[index]" :key="suggestion"
|
||||
@mousedown="selectParticipantSuggestion(index, suggestion)"
|
||||
class="w-full px-3 py-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors text-sm">
|
||||
${suggestion}
|
||||
</button>
|
||||
</div>
|
||||
</teleport>
|
||||
<button @click="removeParticipant(index)"
|
||||
class="p-1 text-[var(--text-muted)] hover:text-red-500 transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="addParticipant"
|
||||
class="mt-4 w-full px-3 py-2 border border-dashed border-[var(--border-secondary)] rounded-lg text-sm text-[var(--text-muted)] hover:border-[var(--border-accent)] hover:text-[var(--text-accent)] transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Add Participant
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3">
|
||||
<button @click="closeEditParticipantsModal" class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveParticipants" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
63
templates/modals/edit-speakers-modal.html
Normal file
63
templates/modals/edit-speakers-modal.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!-- Edit Speakers Modal -->
|
||||
<div v-if="showEditSpeakersModal" @click.self="closeEditSpeakersModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||
<div class="p-6 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.editSpeakers')"></h3>
|
||||
<button @click="closeEditSpeakersModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<p class="text-sm text-[var(--text-muted)] mb-4">Rename speakers in the transcript.</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="(speaker, index) in editingSpeakersList" :key="index" class="relative">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-24 text-sm text-[var(--text-muted)] truncate">${speaker.original || 'New'}</span>
|
||||
<i class="fas fa-arrow-right text-[var(--text-muted)]"></i>
|
||||
<div class="flex-1 relative" :ref="'editSpeakerInput' + index">
|
||||
<input v-model="speaker.current"
|
||||
@input="filterEditingSpeakerSuggestions(index)"
|
||||
@focus="filterEditingSpeakerSuggestions(index)"
|
||||
@blur="onEditSpeakerBlur(index)"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm"
|
||||
placeholder="New name...">
|
||||
</div>
|
||||
<!-- Teleported Suggestions dropdown -->
|
||||
<teleport to="body">
|
||||
<div v-if="editingSpeakerSuggestions[index]?.length > 0"
|
||||
class="fixed z-[100] bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg max-h-32 overflow-y-auto"
|
||||
:style="getEditSpeakerDropdownPosition(index)">
|
||||
<button v-for="suggestion in editingSpeakerSuggestions[index]" :key="suggestion.id"
|
||||
@mousedown="selectEditingSpeakerSuggestion(index, suggestion.name)"
|
||||
class="w-full px-3 py-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors text-sm">
|
||||
${suggestion.name}
|
||||
</button>
|
||||
</div>
|
||||
</teleport>
|
||||
<button @click="removeEditingSpeaker(index)"
|
||||
class="p-1 text-[var(--text-muted)] hover:text-red-500 transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="addEditingSpeaker"
|
||||
class="mt-4 w-full px-3 py-2 border border-dashed border-[var(--border-secondary)] rounded-lg text-sm text-[var(--text-muted)] hover:border-[var(--border-accent)] hover:text-[var(--text-accent)] transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Add Speaker
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3">
|
||||
<button @click="closeEditSpeakersModal" class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveEditingSpeakers" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
85
templates/modals/edit-tags-modal.html
Normal file
85
templates/modals/edit-tags-modal.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!-- Edit Tags Modal -->
|
||||
<div v-if="showEditTagsModal" @click.self="closeEditTagsModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||
<div class="p-6 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.editTags')"></h3>
|
||||
<button @click="closeEditTagsModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<!-- Current Tags with Drag Reorder -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2" v-text="t('tags.currentTags')"></label>
|
||||
<div v-if="editingRecording && editingRecording.tags && editingRecording.tags.length > 0"
|
||||
class="flex flex-wrap gap-2"
|
||||
@touchmove="handleModalTagTouchMove">
|
||||
<span v-for="(tag, index) in editingRecording.tags" :key="tag.id"
|
||||
:data-modal-tag-index="index"
|
||||
draggable="true"
|
||||
@dragstart="handleModalTagDragStart(index, $event)"
|
||||
@dragover="handleModalTagDragOver(index, $event)"
|
||||
@drop="handleModalTagDrop(index, $event)"
|
||||
@dragend="handleModalTagDragEnd"
|
||||
@touchstart="handleModalTagTouchStart(index, $event)"
|
||||
@touchend="handleModalTagTouchEnd"
|
||||
:class="[
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium transition-all duration-150',
|
||||
modalDraggedTagIndex === index ? 'opacity-50 cursor-grabbing' : 'cursor-grab',
|
||||
modalDragOverTagIndex === index && modalDraggedTagIndex !== index ? 'ring-2 ring-[var(--ring-focus)] ring-offset-1' : ''
|
||||
]"
|
||||
:style="{ backgroundColor: tag.color, color: getContrastTextColor(tag.color) }"
|
||||
:title="(tag.group_id ? ('Group: ' + tag.group_name) : tag.name) + ' (drag to reorder)'">
|
||||
<span class="opacity-75 mr-1 text-xs">${index + 1}.</span>
|
||||
<i v-if="tag.group_id" class="fas fa-users mr-1.5 text-xs"></i>
|
||||
<span v-if="tag.group_id" class="opacity-75">${ tag.group_name }: </span>${ tag.name }
|
||||
<button @click.stop="removeTagFromRecording(tag.id)" class="ml-2 hover:opacity-80">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-sm text-[var(--text-muted)] italic" v-text="t('tags.noTags')"></p>
|
||||
<p v-if="editingRecording && editingRecording.tags && editingRecording.tags.length > 1" class="text-xs text-[var(--text-muted)] mt-2">
|
||||
<i class="fas fa-grip-vertical mr-1" style="font-size: 10px;"></i>
|
||||
${ t('help.dragToReorder') } • ${ t('help.firstTagDefaultsApplied') }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Add Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2" v-text="t('tags.addTag')"></label>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative mb-3">
|
||||
<input v-model="tagSearchFilter" type="text" :placeholder="t('tagsModal.searchTags')"
|
||||
class="w-full px-3 py-2 pl-9 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]"></i>
|
||||
</div>
|
||||
|
||||
<!-- Available Tags -->
|
||||
<div class="max-h-48 overflow-y-auto space-y-2">
|
||||
<button v-for="tag in filteredAvailableTagsForModal" :key="tag.id"
|
||||
@click="addTagToRecording(tag.id)"
|
||||
class="w-full flex items-center justify-between px-3 py-2 rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<i v-if="tag.group_id" class="fas fa-users text-xs flex-shrink-0" :style="{ color: tag.color }"></i>
|
||||
<span v-else class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: tag.color }"></span>
|
||||
<span class="text-sm">
|
||||
<span v-if="tag.group_id" class="opacity-75">${ tag.group_name }: </span>${ tag.name }
|
||||
</span>
|
||||
</div>
|
||||
<i class="fas fa-plus text-[var(--text-muted)]"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="filteredAvailableTagsForModal.length === 0" class="text-sm text-[var(--text-muted)] text-center py-4" v-text="t('tags.noAvailableTags')"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end">
|
||||
<button @click="closeEditTagsModal" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||||
<span v-text="t('tagsModal.done')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
38
templates/modals/edit-text-modal.html
Normal file
38
templates/modals/edit-text-modal.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!-- Edit Text Modal -->
|
||||
<div v-if="showEditTextModal" @click.self="closeEditTextModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl">
|
||||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold text-[var(--text-primary)]">Edit Transcript Text</h3>
|
||||
<button @click="closeEditTextModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
|
||||
<!-- Text editor -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Text Content
|
||||
</label>
|
||||
<textarea
|
||||
v-model="editedText"
|
||||
rows="6"
|
||||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)] resize-y"
|
||||
placeholder="Enter transcript text..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button @click="closeEditTextModal" class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveEditedText" class="px-4 py-2 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-lg hover:bg-[var(--bg-accent-hover)] transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
18
templates/modals/global-error.html
Normal file
18
templates/modals/global-error.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- Global Error Display - DEPRECATED: Now using toast system for all errors -->
|
||||
<!-- Keeping this commented out in case we need to revert
|
||||
<div v-if="globalError"
|
||||
class="fixed top-4 right-4 text-white p-4 rounded-lg shadow-lg z-[60] max-w-md border"
|
||||
style="background-color: #ef4444; border-color: #dc2626;">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-exclamation-circle text-xl flex-shrink-0 mt-0.5"></i>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold mb-1" v-text="t('common.error')"></p>
|
||||
<p class="text-sm">${globalError}</p>
|
||||
</div>
|
||||
<button @click="globalError = null"
|
||||
class="text-white hover:opacity-80 transition-opacity flex-shrink-0">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
36
templates/modals/recording-disclaimer-modal.html
Normal file
36
templates/modals/recording-disclaimer-modal.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!-- Recording Disclaimer Modal -->
|
||||
<div v-if="showRecordingDisclaimerModal" @click.self="cancelRecordingDisclaimer" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||
<div class="flex-shrink-0 p-6 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-info-circle text-2xl text-[var(--text-accent)]"></i>
|
||||
<h3 class="text-xl font-semibold text-[var(--text-primary)]" v-text="t('modal.recordingNotice')"></h3>
|
||||
</div>
|
||||
<button @click="cancelRecordingDisclaimer" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Render markdown content -->
|
||||
<div class="ai-message text-[var(--text-secondary)]"
|
||||
style="line-height: 1.6;">
|
||||
<div v-html="recordingDisclaimerHtml"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 p-6 border-t border-[var(--border-primary)] bg-[var(--bg-tertiary)] rounded-b-lg">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="cancelRecordingDisclaimer"
|
||||
class="px-6 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors border border-[var(--border-secondary)]">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="acceptRecordingDisclaimer"
|
||||
class="px-6 py-2 bg-[var(--bg-accent)] text-white rounded-lg hover:bg-[var(--bg-accent-hover)] transition-colors">
|
||||
<i class="fas fa-microphone mr-2"></i>
|
||||
Start Recording
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
64
templates/modals/recording-recovery-modal.html
Normal file
64
templates/modals/recording-recovery-modal.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!-- Recording Recovery Modal -->
|
||||
<div v-if="showRecoveryModal" @click.self="cancelRecovery" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-history text-2xl text-blue-500"></i>
|
||||
<h3 class="text-lg font-semibold" v-text="t('recording.recoveryTitle')"></h3>
|
||||
</div>
|
||||
<button @click="cancelRecovery" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mt-0.5"></i>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-400">
|
||||
<p class="font-medium mb-1">${ t('recording.recoveryFound') }</p>
|
||||
<p>${ t('recording.recoveryDescription') }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recoverableRecording" class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-[var(--text-muted)]">${ t('recording.recordingMode') }:</span>
|
||||
<span class="font-medium">${ formatRecordingMode(recoverableRecording.mode) }</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-[var(--text-muted)]">${ t('recording.duration') }:</span>
|
||||
<span class="font-medium">${ formatTime(recoverableRecording.duration) }</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-[var(--text-muted)]">${ t('recording.size') }:</span>
|
||||
<span class="font-medium">${ formatFileSize(recoverableRecording.totalSize) }</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-[var(--text-muted)]">${ t('recording.startedAt') }:</span>
|
||||
<span class="font-medium">${ formatDateTime(recoverableRecording.startTime) }</span>
|
||||
</div>
|
||||
<div v-if="recoverableRecording.notes" class="pt-2 border-t border-[var(--border-secondary)]">
|
||||
<span class="text-[var(--text-muted)]">${ t('recording.notes') }:</span>
|
||||
<p class="mt-1 text-[var(--text-primary)] max-h-32 overflow-y-auto text-xs whitespace-pre-wrap">${ recoverableRecording.notes }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex gap-3">
|
||||
<button
|
||||
@click="cancelRecovery"
|
||||
class="flex-1 px-4 py-2.5 bg-[var(--bg-tertiary)] text-[var(--text-primary)] rounded-lg hover:bg-[var(--bg-hover)] transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>${ t('recording.discardRecovery') }
|
||||
</button>
|
||||
<button
|
||||
@click="recoverRecording"
|
||||
class="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-undo mr-2"></i>${ t('recording.restoreRecording') }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
184
templates/modals/reprocess-modal.html
Normal file
184
templates/modals/reprocess-modal.html
Normal file
@@ -0,0 +1,184 @@
|
||||
<!-- Reprocess Modal -->
|
||||
<div v-if="showReprocessModal" @click.self="cancelReprocess" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm transition-all duration-300 ease-in-out">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] flex flex-col transform transition-all duration-300 ease-in-out">
|
||||
<!-- Header with gradient background -->
|
||||
<div class="bg-gradient-to-r from-[var(--bg-accent)] to-[var(--bg-secondary)] p-5 rounded-t-xl flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mr-4 shadow-lg">
|
||||
<i class="fas fa-sync-alt text-white text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-[var(--text-primary)] mb-1" v-text="t('help.confirmReprocessingTitle')"></h3>
|
||||
<p class="text-sm text-[var(--text-muted)] capitalize">${ reprocessType } reprocessing</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="cancelReprocess" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 modal-content overflow-y-auto flex-1">
|
||||
<div v-if="reprocessRecording" class="mb-6">
|
||||
<div class="bg-[var(--bg-tertiary)] rounded-lg p-4 border border-[var(--border-primary)]">
|
||||
<div class="flex items-start space-x-3">
|
||||
<i class="fas fa-file-audio text-[var(--text-accent)] mt-1"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-medium text-[var(--text-primary)] truncate" :title="reprocessRecording.title">
|
||||
${ reprocessRecording.title || 'Untitled Recording' }
|
||||
</h4>
|
||||
<p class="text-sm text-[var(--text-muted)] mt-1">
|
||||
Created: ${ new Date(reprocessRecording.created_at).toLocaleDateString() }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-6 h-6 bg-amber-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[var(--text-secondary)] font-medium mb-1" v-text="t('help.whatWillHappen')"></p>
|
||||
<p class="text-sm text-[var(--text-muted)]" v-if="reprocessType === 'transcription' && !connectorSupportsDiarization">
|
||||
<span v-text="t('reprocessModal.audioReTranscribedFromScratch')"></span>
|
||||
</p>
|
||||
<p class="text-sm text-[var(--text-muted)]" v-else-if="reprocessType === 'transcription' && connectorSupportsDiarization">
|
||||
<span v-text="t('reprocessModal.audioReTranscribedWithAsr')"></span>
|
||||
</p>
|
||||
<p class="text-sm text-[var(--text-muted)]" v-else-if="reprocessType === 'summary'">
|
||||
<span v-text="t('reprocessModal.newTitleAndSummary')"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Selection - show for all transcription reprocessing -->
|
||||
<div v-if="reprocessType === 'transcription'" class="space-y-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<div>
|
||||
<label for="asr-language-reprocess" class="block text-sm font-medium text-[var(--text-muted)]" v-text="t('form.language')"></label>
|
||||
<select id="asr-language-reprocess" v-model="asrReprocessOptions.language" class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">
|
||||
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value" v-text="lang.label"></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diarization Options (for connectors that support it) -->
|
||||
<div v-if="reprocessType === 'transcription' && connectorSupportsDiarization && connectorSupportsSpeakerCount" class="space-y-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<h4 class="text-md font-semibold text-[var(--text-secondary)]" v-text="t('help.advancedAsrOptions')"></h4>
|
||||
<!-- Min/Max Speakers - only show for connectors that support it (ASR endpoint, not OpenAI) -->
|
||||
<div>
|
||||
<label for="asr-min-speakers-reprocess" class="block text-sm font-medium text-[var(--text-muted)]" v-text="t('form.minSpeakers')"></label>
|
||||
<input type="number" id="asr-min-speakers-reprocess" v-model.number="asrReprocessOptions.min_speakers" class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]" :placeholder="t('form.optional')">
|
||||
</div>
|
||||
<div>
|
||||
<label for="asr-max-speakers-reprocess" class="block text-sm font-medium text-[var(--text-muted)]" v-text="t('form.maxSpeakers')"></label>
|
||||
<input type="number" id="asr-max-speakers-reprocess" v-model.number="asrReprocessOptions.max_speakers" class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]" :placeholder="t('form.optional')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Prompt Options for Summary Reprocessing -->
|
||||
<div v-if="reprocessType === 'summary'" class="space-y-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<h4 class="text-md font-semibold text-[var(--text-secondary)]">Custom Summarization Prompt</h4>
|
||||
|
||||
<!-- Prompt Source Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2">Prompt Source</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" v-model="summaryReprocessPromptSource" value="default" class="text-[var(--text-accent)]" name="promptSource">
|
||||
<span class="text-sm text-[var(--text-secondary)]">Use default prompt</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer" v-if="tagsWithCustomPrompts.length > 0">
|
||||
<input type="radio" v-model="summaryReprocessPromptSource" value="tag" class="text-[var(--text-accent)]" name="promptSource">
|
||||
<span class="text-sm text-[var(--text-secondary)]">Use prompt from tag</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" v-model="summaryReprocessPromptSource" value="custom" class="text-[var(--text-accent)]" name="promptSource">
|
||||
<span class="text-sm text-[var(--text-secondary)]">Enter custom prompt</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Selection (shown when 'tag' is selected) -->
|
||||
<div v-if="summaryReprocessPromptSource === 'tag' && tagsWithCustomPrompts.length > 0">
|
||||
<label for="reprocess-tag-select" class="block text-sm font-medium text-[var(--text-muted)] mb-2">Select Tag</label>
|
||||
<select id="reprocess-tag-select" v-model="summaryReprocessSelectedTagId" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] text-sm bg-[var(--bg-input)] text-[var(--text-primary)]">
|
||||
<option value="">Select a tag...</option>
|
||||
<option v-for="tag in tagsWithCustomPrompts" :key="tag.id" :value="tag.id">${ tag.name }</option>
|
||||
</select>
|
||||
<div v-if="summaryReprocessSelectedTagId" class="mt-2 p-2 bg-[var(--bg-tertiary)] rounded text-xs text-[var(--text-muted)] max-h-20 overflow-y-auto">
|
||||
<strong>Preview:</strong> ${ getTagPromptPreview(summaryReprocessSelectedTagId) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Prompt Textarea (shown when 'custom' is selected) -->
|
||||
<div v-if="summaryReprocessPromptSource === 'custom'">
|
||||
<label for="reprocess-custom-prompt" class="block text-sm font-medium text-[var(--text-muted)] mb-2">Custom Prompt</label>
|
||||
<textarea id="reprocess-custom-prompt" v-model="summaryReprocessCustomPrompt" rows="4" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] text-sm bg-[var(--bg-input)] text-[var(--text-primary)]" placeholder="Enter your custom summarization instructions..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Info message when no tags with custom prompts exist -->
|
||||
<div v-if="summaryReprocessPromptSource === 'tag' && tagsWithCustomPrompts.length === 0" class="text-xs text-[var(--text-muted)] italic">
|
||||
No tags with custom prompts available. Create tags with custom prompts in the Account Settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-clock text-blue-600 text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[var(--text-secondary)] font-medium mb-1" v-text="t('help.processingTime')"></p>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
<span v-text="t('help.processingTimeDescription')"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3" v-if="reprocessType === 'transcription'">
|
||||
<div class="w-6 h-6 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-info-circle text-red-600 text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[var(--text-secondary)] font-medium mb-1" v-text="t('help.importantNote')"></p>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
<span v-text="t('reprocessModal.manualEditsOverwritten')"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3" v-else-if="reprocessType === 'summary'">
|
||||
<div class="w-6 h-6 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-info-circle text-red-600 text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[var(--text-secondary)] font-medium mb-1" v-text="t('help.importantNote')"></p>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
<span v-text="t('reprocessModal.manualEditsOverwrittenSummary')"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with action buttons -->
|
||||
<div class="bg-[var(--bg-tertiary)] px-6 py-4 rounded-b-xl flex justify-end space-x-3 border-t border-[var(--border-primary)] flex-shrink-0">
|
||||
<button
|
||||
@click="cancelReprocess"
|
||||
class="px-5 py-2.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)] transition-all duration-200 flex items-center shadow-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="executeReprocess"
|
||||
class="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center shadow-lg font-medium transform hover:scale-105">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Start Reprocessing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
31
templates/modals/reset-modal.html
Normal file
31
templates/modals/reset-modal.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!-- Reset Status Confirmation Modal -->
|
||||
<div v-if="showResetModal" @click.self="cancelReset" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-orange-100 dark:bg-orange-800">
|
||||
<i class="fas fa-exclamation-triangle text-2xl text-orange-500 dark:text-orange-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2" v-text="t('modal.resetStatus')"></h3>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="cancelReset" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-muted)] mb-6">
|
||||
This will mark the recording as 'Failed'. This is useful if processing is stuck. You will be able to reprocess it afterwards.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-[var(--bg-tertiary)] px-6 py-4 rounded-b-xl flex justify-end space-x-3">
|
||||
<button @click="cancelReset" class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="executeReset" class="px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600">
|
||||
Yes, Reset Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
52
templates/modals/share-delete-modal.html
Normal file
52
templates/modals/share-delete-modal.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!-- Share Delete Confirmation Modal -->
|
||||
<div v-if="showShareDeleteModal" @click.self="cancelDeleteShare" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md transform transition-all duration-300 ease-in-out">
|
||||
<!-- Header with gradient background -->
|
||||
<div class="bg-gradient-to-r from-red-500 to-red-600 p-5 rounded-t-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-white/20 backdrop-blur rounded-full flex items-center justify-center mr-4">
|
||||
<i class="fas fa-trash-alt text-white text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-white">Delete Shared Link</h3>
|
||||
<p class="text-red-100 text-sm mt-1">This action cannot be undone</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="cancelDeleteShare" class="text-white/80 hover:text-white transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<p class="text-[var(--text-secondary)] mb-4">
|
||||
Are you sure you want to delete the shared link for:
|
||||
</p>
|
||||
<div v-if="shareToDelete" class="bg-[var(--bg-tertiary)] p-3 rounded-lg border border-[var(--border-primary)]">
|
||||
<p class="font-medium text-[var(--text-primary)]">${shareToDelete.recording_title}</p>
|
||||
<p class="text-sm text-[var(--text-muted)] mt-1">Shared on: ${shareToDelete.created_at}</p>
|
||||
</div>
|
||||
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 mt-0.5 mr-2"></i>
|
||||
<p class="text-sm text-red-800 dark:text-red-300">
|
||||
The share link will be permanently deleted and anyone with this link will no longer be able to access the recording.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="p-6 pt-0 flex justify-end gap-3">
|
||||
<button @click="cancelDeleteShare" class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="deleteShare" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-2">
|
||||
<i class="fas fa-trash"></i>
|
||||
Delete Share Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
82
templates/modals/share-modal.html
Normal file
82
templates/modals/share-modal.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<!-- Share Modal -->
|
||||
<div v-if="showShareModal" @click.self="closeShareModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-[var(--border-primary)] flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.shareRecording')"></h3>
|
||||
<button @click="closeShareModal" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-3 overflow-y-auto flex-1">
|
||||
<p class="text-xs text-[var(--text-muted)]" v-text="t('help.createPublicLink')"></p>
|
||||
|
||||
<!-- Share Options -->
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="shareOptions.share_summary" id="share_summary" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-xs" v-text="t('form.shareSummary')"></span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="shareOptions.share_notes" id="share_notes" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-xs" v-text="t('form.shareNotes')"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Create New Share Button -->
|
||||
<button @click="createShare(true)" class="w-full px-3 py-2 bg-[var(--bg-accent)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm font-medium">
|
||||
<i class="fas fa-plus-circle mr-1.5"></i>Create New Share Link
|
||||
</button>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingPublicShares" class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Loading share links...</p>
|
||||
</div>
|
||||
|
||||
<!-- Existing Shares List -->
|
||||
<div v-else-if="recordingPublicShares.length > 0" class="space-y-2">
|
||||
<h4 class="text-xs font-medium text-[var(--text-secondary)]">
|
||||
Existing Share Links (${recordingPublicShares.length})
|
||||
</h4>
|
||||
<div v-for="share in recordingPublicShares" :key="share.id" class="bg-[var(--bg-tertiary)] p-2.5 rounded-lg border border-[var(--border-primary)]">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-muted)]">${ t('help.sharedOn') }: ${share.created_at}</p>
|
||||
</div>
|
||||
<button @click="confirmDeletePublicShare(share)" class="text-red-500 hover:text-red-700 p-1">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<label class="flex items-center text-xs">
|
||||
<input type="checkbox" v-model="share.share_summary" @change="updateShare(share)" class="h-3.5 w-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-1.5" v-text="t('form.shareSummary')"></span>
|
||||
</label>
|
||||
<label class="flex items-center text-xs">
|
||||
<input type="checkbox" v-model="share.share_notes" @change="updateShare(share)" class="h-3.5 w-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-1.5" v-text="t('form.shareNotes')"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input :value="share.share_url" :id="'share-link-' + share.id" readonly class="w-full px-2 py-1.5 pr-10 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs font-mono">
|
||||
<button @click="copyPublicShareLinkWithFeedback(share.share_url, share.id)" class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-7 flex items-center justify-center rounded bg-[var(--bg-button)] text-[var(--text-button)] hover:bg-[var(--bg-button-hover)] transition-colors" :title="t('buttons.copy')">
|
||||
<i :class="copiedShareId === share.id ? 'fas fa-check' : 'fas fa-copy'" class="text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Shares Yet -->
|
||||
<div v-else-if="!isLoadingPublicShares" class="text-center py-8 text-[var(--text-muted)]">
|
||||
<i class="fas fa-link text-3xl mb-2"></i>
|
||||
<p v-text="t('sharedTranscripts.noSharedTranscripts')"></p>
|
||||
<p class="text-xs mt-1">Click the button above to create one</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 border-t border-[var(--border-primary)] flex justify-end">
|
||||
<button @click="closeShareModal" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm font-medium">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
46
templates/modals/shares-list-modal.html
Normal file
46
templates/modals/shares-list-modal.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!-- Shares List Modal -->
|
||||
<div v-if="showSharesListModal" @click.self="closeSharesList" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<div class="p-6 border-b border-[var(--border-primary)] flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.sharedTranscripts')"></h3>
|
||||
<button @click="closeSharesList" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4 overflow-y-auto">
|
||||
<div v-if="isLoadingShares" class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||||
</div>
|
||||
<div v-else-if="userShares.length === 0" class="text-center text-[var(--text-muted)]">
|
||||
<span v-text="t('sharedTranscripts.noSharedTranscripts')"></span>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="share in userShares" :key="share.id" class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-semibold">${share.recording_title}</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">${ t('help.sharedOn') }: ${share.created_at}</p>
|
||||
</div>
|
||||
<button @click="confirmDeleteShare(share)" class="text-red-500 hover:text-red-700 p-1"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox" v-model="share.share_summary" @change="updateShare(share)" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2" v-text="t('form.shareSummary')"></span>
|
||||
</label>
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox" v-model="share.share_notes" @change="updateShare(share)" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2" v-text="t('form.shareNotes')"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 relative">
|
||||
<input :value="'{{ request.url_root }}share/' + share.public_id" :id="'share-link-' + share.id" readonly class="w-full px-3 py-2 pr-12 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[var(--border-accent)]">
|
||||
<button @click="copyIndividualShareLink(share.id)" class="absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded bg-[var(--bg-button)] text-[var(--text-button)] hover:bg-[var(--bg-button-hover)] transition-colors" :title="t('buttons.copy')">
|
||||
<i :class="copiedShareId === share.id ? 'fas fa-check' : 'fas fa-copy'" class="text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
353
templates/modals/speaker-modal.html
Normal file
353
templates/modals/speaker-modal.html
Normal file
@@ -0,0 +1,353 @@
|
||||
<!-- Speaker Identification Modal -->
|
||||
<div v-if="showSpeakerModal" @click.self="closeSpeakerModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-2 sm:p-4 backdrop-blur-sm">
|
||||
<div @click="closeSpeakerSuggestionsOnClick" class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-4xl flex flex-col h-[95vh] sm:h-[85vh]">
|
||||
<!-- Header -->
|
||||
<div class="p-3 sm:p-5 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base sm:text-xl font-bold text-[var(--text-primary)]" v-text="t('modal.identifySpeakers')"></h3>
|
||||
<button @click="closeSpeakerModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Warning for too many speakers -->
|
||||
<div v-if="modalSpeakers.length > 16" class="mt-3 p-3 bg-[var(--bg-warn-light)] border border-amber-300 dark:border-amber-800 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle text-[var(--text-warn-strong)] mt-0.5 mr-2"></i>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-[var(--text-warn-strong)]" v-text="t('help.moreSpeakersThanColors')"></p>
|
||||
<p class="text-[var(--text-warn-strong)] mt-1" v-text="t('help.youHaveXSpeakers', { count: modalSpeakers.length })">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Tab Switcher (hidden on lg screens) -->
|
||||
<div class="lg:hidden flex border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<button
|
||||
@click="speakerModalTab = 'speakers'"
|
||||
class="flex-1 py-2 text-xs font-medium transition-colors relative"
|
||||
:class="speakerModalTab === 'speakers' ? 'text-[var(--text-accent)]' : 'text-[var(--text-muted)]'">
|
||||
<i class="fas fa-users mr-1"></i>Speakers
|
||||
<span v-if="speakerModalTab === 'speakers'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--bg-accent)]"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="speakerModalTab = 'transcript'"
|
||||
class="flex-1 py-2 text-xs font-medium transition-colors relative"
|
||||
:class="speakerModalTab === 'transcript' ? 'text-[var(--text-accent)]' : 'text-[var(--text-muted)]'">
|
||||
<i class="fas fa-file-lines mr-1"></i>Transcript
|
||||
<span v-if="speakerModalTab === 'transcript'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--bg-accent)]"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content - Tab-based on mobile, side-by-side on desktop -->
|
||||
<div class="flex-grow flex flex-col lg:flex-row overflow-hidden modal-content">
|
||||
<!-- Speaker List Panel - shown on mobile when speakers tab active, always on desktop -->
|
||||
<div class="lg:w-1/3 p-3 sm:p-6 space-y-2 sm:space-y-4 overflow-y-auto custom-scrollbar lg:border-r border-[var(--border-primary)]"
|
||||
:class="{ 'hidden lg:block': speakerModalTab !== 'speakers', 'flex-1': speakerModalTab === 'speakers' }">
|
||||
<div v-for="(speaker, index) in modalSpeakers" :key="`${selectedRecording.id}-speaker-${index}-${speaker}`" class="space-y-1.5 sm:space-y-3">
|
||||
<!-- Speaker label with color indicator and "This is Me" checkbox -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center space-x-1.5 min-w-0">
|
||||
<div :class="speakerMap[speaker].color" class="w-3 h-3 sm:w-4 sm:h-4 rounded-full border border-white/30 shadow-sm flex-shrink-0"></div>
|
||||
<span class="font-mono text-xs sm:text-sm text-[var(--text-muted)] truncate">${ speakerDisplayMap[speaker] || speaker }</span>
|
||||
<span v-if="index >= 16" class="text-[10px] text-[var(--text-warn-strong)] bg-[var(--bg-warn-light)] px-1.5 py-0.5 rounded-full flex-shrink-0" :title="`Color repeats from speaker ${((index % 16) + 1)}`">
|
||||
Repeat
|
||||
</span>
|
||||
</div>
|
||||
<label class="flex items-center text-xs sm:text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors flex-shrink-0">
|
||||
<input type="checkbox"
|
||||
v-model="speakerMap[speaker].isMe"
|
||||
@change="handleIsMeChange(speaker)"
|
||||
@focus="highlightSpeakerInTranscript(speaker)"
|
||||
@blur="clearSpeakerHighlight"
|
||||
class="speaker-checkbox w-4 h-4 sm:w-4 sm:h-4">
|
||||
<span class="ml-1.5 select-none" v-text="t('help.me')"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete input field -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
v-model="speakerMap[speaker].name"
|
||||
@input="searchSpeakers($event.target.value, speaker)"
|
||||
@focus="focusSpeaker(speaker)"
|
||||
@blur="blurSpeaker()"
|
||||
:disabled="speakerMap[speaker].isMe"
|
||||
class="w-full px-2 py-2 sm:py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] focus:border-[var(--border-focus)] text-sm bg-[var(--bg-input)] text-[var(--text-primary)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed"
|
||||
:class="{ 'pr-28': shouldShowVoiceSuggestionPill(speaker) }"
|
||||
:placeholder="speakerMap[speaker].isMe ? currentUserName : t('help.enterNameFor') + ' ' + (speakerDisplayMap[speaker] || speaker)"
|
||||
autocomplete="off">
|
||||
|
||||
<!-- Voice match suggestion pill -->
|
||||
<button v-if="shouldShowVoiceSuggestionPill(speaker)"
|
||||
@click.stop="applyVoiceSuggestion(speaker, voiceSuggestions[speaker][0])"
|
||||
class="absolute right-1.5 top-1/2 transform -translate-y-1/2 px-1.5 py-0.5 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-[10px] rounded-full flex items-center gap-0.5 hover:from-blue-600 hover:to-purple-600 transition-all shadow-sm hover:shadow group active:scale-95">
|
||||
<i class="fas fa-waveform-lines text-[9px]"></i>
|
||||
<span class="font-medium truncate max-w-[50px] sm:max-w-[80px]">${ voiceSuggestions[speaker][0].name }</span>
|
||||
<span class="text-[9px] opacity-90">${ voiceSuggestions[speaker][0].similarity }%</span>
|
||||
</button>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="loadingSuggestions[speaker] && !speakerMap[speaker].isMe" class="absolute right-2 top-1/2 transform -translate-y-1/2">
|
||||
<i class="fas fa-spinner fa-spin text-[var(--text-muted)] text-xs"></i>
|
||||
</div>
|
||||
|
||||
<!-- Suggestions dropdown -->
|
||||
<div v-if="activeSpeakerInput === speaker && speakerSuggestions[speaker] && speakerSuggestions[speaker].length > 0 && !speakerMap[speaker].isMe"
|
||||
@click.stop
|
||||
class="absolute z-10 w-full mt-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-md shadow-lg max-h-40 overflow-y-auto">
|
||||
<div class="py-0.5">
|
||||
<div v-for="suggestion in speakerSuggestions[speaker]"
|
||||
:key="suggestion.id"
|
||||
@click="selectSpeakerSuggestion(speaker, suggestion)"
|
||||
class="px-2 py-1.5 cursor-pointer hover:bg-[var(--bg-tertiary)] active:bg-[var(--bg-accent)] flex items-center justify-between">
|
||||
<div class="flex-grow min-w-0">
|
||||
<div class="text-xs font-medium text-[var(--text-primary)] truncate">${ suggestion.name }</div>
|
||||
<div class="text-[10px] text-[var(--text-muted)]">
|
||||
Used ${ suggestion.use_count }x
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-user text-[var(--text-muted)] ml-1.5 flex-shrink-0 text-[10px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Speaker Button -->
|
||||
<div class="pt-2 sm:pt-4 border-t border-[var(--border-primary)]">
|
||||
<button @click="openAddSpeakerModal" class="w-full px-3 py-2 sm:py-2 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-md hover:bg-[var(--bg-accent-hover)] active:scale-[0.98] transition-all flex items-center justify-center text-xs sm:text-sm font-medium">
|
||||
<i class="fas fa-plus mr-1.5"></i>
|
||||
<span v-text="t('buttons.addSpeaker')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Player & Transcript Panel - shown on mobile when transcript tab active, always on desktop -->
|
||||
<div class="lg:w-2/3 p-3 sm:p-6 flex flex-col overflow-hidden"
|
||||
:class="{ 'hidden lg:flex': speakerModalTab !== 'transcript', 'flex-1': speakerModalTab === 'transcript' }">
|
||||
<!-- Audio Player Section -->
|
||||
<div class="mb-2 sm:mb-4 flex-shrink-0">
|
||||
<!-- Show message if audio has been deleted -->
|
||||
<div v-if="selectedRecording.audio_deleted_at"
|
||||
class="text-[var(--text-muted)] text-xs flex items-center gap-2">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span v-text="t('help.audioDeletedMessage')"></span>
|
||||
</div>
|
||||
<!-- Show message for incognito recordings -->
|
||||
<div v-else-if="selectedRecording.incognito"
|
||||
class="text-[var(--text-muted)] text-xs flex items-center gap-2">
|
||||
<i class="fas fa-user-secret"></i>
|
||||
<span>Audio not stored in incognito mode</span>
|
||||
</div>
|
||||
<!-- Custom Audio/Video Player (Independent from main player) -->
|
||||
<div v-else>
|
||||
<component :is="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') ? 'video' : 'audio'"
|
||||
ref="speakerModalAudioRef"
|
||||
:class="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') ? 'w-full rounded-lg mb-2' : 'hidden'"
|
||||
:key="'speaker-modal-' + selectedRecording.id"
|
||||
:src="'/audio/' + selectedRecording.id"
|
||||
:volume="playerVolume"
|
||||
@play="handleModalAudioPlayPause"
|
||||
@pause="handleModalAudioPlayPause"
|
||||
@timeupdate="handleModalAudioTimeUpdate"
|
||||
@loadedmetadata="handleModalAudioLoadedMetadata"
|
||||
@ended="modalAudioIsPlaying = false">
|
||||
</component>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Play/Pause -->
|
||||
<button @click="$refs.speakerModalAudioRef?.paused ? $refs.speakerModalAudioRef.play() : $refs.speakerModalAudioRef.pause()"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full bg-[var(--bg-accent)] hover:bg-[var(--bg-accent-hover)] text-white transition-all flex-shrink-0 shadow-sm"
|
||||
:title="modalAudioIsPlaying ? 'Pause' : 'Play'">
|
||||
<i :class="modalAudioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-xs" :style="!modalAudioIsPlaying ? 'margin-left: 1px' : ''"></i>
|
||||
</button>
|
||||
<!-- Time -->
|
||||
<div class="flex flex-col items-end flex-shrink-0 leading-none">
|
||||
<span class="text-xs text-[var(--text-primary)] font-mono">${ formatAudioTime(modalAudioCurrentTime) }</span>
|
||||
<span class="text-[10px] text-[var(--text-muted)] font-mono">${ formatAudioTime(modalAudioDuration) }</span>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="flex-1 h-1.5 bg-[var(--bg-tertiary)] rounded-full cursor-pointer group relative"
|
||||
@click="(e) => { const rect = e.currentTarget.getBoundingClientRect(); const pct = (e.clientX - rect.left) / rect.width; if ($refs.speakerModalAudioRef) $refs.speakerModalAudioRef.currentTime = pct * modalAudioDuration; }">
|
||||
<div class="h-full bg-[var(--bg-accent)] rounded-full transition-all duration-100"
|
||||
:style="{ width: modalAudioProgressPercent + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Volume Control -->
|
||||
<div class="relative flex items-center flex-shrink-0"
|
||||
@mouseenter="showModalVolumeSlider = true"
|
||||
@mouseleave="showModalVolumeSlider = false">
|
||||
<button @click="if ($refs.speakerModalAudioRef) { $refs.speakerModalAudioRef.muted = !$refs.speakerModalAudioRef.muted; audioIsMuted = $refs.speakerModalAudioRef.muted; }"
|
||||
class="w-7 h-7 flex items-center justify-center rounded hover:bg-[var(--bg-tertiary)] transition-all flex-shrink-0"
|
||||
:class="audioIsMuted || playerVolume === 0 ? 'text-[var(--text-muted)]' : 'text-[var(--text-primary)]'">
|
||||
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-xs"></i>
|
||||
</button>
|
||||
<!-- Volume Slider Popup (outer div is invisible hover bridge) -->
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 pt-2 z-[9999] transition-all duration-200"
|
||||
:class="showModalVolumeSlider ? 'opacity-100 pointer-events-auto scale-100' : 'opacity-0 pointer-events-none scale-95'"
|
||||
@mouseenter="showModalVolumeSlider = true"
|
||||
@mouseleave="showModalVolumeSlider = false">
|
||||
<div class="px-2.5 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-accent)] rounded-lg shadow-xl flex flex-col items-center gap-1">
|
||||
<span class="text-[9px] font-mono text-[var(--text-muted)]">${ Math.round(playerVolume * 100) }</span>
|
||||
<input type="range" min="0" max="1" step="0.05"
|
||||
:value="audioIsMuted ? 0 : playerVolume"
|
||||
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
|
||||
class="volume-slider-vertical"
|
||||
style="height: 70px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Speed Control (compact - tap to cycle) -->
|
||||
<button @click="cycleModalPlaybackRate(); if ($refs.speakerModalAudioRef) $refs.speakerModalAudioRef.playbackRate = modalPlaybackRate"
|
||||
class="w-7 h-7 flex items-center justify-center rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all flex-shrink-0"
|
||||
title="Playback speed (tap to change)">
|
||||
<span class="text-[10px] font-semibold font-mono">${ formatPlaybackRate(modalPlaybackRate) }</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcript Section (Virtual Scrolling for performance) -->
|
||||
<div ref="speakerModalTranscriptRef"
|
||||
class="flex-grow overflow-y-auto custom-scrollbar speaker-modal-transcript min-h-0"
|
||||
@click="(e) => { const t = e.target.closest('[data-start-time]')?.dataset.startTime; if (t && $refs.speakerModalAudioRef) { $refs.speakerModalAudioRef.currentTime = parseFloat(t); $refs.speakerModalAudioRef.play(); } }"
|
||||
@scroll="onSpeakerModalScroll">
|
||||
<div v-if="processedTranscription.isJson">
|
||||
<!-- Virtual scroll spacer (top) -->
|
||||
<div :style="{ height: speakerModalSpacerBefore + 'px' }"></div>
|
||||
|
||||
<!-- Only render visible segments -->
|
||||
<div v-for="segment in speakerModalVisibleSegments"
|
||||
:key="getVirtualItemKey(segment, 'sm')"
|
||||
class="speaker-segment mb-1.5 sm:mb-2 group relative"
|
||||
:data-start-time="segment.startTime"
|
||||
:data-segment-index="segment._originalIndex">
|
||||
<div class="flex items-start gap-1">
|
||||
<!-- Speaker tag on its own line on mobile -->
|
||||
<span :class="[segment.color, 'speaker-tag', { 'speaker-highlight': highlightedSpeaker === segment.speakerId }]" :data-speaker-id="segment.speakerId" class="inline-block flex-shrink-0 truncate text-xs sm:text-sm font-medium w-16 sm:w-24" :title="segment.speaker">${ segment.speaker }</span>
|
||||
<!-- Text content -->
|
||||
<span class="word cursor-pointer hover:bg-[var(--bg-accent)] hover:text-[var(--text-accent)] rounded px-0.5 flex-1 text-xs sm:text-sm leading-relaxed">${ segment.sentence }</span>
|
||||
<!-- Edit buttons - inline on mobile -->
|
||||
<span class="flex items-center gap-0.5 flex-shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
||||
<button @click.stop="openSpeakerChangeDropdown(segment._originalIndex)" class="p-1 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--bg-accent)] text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors active:scale-95" title="Change speaker">
|
||||
<i class="fas fa-user-edit text-[10px] sm:text-xs"></i>
|
||||
</button>
|
||||
<button @click.stop="openEditTextModal(segment._originalIndex)" class="p-1 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--bg-accent)] text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors active:scale-95" title="Edit text">
|
||||
<i class="fas fa-edit text-[10px] sm:text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Change Dropdown - compact on mobile -->
|
||||
<div v-if="editingSpeakerIndex === segment._originalIndex" @click.stop class="mt-1 p-1.5 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-primary)] shadow-lg">
|
||||
<p class="text-[10px] text-[var(--text-muted)] mb-1">Select speaker:</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button v-for="speaker in modalSpeakers" :key="speaker" @click="changeSpeaker(segment._originalIndex, speaker)" class="px-2 py-1 rounded-md hover:bg-[var(--bg-accent)] hover:text-[var(--text-accent)] active:scale-[0.98] transition-all flex items-center gap-1 text-xs bg-[var(--bg-secondary)]">
|
||||
<div :class="speakerMap[speaker].color" class="w-2.5 h-2.5 rounded-full flex-shrink-0"></div>
|
||||
<span class="truncate max-w-[60px]">${ speakerMap[speaker].name || speaker }</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual scroll spacer (bottom) -->
|
||||
<div :style="{ height: speakerModalSpacerAfter + 'px' }"></div>
|
||||
</div>
|
||||
<div v-else class="whitespace-pre-wrap text-sm text-[var(--text-primary)]">${ processedTranscription.plainText }</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Navigation Controls - more compact -->
|
||||
<div class="mt-1.5 sm:mt-3 p-1.5 sm:p-3 bg-[var(--bg-tertiary)] rounded-md flex flex-col gap-1.5 flex-shrink-0">
|
||||
<!-- Speaker Selector Row -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<select
|
||||
:value="highlightedSpeaker"
|
||||
@change="selectSpeakerForNavigation($event.target.value)"
|
||||
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||||
<option value="">Navigate to speaker...</option>
|
||||
<option v-for="speaker in modalSpeakers" :key="speaker" :value="speaker">
|
||||
${ speakerMap[speaker]?.name || speakerDisplayMap[speaker] || speaker }
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="highlightedSpeaker && speakerGroups.length > 0" class="text-[10px] text-[var(--text-muted)] flex-shrink-0 tabular-nums">
|
||||
${ currentSpeakerGroupIndex + 1 }/${ speakerGroups.length }
|
||||
</span>
|
||||
</div>
|
||||
<!-- Navigation Buttons Row -->
|
||||
<div class="flex items-center gap-1.5" :class="{'opacity-50': !highlightedSpeaker || speakerGroups.length <= 1}">
|
||||
<button @mousedown.prevent @click="navigateToPrevSpeakerGroup"
|
||||
class="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-primary)] text-[var(--text-secondary)] rounded border border-[var(--border-secondary)] transition-all active:scale-95 flex items-center justify-center text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="!highlightedSpeaker || speakerGroups.length <= 1">
|
||||
<i class="fas fa-chevron-up mr-1"></i>Prev
|
||||
</button>
|
||||
<button @mousedown.prevent @click="navigateToNextSpeakerGroup"
|
||||
class="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-primary)] text-[var(--text-secondary)] rounded border border-[var(--border-secondary)] transition-all active:scale-95 flex items-center justify-center text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="!highlightedSpeaker || speakerGroups.length <= 1">
|
||||
Next<i class="fas fa-chevron-down ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regenerate Summary Checkbox -->
|
||||
<div class="p-2 sm:p-4 bg-[var(--bg-tertiary)] border-t border-[var(--border-primary)] flex-shrink-0">
|
||||
<label class="flex items-center text-xs sm:text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors">
|
||||
<input type="checkbox"
|
||||
v-model="regenerateSummaryAfterSpeakerUpdate"
|
||||
class="speaker-checkbox w-4 h-4">
|
||||
<span class="ml-1.5 select-none" v-text="t('help.regenerateSummaryAfterNames')"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Footer Buttons -->
|
||||
<div class="bg-[var(--bg-tertiary)] px-3 sm:px-6 py-2 sm:py-4 flex flex-row justify-between items-center gap-2 border-t border-[var(--border-primary)] flex-shrink-0 rounded-b-xl">
|
||||
<!-- Left: Auto Identify Split Button + Apply Suggested -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Split Button: Auto Identify -->
|
||||
<div class="relative inline-flex" ref="autoIdSplitBtn">
|
||||
<!-- Main button: identify missing only -->
|
||||
<button @click="autoIdentifySpeakers(false)" class="px-2 sm:px-3 py-1.5 sm:py-2 bg-purple-600 text-white rounded-l-md hover:bg-purple-700 active:scale-[0.98] transition-all flex items-center justify-center text-xs sm:text-sm font-medium" :disabled="isAutoIdentifying">
|
||||
<i class="fas fa-magic mr-1 sm:mr-1.5"></i>
|
||||
<span v-if="!isAutoIdentifying" class="hidden sm:inline" v-text="t('help.autoIdentify')"></span>
|
||||
<span v-if="!isAutoIdentifying" class="sm:hidden" v-text="t('help.autoIdentifyMobile')"></span>
|
||||
<span v-else>
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
<!-- Dropdown arrow -->
|
||||
<button @click="showAutoIdDropdown = !showAutoIdDropdown" class="px-1.5 py-1.5 sm:py-2 bg-purple-600 text-white rounded-r-md border-l border-purple-500 hover:bg-purple-700 active:scale-[0.98] transition-all text-xs sm:text-sm" :disabled="isAutoIdentifying">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div v-if="showAutoIdDropdown" class="absolute bottom-full left-0 mb-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-md shadow-lg z-50 min-w-[180px]">
|
||||
<button @click="autoIdentifySpeakers(true)" class="w-full px-3 py-2 text-left text-xs sm:text-sm text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors rounded-md">
|
||||
<i class="fas fa-users mr-2"></i><span v-text="t('help.identifyAllSpeakers')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Apply Suggested Button -->
|
||||
<button v-if="hasAnySuggestions" @click="applySuggestedNames" class="px-2 sm:px-3 py-1.5 sm:py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-md hover:from-blue-700 hover:to-purple-700 active:scale-[0.98] transition-all flex items-center text-xs sm:text-sm font-medium">
|
||||
<i class="fas fa-lightbulb mr-1 sm:mr-1.5"></i>
|
||||
<span class="hidden sm:inline" v-text="t('help.applySuggested')"></span>
|
||||
<span class="sm:hidden" v-text="t('help.applySuggestedMobile')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Right: Cancel & Save Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button @click="closeSpeakerModal" class="px-3 sm:px-4 py-1.5 sm:py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-md border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)] active:scale-[0.98] transition-all text-xs sm:text-sm" v-text="t('common.cancel')"></button>
|
||||
<button
|
||||
@click="saveSpeakerNames"
|
||||
:disabled="!hasSpeakerNames"
|
||||
class="px-3 sm:px-4 py-1.5 sm:py-2 rounded-md transition-all text-xs sm:text-sm font-medium active:scale-[0.98]"
|
||||
:class="hasSpeakerNames
|
||||
? 'bg-[var(--bg-accent)] text-[var(--text-accent)] hover:bg-[var(--bg-accent-hover)] cursor-pointer'
|
||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'"
|
||||
v-text="t('buttons.saveNames')"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
53
templates/modals/system-audio-help-modal.html
Normal file
53
templates/modals/system-audio-help-modal.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!-- System Audio Help Modal -->
|
||||
<div v-if="showSystemAudioHelp" @click.self="showSystemAudioHelp = false" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold" v-text="t('help.systemAudioHelp')"></h3>
|
||||
<button @click="showSystemAudioHelp = false" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-medium text-blue-800 dark:text-blue-300 mb-2">Compatibilité des navigateurs</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
L'enregistrement audio système fonctionne mieux dans Chrome, Edge et Brave. Firefox est supporté mais nécessite que l'onglet soit en train de jouer de l'audio. Non supporté sur Safari ou les appareils mobiles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-2">Comment enregistrer l'audio système :</h4>
|
||||
<ol class="list-decimal list-inside space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
<li>Cliquez sur le bouton "Audio Système" ou "Micro + Système"</li>
|
||||
<li>Une fenêtre de partage d'écran apparaîtra</li>
|
||||
<li>Sélectionnez un onglet ou une fenêtre qui <strong>joue de l'audio activement</strong></li>
|
||||
<li>Assurez-vous que la case "Partager l'audio" est <strong>cochée</strong></li>
|
||||
<li>Cliquez sur "Partager" pour démarrer l'enregistrement</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-2">Dépannage :</h4>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm text-[var(--text-muted)]">
|
||||
<li><strong>Important :</strong> L'onglet/fenêtre doit jouer de l'audio au moment du partage</li>
|
||||
<li>Vérifiez que la case "Partager l'audio" est cochée dans la fenêtre de partage</li>
|
||||
<li>Sur Firefox, lancez d'abord la lecture audio, puis cliquez sur enregistrer</li>
|
||||
<li>Certains contenus peuvent avoir une protection DRM qui bloque l'enregistrement</li>
|
||||
<li>Non supporté sur Safari ou les navigateurs mobiles</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end">
|
||||
<button @click="showSystemAudioHelp = false" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||||
Compris
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
32
templates/modals/text-editor-modal.html
Normal file
32
templates/modals/text-editor-modal.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!-- Simple Transcription Editor Modal -->
|
||||
<div v-if="showTextEditorModal" @click.self="closeTextEditorModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.editTranscription')"></h3>
|
||||
<button @click="closeTextEditorModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" v-text="t('transcription.title')"></label>
|
||||
<textarea v-model="editingTranscriptionContent"
|
||||
rows="15"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] font-mono text-sm">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3">
|
||||
<button @click="closeTextEditorModal"
|
||||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveTranscription"
|
||||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
2
templates/modals/toast-container.html
Normal file
2
templates/modals/toast-container.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<!-- Toast Container -->
|
||||
<div id="toastContainer" class="fixed top-4 right-4 z-50 pointer-events-none"></div>
|
||||
281
templates/modals/unified-share-modal.html
Normal file
281
templates/modals/unified-share-modal.html
Normal file
@@ -0,0 +1,281 @@
|
||||
<!-- Unified Share Modal (Public + Internal Sharing) -->
|
||||
<div v-if="showUnifiedShareModal" @click.self="closeUnifiedShareModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold" v-text="t('modal.shareRecording')"></h3>
|
||||
<p class="text-sm text-[var(--text-muted)] mt-0.5">${internalShareRecording?.title}</p>
|
||||
</div>
|
||||
<button @click="closeUnifiedShareModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors ml-4">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Public Sharing Section -->
|
||||
<div v-if="!internalShareRecording?.is_shared || internalShareRecording?.share_info?.can_reshare" class="px-6 py-4 border-b border-[var(--border-primary)]">
|
||||
<h4 class="font-medium mb-2 flex items-center gap-2">
|
||||
<i class="fas fa-globe text-[var(--text-accent)]"></i>
|
||||
<span v-text="t('sharing.publicLink')"></span>
|
||||
</h4>
|
||||
|
||||
<p class="text-xs text-[var(--text-muted)] mb-3" v-text="t('help.publicLinkDesc')"></p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Share Options -->
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" v-model="shareOptions.share_summary" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm" v-text="t('form.shareSummary')"></span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" v-model="shareOptions.share_notes" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm" v-text="t('form.shareNotes')"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Create New Share Button -->
|
||||
<button @click="createShare(true)"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-accent)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm font-medium">
|
||||
<i class="fas fa-plus-circle mr-1.5"></i>Create New Public Share Link
|
||||
</button>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingPublicShares" class="text-center py-6">
|
||||
<i class="fas fa-spinner fa-spin text-xl text-[var(--text-muted)]"></i>
|
||||
<p class="mt-2 text-xs text-[var(--text-muted)]">Loading share links...</p>
|
||||
</div>
|
||||
|
||||
<!-- Existing Shares List -->
|
||||
<div v-else-if="recordingPublicShares.length > 0" class="space-y-2">
|
||||
<h5 class="text-xs font-medium text-[var(--text-secondary)] mb-1.5">
|
||||
Existing Share Links (${recordingPublicShares.length})
|
||||
</h5>
|
||||
<div v-for="share in recordingPublicShares" :key="share.id"
|
||||
class="bg-[var(--bg-tertiary)] p-2.5 rounded-lg border border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<i class="fas fa-calendar text-[var(--text-muted)] text-xs"></i>
|
||||
<span class="text-xs text-[var(--text-muted)] truncate">Created: ${share.created_at}</span>
|
||||
<span v-if="share.share_summary" class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
||||
<i class="fas fa-file-alt mr-1"></i>Summary
|
||||
</span>
|
||||
<span v-if="share.share_notes" class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
|
||||
<i class="fas fa-sticky-note mr-1"></i>Notes
|
||||
</span>
|
||||
</div>
|
||||
<button @click="confirmDeletePublicShare(share)"
|
||||
class="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-[var(--text-muted)] hover:text-red-500 transition-colors"
|
||||
:title="'Delete share link'">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input :value="share.share_url" :id="'unified-share-link-' + share.id" readonly
|
||||
class="w-full px-2 py-1.5 pr-10 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs font-mono focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||||
<button @click="copyPublicShareLinkWithFeedback(share.share_url, share.id)"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-7 flex items-center justify-center rounded bg-[var(--bg-button)] text-[var(--text-button)] hover:bg-[var(--bg-button-hover)] transition-all"
|
||||
:title="t('buttons.copy')">
|
||||
<i :class="copiedShareId === share.id ? 'fas fa-check' : 'fas fa-copy'" class="text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Shares Yet -->
|
||||
<div v-else-if="!isLoadingPublicShares" class="text-center py-4 text-xs text-[var(--text-muted)]">
|
||||
<i class="fas fa-link text-xl mb-1"></i>
|
||||
<p>No public share links yet</p>
|
||||
<p class="text-xs mt-0.5 opacity-75">Click the button above to create one</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Internal Sharing Section -->
|
||||
<div v-if="enableInternalSharing && (!internalShareRecording?.is_shared || internalShareRecording?.share_info?.can_reshare)" class="px-6 py-4">
|
||||
<h4 class="font-medium mb-2 flex items-center gap-2">
|
||||
<i class="fas fa-users text-[var(--text-accent)]"></i>
|
||||
<span v-text="t('sharing.internalSharing')"></span>
|
||||
</h4>
|
||||
|
||||
<p class="text-xs text-[var(--text-muted)] mb-3" v-text="t('help.internalSharingDesc')"></p>
|
||||
|
||||
<!-- Share with New User Section -->
|
||||
<div class="bg-[var(--bg-tertiary)] p-3 rounded-lg border border-[var(--border-primary)] mb-3">
|
||||
<h5 class="font-medium text-sm text-[var(--text-primary)] mb-2 flex items-center">
|
||||
<i class="fas fa-user-plus mr-1.5 text-blue-500 text-xs"></i>
|
||||
Share with User
|
||||
</h5>
|
||||
|
||||
<!-- User Search/Selection (when SHOW_USERNAMES_IN_UI is enabled) -->
|
||||
<div v-if="showUsernamesInUI" class="space-y-3">
|
||||
<!-- Search Input (for filtering) -->
|
||||
<div class="relative">
|
||||
<input v-model="internalShareUserSearch"
|
||||
@input="searchInternalShareUsers()"
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
class="w-full px-4 py-2 pl-10 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<i class="fas fa-search absolute left-3 top-3 text-[var(--text-muted)]"></i>
|
||||
</div>
|
||||
|
||||
<!-- User Grid -->
|
||||
<div v-if="isLoadingAllUsers" class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Loading users...</p>
|
||||
</div>
|
||||
<div v-else-if="internalShareSearchResults.length === 0" class="text-center py-8 text-[var(--text-muted)]">
|
||||
<i class="fas fa-users-slash text-3xl mb-2"></i>
|
||||
<p>No users found</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[50vh] sm:max-h-64 overflow-y-auto">
|
||||
<div v-for="user in internalShareSearchResults" :key="user.id"
|
||||
@click="createInternalShare(user.id, user.username)"
|
||||
class="group p-3 sm:p-2 bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover)] rounded-lg border border-[var(--border-secondary)] hover:border-blue-500 cursor-pointer transition-all active:scale-[0.98]">
|
||||
<div class="flex items-center gap-3 sm:gap-2">
|
||||
<div :class="['w-10 h-10 sm:w-8 sm:h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm sm:text-xs', getUserColorClass(user.username)]">
|
||||
${user.username ? user.username.charAt(0).toUpperCase() : '?'}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-base sm:text-sm text-[var(--text-primary)] truncate">${user.username}</p>
|
||||
<p v-if="user.email" class="text-sm sm:text-xs text-[var(--text-muted)] truncate">${user.email}</p>
|
||||
</div>
|
||||
<i class="fas fa-user-plus text-blue-500 text-lg sm:text-base group-hover:scale-110 transition-transform flex-shrink-0"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct Username Entry (when SHOW_USERNAMES_IN_UI is disabled - Privacy Mode) -->
|
||||
<div v-else class="space-y-3">
|
||||
<p class="text-sm text-[var(--text-muted)]">Enter the exact username to share with</p>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="internalShareUserSearch"
|
||||
@keyup.enter="shareWithUsername()"
|
||||
type="text"
|
||||
placeholder="Enter full username..."
|
||||
class="flex-1 px-4 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<button @click="shareWithUsername()"
|
||||
:disabled="!internalShareUserSearch.trim() || isSearchingUsers"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<i v-if="isSearchingUsers" class="fas fa-spinner fa-spin"></i>
|
||||
<span v-else>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
You must enter the exact username. If the user exists, they will be added to the share list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Permissions -->
|
||||
<div class="border-t border-[var(--border-primary)] mt-3 pt-2.5 space-y-1.5">
|
||||
<p class="text-xs font-medium text-[var(--text-secondary)] mb-1.5">Share Permissions</p>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox"
|
||||
v-model="internalSharePermissions.can_edit"
|
||||
:disabled="!internalShareMaxPermissions.can_edit"
|
||||
id="unified_share_can_edit"
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<label for="unified_share_can_edit"
|
||||
:class="['ml-2 block text-sm', internalShareMaxPermissions.can_edit ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)] opacity-60']">
|
||||
<i class="fas fa-pencil-alt mr-1"></i>Allow editing notes and metadata
|
||||
<span v-if="!internalShareMaxPermissions.can_edit" class="text-xs italic ml-1">(You don't have this permission)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox"
|
||||
v-model="internalSharePermissions.can_reshare"
|
||||
:disabled="!internalShareMaxPermissions.can_reshare"
|
||||
id="unified_share_can_reshare"
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<label for="unified_share_can_reshare"
|
||||
:class="['ml-2 block text-sm', internalShareMaxPermissions.can_reshare ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)] opacity-60']">
|
||||
<i class="fas fa-share-alt mr-1"></i>Allow re-sharing with others
|
||||
<span v-if="!internalShareMaxPermissions.can_reshare" class="text-xs italic ml-1">(You don't have this permission)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Already Shared With Section -->
|
||||
<div class="bg-[var(--bg-tertiary)] p-3 rounded-lg border border-[var(--border-primary)]">
|
||||
<h5 class="font-medium text-sm text-[var(--text-primary)] mb-2 flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-users-cog mr-1.5 text-purple-500 text-xs"></i>
|
||||
Already Shared With
|
||||
</span>
|
||||
<span v-if="recordingInternalShares.length > 0" class="text-xs font-normal text-[var(--text-muted)]">
|
||||
${recordingInternalShares.length} user(s)
|
||||
</span>
|
||||
</h5>
|
||||
|
||||
<div v-if="isLoadingInternalShares" class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Loading shares...</p>
|
||||
</div>
|
||||
<div v-else-if="recordingInternalShares.length === 0" class="text-center py-8 text-[var(--text-muted)]">
|
||||
<i class="fas fa-user-slash text-3xl mb-3"></i>
|
||||
<p>Not shared with anyone yet</p>
|
||||
<p class="text-sm mt-1">Select a user above to share this recording</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[50vh] sm:max-h-64 overflow-y-auto">
|
||||
<div v-for="share in recordingInternalShares" :key="share.user_id"
|
||||
class="bg-[var(--bg-secondary)] p-3 sm:p-2 rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-primary)] transition-colors">
|
||||
<div class="flex items-center gap-3 sm:gap-2">
|
||||
<div :class="['w-10 h-10 sm:w-8 sm:h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm sm:text-xs', getUserColorClass(share.username)]">
|
||||
${share.username ? share.username.charAt(0).toUpperCase() : '#'}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-base sm:text-sm text-[var(--text-primary)] truncate">${share.username || 'User #' + share.user_id}</p>
|
||||
<p class="text-sm sm:text-xs text-[var(--text-muted)] mb-1 sm:mb-0.5">${share.is_owner ? 'Recording Owner' : formatShareDate(share.created_at)}</p>
|
||||
<div class="flex flex-wrap gap-1 text-xs sm:text-[10px]">
|
||||
<span v-if="share.is_owner" class="inline-flex items-center px-2 py-0.5 sm:px-1.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium">
|
||||
<i class="fas fa-crown mr-1 sm:mr-0.5 text-xs sm:text-[9px]"></i>Owner
|
||||
</span>
|
||||
<template v-else>
|
||||
<span v-if="share.can_edit" class="inline-flex items-center px-2 py-0.5 sm:px-1.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
|
||||
<i class="fas fa-pencil-alt mr-1 sm:mr-0.5 text-xs sm:text-[8px]"></i>Edit
|
||||
</span>
|
||||
<span v-if="share.can_reshare" class="inline-flex items-center px-2 py-0.5 sm:px-1.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
|
||||
<i class="fas fa-share-alt mr-1 sm:mr-0.5 text-xs sm:text-[8px]"></i>Reshare
|
||||
</span>
|
||||
<span v-if="!share.can_edit && !share.can_reshare" class="inline-flex items-center px-2 py-0.5 sm:px-1.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400">
|
||||
<i class="fas fa-eye mr-1 sm:mr-0.5 text-xs sm:text-[8px]"></i>View
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!share.is_owner"
|
||||
@click="revokeInternalShare(share.id, share.username || share.user_name)"
|
||||
class="p-2 sm:p-1.5 rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 active:scale-95 transition-all flex-shrink-0"
|
||||
:title="'Revoke access'">
|
||||
<i class="fas fa-user-times text-base sm:text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Permission Message -->
|
||||
<div v-if="internalShareRecording?.is_shared && !internalShareRecording?.share_info?.can_reshare" class="px-6 py-6 text-center">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<i class="fas fa-info-circle text-yellow-600 dark:text-yellow-400 text-2xl mb-2"></i>
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-300 font-medium">Sharing Not Available</p>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-400 mt-1">
|
||||
This recording was shared with you, but you don't have permission to share it with others.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-3 border-t border-[var(--border-primary)] flex justify-end flex-shrink-0">
|
||||
<button @click="closeUnifiedShareModal" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||||
<span v-text="t('tagsModal.done')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
36
templates/modals/upload-disclaimer-modal.html
Normal file
36
templates/modals/upload-disclaimer-modal.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!-- Upload Disclaimer Modal -->
|
||||
<div v-if="showUploadDisclaimerModal" @click.self="cancelUploadDisclaimer" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||
<div class="flex-shrink-0 p-6 border-b border-[var(--border-primary)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-info-circle text-2xl text-[var(--text-accent)]"></i>
|
||||
<h3 class="text-xl font-semibold text-[var(--text-primary)]" v-text="t('modal.uploadNotice')"></h3>
|
||||
</div>
|
||||
<button @click="cancelUploadDisclaimer" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Render markdown content -->
|
||||
<div class="ai-message text-[var(--text-secondary)]"
|
||||
style="line-height: 1.6;">
|
||||
<div v-html="uploadDisclaimerHtml"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 p-6 border-t border-[var(--border-primary)] bg-[var(--bg-tertiary)] rounded-b-lg">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="cancelUploadDisclaimer"
|
||||
class="px-6 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors border border-[var(--border-secondary)]">
|
||||
${ t('buttons.cancel') }
|
||||
</button>
|
||||
<button @click="acceptUploadDisclaimer"
|
||||
class="px-6 py-2 bg-[var(--bg-accent)] text-white rounded-lg hover:bg-[var(--bg-accent-hover)] transition-colors">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
<span v-text="t('modal.uploadFiles')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
164
templates/register.html
Normal file
164
templates/register.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>{{ title }} - DictIA</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
|
||||
<!-- Loading overlay to prevent FOUC -->
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
|
||||
<script>
|
||||
// Function to apply the theme based on localStorage
|
||||
function applyTheme() {
|
||||
// Guard against early execution
|
||||
if (!document.documentElement) return;
|
||||
|
||||
// Apply dark mode
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Apply color scheme
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||||
|
||||
// Remove all other theme classes
|
||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
||||
themeClasses.forEach(theme => {
|
||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
||||
});
|
||||
|
||||
// Add the correct theme class
|
||||
if (savedScheme !== 'blue') {
|
||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
||||
DictIA
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow flex items-center justify-center">
|
||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Create an Account</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.username.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
||||
{% if form.username.errors %}
|
||||
{{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
||||
{% for error in form.username.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
||||
{% if form.email.errors %}
|
||||
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
||||
{% for error in form.email.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
||||
{% if form.password.errors %}
|
||||
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
||||
{% for error in form.password.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
{% endif %}
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">Password must be at least 8 characters long.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
{{ form.confirm_password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
||||
{% if form.confirm_password.errors %}
|
||||
{{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
||||
{% for error in form.confirm_password.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-4">
|
||||
{{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }}
|
||||
|
||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
||||
<span>Already have an account?</span>
|
||||
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">Login here</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Hide loading overlay when page is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
898
templates/share.html
Normal file
898
templates/share.html
Normal file
@@ -0,0 +1,898 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<title>Shared Recording - {{ recording.title }}</title>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/vue.global.js') }}"></script>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/axios.min.js') }}"></script>
|
||||
<!-- All dependencies bundled locally for offline support -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
||||
|
||||
<!-- Loading overlay to prevent FOUC -->
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
maxHeight: {
|
||||
'85vh': '85vh',
|
||||
'90vh': '90vh'
|
||||
},
|
||||
colors: {
|
||||
primary: 'var(--bg-primary)',
|
||||
secondary: 'var(--bg-secondary)',
|
||||
accent: 'var(--bg-accent)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to apply the theme based on localStorage and system preference
|
||||
function applyTheme() {
|
||||
// Guard against early execution
|
||||
if (!document.documentElement) return;
|
||||
|
||||
// Apply dark mode
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Apply color scheme
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||||
|
||||
// Remove all other theme classes
|
||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
||||
themeClasses.forEach(theme => {
|
||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
||||
});
|
||||
|
||||
// Add the correct theme class
|
||||
if (savedScheme !== 'blue') {
|
||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for DOM to be ready before applying theme
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', applyTheme);
|
||||
} else {
|
||||
applyTheme();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] transition-colors duration-300">
|
||||
<div id="app" class="h-full flex flex-col" data-recording='{{ recording|tojson|safe }}'>
|
||||
<!-- Header -->
|
||||
<header class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] px-4 py-3 flex items-center justify-between flex-shrink-0 z-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="w-8 h-8">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-[var(--text-primary)]">${ recording.title }</h1>
|
||||
<p class="text-sm text-[var(--text-muted)]">Shared Recording</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="toggleDarkMode"
|
||||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
title="Toggle Theme">
|
||||
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Audio Player - Fixed at top -->
|
||||
<div class="bg-[var(--bg-secondary)] p-4 border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Show message if audio has been deleted -->
|
||||
<div v-if="recording.audio_deleted_at"
|
||||
class="bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-[var(--text-muted)] px-4 py-3 rounded-lg flex items-center gap-2 text-sm">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>Audio file has been archived and is no longer available for playback.</span>
|
||||
</div>
|
||||
<!-- Custom Audio/Video Player -->
|
||||
<div v-else>
|
||||
<component :is="recording.mime_type && recording.mime_type.startsWith('video/') ? 'video' : 'audio'"
|
||||
ref="audioPlayer"
|
||||
:class="recording.mime_type && recording.mime_type.startsWith('video/') ? 'w-full rounded-lg mb-3' : 'hidden'"
|
||||
:src="'/share/audio/' + recording.public_id"
|
||||
@play="handleAudioPlayPause"
|
||||
@pause="handleAudioPlayPause"
|
||||
@timeupdate="handleCustomAudioTimeUpdate"
|
||||
@loadedmetadata="handleAudioLoadedMetadata"
|
||||
@durationchange="handleAudioDurationChange"
|
||||
@ended="handleAudioEnded">
|
||||
</component>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Play/Pause -->
|
||||
<button @click="toggleAudioPlayback"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-[var(--bg-accent)] hover:bg-[var(--bg-accent-hover)] text-white transition-all flex-shrink-0 shadow-sm"
|
||||
:title="audioIsPlaying ? 'Pause' : 'Play'">
|
||||
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-sm" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
|
||||
</button>
|
||||
<!-- Time -->
|
||||
<div class="flex flex-col items-end flex-shrink-0 leading-none">
|
||||
<span class="text-sm text-[var(--text-primary)] font-mono">${ formatAudioTime(audioCurrentTime) }</span>
|
||||
<span class="text-xs text-[var(--text-muted)] font-mono">${ formatAudioTime(audioDuration) }</span>
|
||||
</div>
|
||||
<!-- Playback Speed Control -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<button @click="showSpeedMenu = !showSpeedMenu"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-tertiary)] text-[var(--text-accent)] hover:opacity-80 transition-all"
|
||||
title="Playback speed">
|
||||
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
|
||||
</button>
|
||||
<!-- Dropdown menu (opens downward) -->
|
||||
<div v-if="showSpeedMenu" @click.stop
|
||||
class="absolute top-full mt-1 left-1/2 -translate-x-1/2 bg-[var(--bg-tertiary)] border border-[var(--border-accent)] rounded-md shadow-xl z-50 speed-dropdown backdrop-blur-sm">
|
||||
<div class="py-0.5 max-h-40 overflow-y-auto">
|
||||
<button v-for="speed in playbackSpeeds" :key="speed"
|
||||
@mousedown.prevent="setPlaybackRate(speed); showSpeedMenu = false"
|
||||
class="w-full px-2 py-0.5 text-[11px] font-mono text-left hover:bg-[var(--bg-accent-light)] transition-colors"
|
||||
:class="speed === playbackRate ? 'text-[var(--text-accent)] font-semibold bg-[var(--bg-accent-light)]' : 'text-[var(--text-primary)]'">
|
||||
${ speed }x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="flex-1 h-2 bg-[var(--bg-tertiary)] rounded-full cursor-pointer group relative"
|
||||
@click="(e) => { const rect = e.currentTarget.getBoundingClientRect(); seekAudioByPercent(((e.clientX - rect.left) / rect.width) * 100); }">
|
||||
<div class="h-full bg-[var(--bg-accent)] rounded-full transition-all duration-100"
|
||||
:style="{ width: audioProgressPercent + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Volume -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button @click="toggleAudioMute"
|
||||
class="w-8 h-8 flex items-center justify-center rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all">
|
||||
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'" class="text-sm"></i>
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.05" :value="playerVolume"
|
||||
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
|
||||
class="volume-slider w-20 h-1.5 rounded-full cursor-pointer">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation - Fixed below audio player -->
|
||||
<div class="bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)] flex-shrink-0">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex">
|
||||
<button @click="activeTab = 'transcription'"
|
||||
:class="[
|
||||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||||
activeTab === 'transcription'
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||
]">
|
||||
<i class="fas fa-file-text mr-2"></i>Transcription
|
||||
</button>
|
||||
{% if recording.summary %}
|
||||
<button @click="activeTab = 'summary'"
|
||||
:class="[
|
||||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||||
activeTab === 'summary'
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||
]">
|
||||
<i class="fas fa-file-alt mr-2"></i>Summary
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if recording.notes %}
|
||||
<button @click="activeTab = 'notes'"
|
||||
:class="[
|
||||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||||
activeTab === 'notes'
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||
]">
|
||||
<i class="fas fa-sticky-note mr-2"></i>Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content - Fixed height container -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="w-full max-w-4xl mx-auto p-4 flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Transcription View -->
|
||||
<div v-show="activeTab === 'transcription'" class="w-full flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Transcription Controls -->
|
||||
<div class="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<div v-if="readableMode ? hasDialogue : processedTranscription.hasDialogue" class="view-mode-toggle">
|
||||
<button @click="transcriptView = 'simple'"
|
||||
:class="['toggle-button', transcriptView === 'simple' ? 'active' : '']">
|
||||
<i class="fas fa-list mr-1"></i>Simple
|
||||
</button>
|
||||
<button @click="transcriptView = 'bubble'"
|
||||
:class="['toggle-button', transcriptView === 'bubble' ? 'active' : '']">
|
||||
<i class="fas fa-comments mr-1"></i>Bubble
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!recording.audio_deleted_at && (readableMode ? hasDialogue : processedTranscription.hasDialogue)"
|
||||
class="follow-player-control text-[var(--text-muted)] hover:text-[var(--text-primary)] cursor-pointer"
|
||||
@click="toggleFollowPlayerMode"
|
||||
:title="followPlayerMode ? 'Auto-scroll enabled' : 'Auto-scroll disabled'">
|
||||
<input type="checkbox"
|
||||
:checked="followPlayerMode"
|
||||
@click.stop="toggleFollowPlayerMode"
|
||||
class="cursor-pointer">
|
||||
<i class="fas fa-arrows-alt-v follow-icon"></i>
|
||||
</div>
|
||||
<button @click="copyTranscript" class="copy-btn">
|
||||
<i class="fas fa-copy mr-1"></i>Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if readable_mode and transcript %}
|
||||
<!-- SERVER-RENDERED VERSION (for READABLE_PUBLIC_LINKS mode) -->
|
||||
<!-- Speaker Legend (only for bubble view) -->
|
||||
{% if transcript.has_speakers and transcript.speakers %}
|
||||
<div v-show="transcriptView === 'bubble'"
|
||||
:class="['speaker-legend', legendExpanded ? 'expanded' : '', 'flex-shrink-0', 'mb-4']">
|
||||
<div class="speaker-legend-header" @click="legendExpanded = !legendExpanded">
|
||||
<div class="speaker-legend-title">
|
||||
<i class="fas fa-users"></i>
|
||||
Speakers
|
||||
<span class="speaker-count-indicator">({{ transcript.speakers|length }})</span>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down speaker-legend-toggle"></i>
|
||||
</div>
|
||||
<div class="speaker-legend-content">
|
||||
{% for speaker in transcript.speakers %}
|
||||
<div class="speaker-legend-item {{ speaker.color }}">
|
||||
<span class="speaker-name">{{ speaker.name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Transcription Content - Scrollable Box (Server-rendered for accessibility) -->
|
||||
<div class="w-full flex-1 overflow-y-auto transcription-box" @click="handleTranscriptClick">
|
||||
{% if transcript.is_json and transcript.segments %}
|
||||
<!-- Simple View (server-rendered) -->
|
||||
<div v-show="transcriptView === 'simple'" class="transcription-simple-view">
|
||||
{% for segment in transcript.segments %}
|
||||
<div class="speaker-segment cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors mb-2"
|
||||
data-start-time="{{ segment.start_time }}"
|
||||
data-end-time="{{ segment.end_time }}"
|
||||
data-segment-index="{{ loop.index0 }}"
|
||||
style="margin-bottom: 0.5rem !important;">
|
||||
{% if segment.show_speaker and segment.speaker %}
|
||||
<div class="speaker-tablet {{ segment.color }}">{{ segment.speaker }}</div>
|
||||
{% endif %}
|
||||
<div class="speaker-text">{{ segment.text }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Bubble View (server-rendered) -->
|
||||
<div v-show="transcriptView === 'bubble'" class="transcription-with-speakers">
|
||||
{% set ns = namespace(last_speaker=None) %}
|
||||
{% for segment in transcript.segments %}
|
||||
{% if segment.speaker != ns.last_speaker %}
|
||||
{% if not loop.first %}</div>{% endif %}
|
||||
<div class="bubble-row {% if segment.speaker and 'me' in segment.speaker|lower %}speaker-me{% endif %}">
|
||||
{% endif %}
|
||||
<div class="speaker-bubble {{ segment.color }} {% if segment.speaker and 'me' in segment.speaker|lower %}speaker-me{% endif %} cursor-pointer"
|
||||
data-start-time="{{ segment.start_time }}"
|
||||
data-end-time="{{ segment.end_time }}"
|
||||
data-segment-index="{{ loop.index0 }}">
|
||||
<div class="speaker-bubble-content">{{ segment.text }}</div>
|
||||
</div>
|
||||
{% set ns.last_speaker = segment.speaker %}
|
||||
{% endfor %}
|
||||
{% if transcript.segments %}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Plain Text View (for non-JSON transcriptions) -->
|
||||
<div class="whitespace-pre-wrap cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors">{{ transcript.plain_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- VUE-RENDERED VERSION (default) -->
|
||||
<!-- Speaker Legend (only for bubble view) -->
|
||||
<div v-if="processedTranscription.hasDialogue && processedTranscription.speakers.length > 0 && transcriptView === 'bubble'"
|
||||
:class="['speaker-legend', legendExpanded ? 'expanded' : '', 'flex-shrink-0', 'mb-4']">
|
||||
<div class="speaker-legend-header" @click="legendExpanded = !legendExpanded">
|
||||
<div class="speaker-legend-title">
|
||||
<i class="fas fa-users"></i>
|
||||
Speakers
|
||||
<span class="speaker-count-indicator">(${processedTranscription.speakers.length})</span>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down speaker-legend-toggle"></i>
|
||||
</div>
|
||||
<div class="speaker-legend-content">
|
||||
<div v-for="(speaker, index) in processedTranscription.speakers"
|
||||
:key="index"
|
||||
:class="['speaker-legend-item', speaker.color]">
|
||||
<span class="speaker-name">${speaker.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcription Content - Scrollable Box -->
|
||||
<div class="w-full flex-1 overflow-y-auto transcription-box" @click="handleTranscriptClick">
|
||||
<!-- Simple View -->
|
||||
<div v-if="transcriptView === 'simple'" class="transcription-simple-view">
|
||||
<div v-for="(segment, index) in processedTranscription.simpleSegments"
|
||||
:key="segment.startTime || Math.random()"
|
||||
:class="['speaker-segment cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors mb-2', { 'active-playing-segment': currentPlayingSegmentIndex === index }]"
|
||||
:data-start-time="segment.startTime"
|
||||
:data-end-time="segment.endTime"
|
||||
:data-segment-index="index"
|
||||
style="margin-bottom: 0.5rem !important;">
|
||||
<div v-if="segment.showSpeaker" :class="['speaker-tablet', segment.color]">
|
||||
${segment.speaker}
|
||||
</div>
|
||||
<div class="speaker-text">
|
||||
${segment.sentence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bubble View -->
|
||||
<div v-else-if="transcriptView === 'bubble'" class="transcription-with-speakers">
|
||||
<div v-for="(row, rowIndex) in processedTranscription.bubbleRows"
|
||||
:key="rowIndex"
|
||||
:class="['bubble-row', row.isMe ? 'speaker-me' : '']">
|
||||
<div v-for="(bubble, bubbleIndex) in row.bubbles"
|
||||
:key="bubble.startTime || Math.random()"
|
||||
:class="['speaker-bubble', bubble.color, row.isMe ? 'speaker-me' : '', 'cursor-pointer', { 'active-playing-segment': currentPlayingSegmentIndex === getBubbleGlobalIndex(rowIndex, bubbleIndex) }]"
|
||||
:data-start-time="bubble.startTime"
|
||||
:data-end-time="bubble.endTime"
|
||||
:data-segment-index="getBubbleGlobalIndex(rowIndex, bubbleIndex)">
|
||||
<div class="speaker-bubble-content">
|
||||
${bubble.sentence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plain Text View (for non-JSON transcriptions) -->
|
||||
<div v-if="!processedTranscription.isJson" class="whitespace-pre-wrap cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors">
|
||||
${processedTranscription.content}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Summary View -->
|
||||
{% if recording.summary %}
|
||||
<div v-show="activeTab === 'summary'" class="w-full flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-end mb-4 flex-shrink-0">
|
||||
<button @click="copySummary" class="copy-btn">
|
||||
<i class="fas fa-copy mr-1"></i>Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full flex-1 overflow-y-auto summary-box">
|
||||
{{ recording.summary|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Notes View -->
|
||||
{% if recording.notes %}
|
||||
<div v-show="activeTab === 'notes'" class="w-full flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-end mb-4 flex-shrink-0">
|
||||
<button @click="copyNotes" class="copy-btn">
|
||||
<i class="fas fa-copy mr-1"></i>Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full flex-1 overflow-y-auto notes-box">
|
||||
{{ recording.notes|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer — Loi 25 & AGPL-3.0 -->
|
||||
<footer class="text-center py-4 text-xs text-[var(--text-muted)] border-t border-[var(--border-primary)] flex-shrink-0">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
|
||||
</footer>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toastContainer" class="fixed top-4 right-4 z-50 space-y-2 pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, computed } = Vue;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const appElement = document.querySelector('#app');
|
||||
const recordingData = JSON.parse(appElement.dataset.recording);
|
||||
const recording = ref(recordingData);
|
||||
const activeTab = ref('transcription');
|
||||
const transcriptView = ref('simple');
|
||||
const audioPlayer = ref(null);
|
||||
const legendExpanded = ref(false);
|
||||
const isDarkMode = ref(document.documentElement.classList.contains('dark'));
|
||||
|
||||
// Readable mode flag from server
|
||||
const readableMode = {{ 'true' if readable_mode else 'false' }};
|
||||
const currentPlayingSegmentIndex = ref(-1);
|
||||
const followPlayerMode = ref(localStorage.getItem('shareFollowPlayerMode') === 'true');
|
||||
|
||||
// Server-rendered transcript info (only used in readable mode)
|
||||
const hasDialogue = ref({{ 'true' if transcript and transcript.has_speakers else 'false' }});
|
||||
const plainTextTranscript = {{ (transcript.plain_text if transcript else '')|tojson|safe }};
|
||||
|
||||
// Custom audio player state
|
||||
const audioIsPlaying = ref(false);
|
||||
const audioCurrentTime = ref(0);
|
||||
// Use server-side duration if available (more reliable for formats like WebM)
|
||||
const audioDuration = ref(recordingData.audio_duration || 0);
|
||||
const audioIsMuted = ref(false);
|
||||
const playerVolume = ref(1.0);
|
||||
|
||||
// Playback speed state
|
||||
const playbackRate = ref(1.0);
|
||||
const showSpeedMenu = ref(false);
|
||||
const playbackSpeeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
|
||||
|
||||
// Computed property for progress percentage
|
||||
const audioProgressPercent = computed(() => {
|
||||
if (!audioDuration.value) return 0;
|
||||
return (audioCurrentTime.value / audioDuration.value) * 100;
|
||||
});
|
||||
|
||||
// Format time as m:ss or h:mm:ss
|
||||
function formatAudioTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Audio event handlers
|
||||
function handleAudioPlayPause(event) {
|
||||
audioIsPlaying.value = !event.target.paused;
|
||||
}
|
||||
|
||||
function handleCustomAudioTimeUpdate(event) {
|
||||
audioCurrentTime.value = event.target.currentTime;
|
||||
updateHighlightedSegment(event.target.currentTime);
|
||||
}
|
||||
|
||||
function updateHighlightedSegment(currentTime) {
|
||||
// Find all segments and determine which one is currently playing
|
||||
const segments = document.querySelectorAll('[data-segment-index]');
|
||||
let newIndex = -1;
|
||||
|
||||
segments.forEach((el) => {
|
||||
const startTime = parseFloat(el.dataset.startTime) || 0;
|
||||
const endTime = parseFloat(el.dataset.endTime) || Infinity;
|
||||
const index = parseInt(el.dataset.segmentIndex);
|
||||
|
||||
if (currentTime >= startTime && currentTime < endTime) {
|
||||
newIndex = index;
|
||||
}
|
||||
|
||||
// Update highlight class
|
||||
if (currentTime >= startTime && currentTime < endTime) {
|
||||
el.classList.add('active-playing-segment');
|
||||
} else {
|
||||
el.classList.remove('active-playing-segment');
|
||||
}
|
||||
});
|
||||
|
||||
currentPlayingSegmentIndex.value = newIndex;
|
||||
|
||||
// Auto-scroll to active segment if follow mode is on
|
||||
if (followPlayerMode.value && newIndex >= 0) {
|
||||
const activeEl = document.querySelector(`[data-segment-index="${newIndex}"]`);
|
||||
if (activeEl) {
|
||||
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFollowPlayerMode() {
|
||||
followPlayerMode.value = !followPlayerMode.value;
|
||||
localStorage.setItem('shareFollowPlayerMode', followPlayerMode.value);
|
||||
if (followPlayerMode.value && currentPlayingSegmentIndex.value >= 0) {
|
||||
const activeEl = document.querySelector(`[data-segment-index="${currentPlayingSegmentIndex.value}"]`);
|
||||
if (activeEl) {
|
||||
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioLoadedMetadata(event) {
|
||||
// Only set browser duration if we don't already have a server-side duration
|
||||
if (!audioDuration.value || audioDuration.value === 0) {
|
||||
const duration = event.target.duration;
|
||||
if (duration && isFinite(duration) && duration > 0) {
|
||||
audioDuration.value = duration;
|
||||
}
|
||||
}
|
||||
// Apply saved playback rate when audio loads
|
||||
if (playbackRate.value !== 1) {
|
||||
event.target.playbackRate = playbackRate.value;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioDurationChange(event) {
|
||||
// Only set browser duration if we don't already have a server-side duration
|
||||
if (!audioDuration.value || audioDuration.value === 0) {
|
||||
const duration = event.target.duration;
|
||||
if (duration && isFinite(duration) && duration > 0) {
|
||||
audioDuration.value = duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
audioIsPlaying.value = false;
|
||||
}
|
||||
|
||||
// Audio control functions
|
||||
function toggleAudioPlayback() {
|
||||
if (!audioPlayer.value) return;
|
||||
if (audioPlayer.value.paused) {
|
||||
audioPlayer.value.play();
|
||||
} else {
|
||||
audioPlayer.value.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAudioMute() {
|
||||
if (!audioPlayer.value) return;
|
||||
audioPlayer.value.muted = !audioPlayer.value.muted;
|
||||
audioIsMuted.value = audioPlayer.value.muted;
|
||||
}
|
||||
|
||||
function setAudioVolume(value) {
|
||||
if (!audioPlayer.value) return;
|
||||
playerVolume.value = value;
|
||||
audioPlayer.value.volume = value;
|
||||
if (value > 0 && audioIsMuted.value) {
|
||||
audioPlayer.value.muted = false;
|
||||
audioIsMuted.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function seekAudioByPercent(percent) {
|
||||
if (!audioPlayer.value || !audioDuration.value || !isFinite(audioDuration.value)) return;
|
||||
const time = (percent / 100) * audioDuration.value;
|
||||
audioPlayer.value.currentTime = time;
|
||||
}
|
||||
|
||||
// Playback speed functions
|
||||
function formatPlaybackRate(rate) {
|
||||
if (rate === 1) return '1x';
|
||||
return `${rate}x`;
|
||||
}
|
||||
|
||||
function setPlaybackRate(rate) {
|
||||
playbackRate.value = rate;
|
||||
localStorage.setItem('playbackRate', rate);
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.playbackRate = rate;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize playback rate from localStorage
|
||||
const savedRate = localStorage.getItem('playbackRate');
|
||||
if (savedRate) {
|
||||
const rate = parseFloat(savedRate);
|
||||
if (playbackSpeeds.includes(rate)) {
|
||||
playbackRate.value = rate;
|
||||
}
|
||||
}
|
||||
|
||||
// processedTranscription computed property (used in Vue mode, not readable mode)
|
||||
const processedTranscription = computed(() => {
|
||||
if (readableMode) {
|
||||
// In readable mode, content is server-rendered, so just return minimal info
|
||||
return { hasDialogue: hasDialogue.value, isJson: true, speakers: [], simpleSegments: [], bubbleRows: [] };
|
||||
}
|
||||
|
||||
if (!recording.value?.transcription) {
|
||||
return { hasDialogue: false, content: '', speakers: [], simpleSegments: [], bubbleRows: [] };
|
||||
}
|
||||
|
||||
const transcription = recording.value.transcription;
|
||||
let transcriptionData;
|
||||
|
||||
try {
|
||||
transcriptionData = JSON.parse(transcription);
|
||||
} catch (e) {
|
||||
transcriptionData = null;
|
||||
}
|
||||
|
||||
if (transcriptionData && Array.isArray(transcriptionData)) {
|
||||
const wasDiarized = transcriptionData.some(segment => segment.speaker);
|
||||
|
||||
if (!wasDiarized) {
|
||||
const segments = transcriptionData.map(segment => ({
|
||||
sentence: segment.sentence,
|
||||
startTime: segment.start_time,
|
||||
endTime: segment.end_time
|
||||
}));
|
||||
return {
|
||||
hasDialogue: false,
|
||||
isJson: true,
|
||||
content: segments.map(s => s.sentence).join('\n'),
|
||||
simpleSegments: segments,
|
||||
speakers: [],
|
||||
bubbleRows: []
|
||||
};
|
||||
}
|
||||
|
||||
const speakers = [...new Set(transcriptionData.map(segment => segment.speaker).filter(Boolean))];
|
||||
const speakerColors = {};
|
||||
speakers.forEach((speaker, index) => {
|
||||
speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
|
||||
});
|
||||
|
||||
const simpleSegments = transcriptionData.map(segment => ({
|
||||
speakerId: segment.speaker,
|
||||
speaker: segment.speaker,
|
||||
sentence: segment.sentence,
|
||||
startTime: segment.start_time || segment.startTime,
|
||||
endTime: segment.end_time || segment.endTime,
|
||||
color: speakerColors[segment.speaker] || 'speaker-color-1'
|
||||
}));
|
||||
|
||||
const processedSimpleSegments = [];
|
||||
let lastSpeaker = null;
|
||||
simpleSegments.forEach(segment => {
|
||||
processedSimpleSegments.push({
|
||||
...segment,
|
||||
showSpeaker: segment.speaker !== lastSpeaker
|
||||
});
|
||||
lastSpeaker = segment.speaker;
|
||||
});
|
||||
|
||||
const bubbleRows = [];
|
||||
let lastBubbleSpeaker = null;
|
||||
simpleSegments.forEach(segment => {
|
||||
if (bubbleRows.length === 0 || segment.speaker !== lastBubbleSpeaker) {
|
||||
bubbleRows.push({
|
||||
speaker: segment.speaker,
|
||||
color: segment.color,
|
||||
isMe: segment.speaker && (typeof segment.speaker === 'string') && segment.speaker.toLowerCase().includes('me'),
|
||||
bubbles: []
|
||||
});
|
||||
lastBubbleSpeaker = segment.speaker;
|
||||
}
|
||||
bubbleRows[bubbleRows.length - 1].bubbles.push({
|
||||
sentence: segment.sentence,
|
||||
startTime: segment.startTime || segment.start_time,
|
||||
endTime: segment.endTime || segment.end_time,
|
||||
color: segment.color
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
hasDialogue: true,
|
||||
isJson: true,
|
||||
segments: simpleSegments,
|
||||
simpleSegments: processedSimpleSegments,
|
||||
bubbleRows: bubbleRows,
|
||||
speakers: speakers.map(speaker => ({
|
||||
name: speaker,
|
||||
color: speakerColors[speaker]
|
||||
}))
|
||||
};
|
||||
}
|
||||
return { hasDialogue: false, content: transcription, speakers: [], simpleSegments: [], bubbleRows: [] };
|
||||
});
|
||||
|
||||
// Helper to get global index for bubble view highlighting
|
||||
function getBubbleGlobalIndex(rowIndex, bubbleIndex) {
|
||||
let globalIndex = 0;
|
||||
for (let i = 0; i < rowIndex; i++) {
|
||||
globalIndex += processedTranscription.value.bubbleRows[i]?.bubbles?.length || 0;
|
||||
}
|
||||
return globalIndex + bubbleIndex;
|
||||
}
|
||||
|
||||
function seekAudio(startTime) {
|
||||
if (recording.value.audio_deleted_at) return;
|
||||
if (startTime && audioPlayer.value) {
|
||||
audioPlayer.value.currentTime = parseFloat(startTime);
|
||||
audioPlayer.value.play();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTranscriptClick(event) {
|
||||
if (recording.value.audio_deleted_at) return;
|
||||
// Use closest() to find the segment element even when clicking on child elements
|
||||
const segmentEl = event.target.closest('[data-start-time]');
|
||||
if (segmentEl && audioPlayer.value) {
|
||||
const startTime = segmentEl.dataset.startTime;
|
||||
audioPlayer.value.currentTime = parseFloat(startTime);
|
||||
audioPlayer.value.play();
|
||||
}
|
||||
}
|
||||
|
||||
function copyTranscript(event) {
|
||||
const button = event?.currentTarget;
|
||||
let textToCopy = '';
|
||||
if (readableMode) {
|
||||
// Use server-provided plain text (already formatted with speaker labels if diarized)
|
||||
textToCopy = plainTextTranscript || recording.value.transcription;
|
||||
} else {
|
||||
// Use Vue-computed data
|
||||
if (processedTranscription.value.isJson && processedTranscription.value.simpleSegments) {
|
||||
textToCopy = processedTranscription.value.simpleSegments.map(s =>
|
||||
s.speaker ? `[${s.speaker}]: ${s.sentence}` : s.sentence
|
||||
).join('\n');
|
||||
} else {
|
||||
textToCopy = recording.value.transcription;
|
||||
}
|
||||
}
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
animateCopyButton(button);
|
||||
showToast('Transcription copied to clipboard!');
|
||||
});
|
||||
}
|
||||
|
||||
function copySummary(event) {
|
||||
const button = event?.currentTarget;
|
||||
const textToCopy = recording.value.summary_raw || recording.value.summary;
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
animateCopyButton(button);
|
||||
showToast('Summary copied to clipboard!');
|
||||
});
|
||||
}
|
||||
|
||||
function copyNotes(event) {
|
||||
const button = event?.currentTarget;
|
||||
const textToCopy = recording.value.notes_raw || recording.value.notes;
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
animateCopyButton(button);
|
||||
showToast('Notes copied to clipboard!');
|
||||
});
|
||||
}
|
||||
|
||||
function animateCopyButton(button) {
|
||||
if (!button) return;
|
||||
const icon = button.querySelector('i');
|
||||
if (icon) {
|
||||
const originalClass = icon.className;
|
||||
icon.className = 'fas fa-check mr-1';
|
||||
setTimeout(() => {
|
||||
icon.className = originalClass;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
const newDarkMode = !isDarkMode.value;
|
||||
isDarkMode.value = newDarkMode;
|
||||
|
||||
if (newDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
localStorage.setItem('darkMode', newDarkMode.toString());
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast bg-[var(--bg-success)] text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 pointer-events-auto';
|
||||
toast.style.cursor = 'pointer';
|
||||
toast.innerHTML = `<i class="fas fa-check"></i>${message}`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
// Function to dismiss the toast
|
||||
const dismissToast = () => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
container.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Add click handler to dismiss toast
|
||||
toast.addEventListener('click', () => {
|
||||
clearTimeout(timeoutId);
|
||||
dismissToast();
|
||||
});
|
||||
|
||||
// Auto-dismiss after 3 seconds
|
||||
const timeoutId = setTimeout(dismissToast, 3000);
|
||||
}
|
||||
|
||||
return {
|
||||
recording,
|
||||
activeTab,
|
||||
transcriptView,
|
||||
audioPlayer,
|
||||
readableMode,
|
||||
hasDialogue,
|
||||
processedTranscription,
|
||||
currentPlayingSegmentIndex,
|
||||
followPlayerMode,
|
||||
toggleFollowPlayerMode,
|
||||
getBubbleGlobalIndex,
|
||||
legendExpanded,
|
||||
isDarkMode,
|
||||
seekAudio,
|
||||
handleTranscriptClick,
|
||||
copyTranscript,
|
||||
copySummary,
|
||||
copyNotes,
|
||||
toggleDarkMode,
|
||||
// Custom audio player
|
||||
audioIsPlaying,
|
||||
audioCurrentTime,
|
||||
audioDuration,
|
||||
audioIsMuted,
|
||||
playerVolume,
|
||||
audioProgressPercent,
|
||||
formatAudioTime,
|
||||
handleAudioPlayPause,
|
||||
handleCustomAudioTimeUpdate,
|
||||
handleAudioLoadedMetadata,
|
||||
handleAudioDurationChange,
|
||||
handleAudioEnded,
|
||||
toggleAudioPlayback,
|
||||
toggleAudioMute,
|
||||
setAudioVolume,
|
||||
seekAudioByPercent,
|
||||
// Playback speed
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
playbackSpeeds,
|
||||
formatPlaybackRate,
|
||||
setPlaybackRate
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.config.compilerOptions.delimiters = ['${', '}'];
|
||||
app.mount('#app');
|
||||
|
||||
// Hide loading overlay after app mounts
|
||||
Vue.nextTick(() => {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user