Files
dictia-public/templates/account.html

7208 lines
417 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<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>{{ 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') }}">
<script src="{{ url_for('static', filename='js/i18n.js') }}"></script>
<!-- Loading overlay to prevent FOUC -->
{% include 'includes/loading_overlay.html' %}
<script>
// Initialize i18n
let t = (key) => key; // Default fallback
async function initializeI18n(language) {
const userLanguage = language || "{{ 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);
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const paramsAttr = el.getAttribute('data-i18n-params');
const params = paramsAttr ? JSON.parse(paramsAttr) : {};
el.textContent = t(key, params);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
}
}
// Function to update all UI elements after language change
function updateAllUIElements() {
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const paramsAttr = el.getAttribute('data-i18n-params');
const params = paramsAttr ? JSON.parse(paramsAttr) : {};
el.textContent = t(key, params);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
// Update language options in dropdown to show native names
const langSelect = document.getElementById('ui_language');
if (langSelect) {
const langOptions = {
'en': 'English',
'es': 'Español (Spanish)',
'fr': 'Français (French)',
'zh': '中文 (Chinese)',
'de': 'Deutsch (German)',
'ru': 'Русский (Russian)'
};
Array.from(langSelect.options).forEach(option => {
if (langOptions[option.value]) {
option.textContent = langOptions[option.value];
}
});
}
// Force update any Vue or dynamic content by dispatching a custom event
window.dispatchEvent(new CustomEvent('language-changed', { detail: { language: window.i18n.currentLanguage } }));
}
// Function to change language immediately
async function changeLanguageImmediately(newLanguage) {
try {
// Show loading indicator
const uiLanguageSelect = document.getElementById('ui_language');
uiLanguageSelect.disabled = true;
// Add a loading message
const originalText = uiLanguageSelect.parentElement.querySelector('p').textContent;
const loadingMsg = uiLanguageSelect.parentElement.querySelector('p');
if (loadingMsg) {
loadingMsg.textContent = 'Applying language change...';
}
// Get CSRF token
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
// Create form data with just the language change
const formData = new FormData();
formData.append('csrf_token', csrfToken);
formData.append('user_name', document.getElementById('user_name').value || '');
formData.append('user_job_title', document.getElementById('user_job_title').value || '');
formData.append('user_company', document.getElementById('user_company').value || '');
formData.append('ui_language', newLanguage);
formData.append('transcription_language', document.getElementById('transcription_language').value || '');
formData.append('output_language', document.getElementById('output_language').value || '');
// Send AJAX request to update language preference
const response = await fetch('{{ url_for("auth.account") }}', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrfToken
},
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Store the new language in localStorage for other pages
localStorage.setItem('preferredLanguage', newLanguage);
localStorage.setItem('ui_language_changed', 'true');
// Store current tab to restore after reload
const activeTab = document.querySelector('.tab-link.border-\\[var\\(--border-accent\\)\\]');
if (activeTab) {
const tabName = activeTab.getAttribute('onclick')?.match(/show(\w+)Tab/)?.[1]?.toLowerCase();
if (tabName) {
localStorage.setItem('activeAccountTab', tabName);
}
}
// Reload the page to apply the new language completely
// This ensures all server-side rendered content is also updated
window.location.reload();
} catch (error) {
console.error('Error updating language:', error);
alert('Failed to update language. Please try again.');
// Revert the dropdown to previous value if update failed
document.getElementById('ui_language').value = currentUILanguage;
document.getElementById('ui_language').disabled = false;
// Restore original text if loading message was shown
const loadingMsg = document.getElementById('ui_language').parentElement.querySelector('p');
if (loadingMsg) {
loadingMsg.textContent = originalText;
}
}
}
// 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>
<style>
/* Make the page take full height */
html, body {
height: 100%;
margin: 0;
padding: 0;
}
/* Container layout for full height */
.main-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Tab content area with fixed height and scrolling */
.tab-content-wrapper {
height: calc(100vh - 280px); /* Adjust based on header and tab nav height */
overflow-y: auto;
overflow-x: hidden;
}
@media (max-width: 640px) {
.tab-content-wrapper {
height: calc(100vh - 320px); /* Slightly less height on mobile */
}
}
/* Ensure all tab content divs take full height */
.tab-content {
min-height: 100%;
display: flex;
flex-direction: column;
}
/* Custom scrollbar for content areas */
.tab-content-wrapper::-webkit-scrollbar {
width: 8px;
}
.tab-content-wrapper::-webkit-scrollbar-track {
background: transparent;
}
.tab-content-wrapper::-webkit-scrollbar-thumb {
background-color: var(--border-secondary);
border-radius: 4px;
}
.tab-content-wrapper::-webkit-scrollbar-thumb:hover {
background-color: var(--border-accent);
}
/* Horizontal scrollbar for tab navigation */
.tab-nav {
scrollbar-width: thin;
scrollbar-color: var(--border-secondary) transparent;
}
.tab-nav::-webkit-scrollbar {
height: 6px;
}
.tab-nav::-webkit-scrollbar-track {
background: transparent;
}
.tab-nav::-webkit-scrollbar-thumb {
background-color: var(--border-secondary);
border-radius: 3px;
}
.tab-nav::-webkit-scrollbar-thumb:hover {
background-color: var(--border-accent);
}
/* Custom scrollbar styling for Speakers Management */
#content-speakers .overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
#content-speakers .overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
#content-speakers .overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-secondary);
border-radius: 4px;
transition: background-color 0.2s;
}
#content-speakers .overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--border-accent);
}
/* Ensure the content fits properly */
#content-speakers {
container-type: inline-size;
}
/* Responsive grid adjustments */
@container (max-width: 640px) {
#speakersTabGrid {
grid-template-columns: 1fr;
}
}
@container (min-width: 641px) and (max-width: 768px) {
#speakersTabGrid {
grid-template-columns: repeat(2, 1fr);
}
}
@container (min-width: 769px) and (max-width: 1024px) {
#speakersTabGrid {
grid-template-columns: repeat(3, 1fr);
}
}
@container (min-width: 1025px) {
#speakersTabGrid {
grid-template-columns: repeat(4, 1fr);
}
}
/* Documentation prose styling */
#help-rendered-content h1 { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); margin-bottom: 1rem; margin-top: 0; padding-bottom: 0.5rem; border-bottom: 2px solid var(--border-accent); }
#help-rendered-content h2 { font-size: 1.35rem; font-weight: 600; color: var(--text-primary); margin-top: 2rem; margin-bottom: 0.75rem; }
#help-rendered-content h3 { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); margin-top: 1.5rem; margin-bottom: 0.5rem; }
#help-rendered-content p { color: var(--text-secondary); line-height: 1.7; margin-bottom: 1rem; }
#help-rendered-content ul, #help-rendered-content ol { color: var(--text-secondary); margin-bottom: 1rem; padding-left: 1.5rem; }
#help-rendered-content li { margin-bottom: 0.35rem; line-height: 1.6; }
#help-rendered-content strong { color: var(--text-primary); font-weight: 600; }
#help-rendered-content code { background: var(--bg-tertiary); color: var(--text-accent); padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.85em; }
#help-rendered-content pre { background: var(--bg-tertiary); border: 1px solid var(--border-primary); border-radius: 8px; padding: 1rem; overflow-x: auto; margin-bottom: 1rem; }
#help-rendered-content pre code { background: transparent; padding: 0; color: var(--text-primary); }
#help-rendered-content table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.9rem; }
#help-rendered-content th { background: var(--bg-tertiary); text-align: left; padding: 0.6rem 0.75rem; border: 1px solid var(--border-primary); color: var(--text-primary); font-weight: 600; }
#help-rendered-content td { padding: 0.6rem 0.75rem; border: 1px solid var(--border-primary); color: var(--text-secondary); }
#help-rendered-content tr:hover td { background: var(--bg-tertiary); }
#help-rendered-content blockquote { border-left: 3px solid var(--border-accent); padding-left: 1rem; margin-left: 0; margin-bottom: 1rem; color: var(--text-muted); font-style: italic; }
#help-rendered-content a { color: var(--text-accent); text-decoration: none; }
#help-rendered-content a:hover { text-decoration: underline; }
#help-rendered-content hr { border: none; border-top: 1px solid var(--border-primary); margin: 2rem 0; }
/* Admonition styling */
#help-rendered-content .admonition { border-left: 4px solid; border-radius: 0 8px 8px 0; padding: 1rem 1.25rem; margin-bottom: 1rem; }
#help-rendered-content .admonition-title { font-weight: 600; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
#help-rendered-content .admonition-title::before { font-family: "Font Awesome 6 Free"; font-weight: 900; }
#help-rendered-content .admonition.tip, #help-rendered-content .admonition.hint { border-color: #10b981; background: rgba(16, 185, 129, 0.08); }
#help-rendered-content .admonition.tip .admonition-title, #help-rendered-content .admonition.hint .admonition-title { color: #10b981; }
#help-rendered-content .admonition.tip .admonition-title::before, #help-rendered-content .admonition.hint .admonition-title::before { content: "\f0eb"; }
#help-rendered-content .admonition.note, #help-rendered-content .admonition.info { border-color: #3b82f6; background: rgba(59, 130, 246, 0.08); }
#help-rendered-content .admonition.note .admonition-title, #help-rendered-content .admonition.info .admonition-title { color: #3b82f6; }
#help-rendered-content .admonition.note .admonition-title::before, #help-rendered-content .admonition.info .admonition-title::before { content: "\f05a"; }
#help-rendered-content .admonition.warning, #help-rendered-content .admonition.caution { border-color: #f59e0b; background: rgba(245, 158, 11, 0.08); }
#help-rendered-content .admonition.warning .admonition-title, #help-rendered-content .admonition.caution .admonition-title { color: #f59e0b; }
#help-rendered-content .admonition.warning .admonition-title::before, #help-rendered-content .admonition.caution .admonition-title::before { content: "\f071"; }
#help-rendered-content .admonition.danger, #help-rendered-content .admonition.error { border-color: #ef4444; background: rgba(239, 68, 68, 0.08); }
#help-rendered-content .admonition.danger .admonition-title, #help-rendered-content .admonition.error .admonition-title { color: #ef4444; }
#help-rendered-content .admonition.danger .admonition-title::before, #help-rendered-content .admonition.error .admonition-title::before { content: "\f06a"; }
#help-rendered-content .admonition p:last-child { margin-bottom: 0; }
/* Help sidebar mobile responsive */
@media (max-width: 640px) {
#help-sidebar { position: fixed; left: -100%; top: 0; bottom: 0; z-index: 40; width: 280px; transition: left 0.3s ease; }
#help-sidebar.open { left: 0; }
#help-sidebar-toggle { display: flex !important; }
}
/* Hide scrollbar for tabs */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Custom themed audio player for speaker snippets */
.snippet-audio-container {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: 6px;
width: 100%;
}
.snippet-audio-btn {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bg-accent);
color: var(--text-accent);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
font-size: 10px;
transition: opacity 0.2s;
}
.snippet-audio-btn:hover {
opacity: 0.8;
}
.snippet-audio-progress {
flex: 1;
height: 4px;
background: var(--bg-secondary);
border-radius: 2px;
position: relative;
cursor: pointer;
overflow: hidden;
}
.snippet-audio-progress-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: var(--bg-accent);
border-radius: 2px;
transition: width 0.1s linear;
}
.snippet-audio-time {
font-size: 10px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* Hide the native audio element */
.snippet-audio {
display: none;
}
</style>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="main-container container mx-auto px-4 sm:px-6 lg:px-8 py-6">
<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-9 w-9 mr-2">
DictIA
</a>
</h1>
<div class="flex items-center space-x-2">
<button id="darkModeToggle" class="p-2 rounded-full text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] dark:text-gray-400 dark:hover:bg-gray-700 transition-colors duration-200">
<i id="darkModeIcon" class="fas fa-moon"></i>
</button>
<div class="relative" id="userDropdown">
<button id="userDropdownButton" class="flex items-center px-3 py-2 border border-[var(--border-secondary)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-accent)] focus:outline-none">
<i class="fas fa-user mr-2"></i>
<span>{{ current_user.username }}</span>
<i class="fas fa-chevron-down ml-2"></i>
</button>
<div id="userDropdownMenu" class="hidden absolute right-0 mt-2 w-48 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-50">
<a href="{{ url_for('recordings.index') }}" class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)]">
<i class="fas fa-home mr-2"></i> <span data-i18n="nav.home">Home</span>
</a>
<a href="{{ url_for('auth.account') }}" class="block px-4 py-2 text-[var(--text-accent)] bg-[var(--bg-accent)]">
<i class="fas fa-user-circle mr-2"></i> <span data-i18n="nav.account">Account</span>
</a>
{% if current_user.is_admin or is_team_admin %}
<a href="{{ url_for('admin.admin') }}" class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)]">
<i class="fas {% if current_user.is_admin %}fa-user-shield{% else %}fa-users-cog{% endif %} mr-2"></i>
<span data-i18n="{% if current_user.is_admin %}nav.admin{% else %}nav.groupManagement{% endif %}">{% if current_user.is_admin %}Admin{% else %}Team Management{% endif %}</span>
</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}" class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-danger)]">
<i class="fas fa-sign-out-alt mr-2"></i> <span data-i18n="nav.signOut">Logout</span>
</a>
</div>
</div>
</div>
</header>
<main class="flex-grow flex flex-col">
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-lg border border-[var(--border-primary)] flex flex-col flex-grow overflow-hidden">
<div class="border-b border-[var(--border-primary)]">
<div class="relative">
<!-- Scroll gradient indicators -->
<div class="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-[var(--bg-secondary)] to-transparent z-[1] opacity-0 transition-opacity duration-200 flex items-center justify-start cursor-pointer hover:bg-[var(--bg-tertiary)] hover:bg-opacity-30" id="left-scroll-indicator">
<i class="fas fa-chevron-left text-[var(--text-muted)] ml-1"></i>
</div>
<div class="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-[var(--bg-secondary)] to-transparent z-[1] opacity-100 transition-opacity duration-200 flex items-center justify-end cursor-pointer hover:bg-[var(--bg-tertiary)] hover:bg-opacity-30" id="right-scroll-indicator">
<i class="fas fa-chevron-right text-[var(--text-muted)] mr-1"></i>
</div>
<nav class="tab-nav px-1 sm:px-4 lg:px-6 pt-3 sm:pt-4 lg:pt-6 pb-4 sm:pb-6 -mb-px overflow-x-auto scrollbar-hide" aria-label="Tabs" id="tabs-container">
<div class="inline-flex gap-1 sm:gap-2">
<a href="#" id="tab-account" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-[var(--border-accent)] text-[var(--text-accent)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-user-circle mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="account.title">Account</span>
</a>
<a href="#" id="tab-prompts" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-cog mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.promptOptions">Prompts</span>
</a>
<a href="#" id="tab-shares" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-share-alt mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.sharedTranscripts">Shares</span>
</a>
<a href="#" id="tab-speakers" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-users mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.speakersManagement">Speakers</span>
</a>
<a href="#" id="tab-folders" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]" style="display: none;">
<i class="fas fa-folder mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.folderManagement">Folder Management</span>
</a>
<a href="#" id="tab-tags" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-tags mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.tagManagement">Tags</span>
</a>
<a href="#" id="tab-templates" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-file-alt mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.templates">Templates</span>
</a>
<a href="#" id="tab-tokens" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-key mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.apiTokens">API Tokens</span>
</a>
<a href="#" id="tab-help" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-question-circle mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.help">Aide</span>
</a>
<a href="#" id="tab-about" class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-info-circle mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span data-i18n="accountTabs.about">À propos</span>
</a>
</div>
</nav>
</div>
</div>
<div class="tab-content-wrapper px-4 sm:px-6 lg:px-8 pb-4 sm:pb-6 lg:pb-8">
<div id="content-account" class="tab-content pt-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6" data-i18n="account.title">Account Information</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 %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left Column: Form -->
<div>
<form id="accountInfoForm" method="POST" action="{{ url_for('auth.account') }}" class="space-y-6">
<div class="mb-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2" data-i18n="account.personalInfo">Personal Information</h3>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
<div>
<label for="user_name" class="block text-sm font-medium text-[var(--text-muted)]" data-i18n="account.fullName">Nom Complet</label>
<input type="text" name="user_name" id="user_name" value="{{ current_user.name or '' }}" data-i18n-placeholder="form.yourFullName" 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)]">
</div>
<div>
<label for="user_job_title" class="block text-sm font-medium text-[var(--text-muted)]" data-i18n="account.jobTitle">Titre du Poste</label>
<input type="text" name="user_job_title" id="user_job_title" value="{{ current_user.job_title or '' }}" placeholder="ex., Ingénieur logiciel" 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)]">
</div>
<div>
<label for="user_company" class="block text-sm font-medium text-[var(--text-muted)]" data-i18n="account.companyOrganization">Entreprise / Organisation</label>
<input type="text" name="user_company" id="user_company" value="{{ current_user.company or '' }}" placeholder="ex., InnovA AI" 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)]">
</div>
</div>
</div>
<div class="mb-6">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2" data-i18n="account.languagePreferences">Préférences Linguistiques</h3>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
<div>
<label for="ui_language" class="block text-sm font-medium text-[var(--text-muted)]" data-i18n="account.interfaceLanguage">Langue de l'Interface</label>
<select name="ui_language" id="ui_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 value="en" {% if current_user.ui_language == 'en' or not current_user.ui_language %}selected{% endif %}>English</option>
<option value="zh" {% if current_user.ui_language == 'zh' %}selected{% endif %}>中文 (Chinese)</option>
<option value="de" {% if current_user.ui_language == 'de' %}selected{% endif %}>Deutsch (German)</option>
<option value="es" {% if current_user.ui_language == 'es' %}selected{% endif %}>Español (Spanish)</option>
<option value="fr" {% if current_user.ui_language == 'fr' %}selected{% endif %}>Français (French)</option>
<option value="ru" {% if current_user.ui_language == 'ru' %}selected{% endif %}>Русский (Russian)</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="account.chooseLanguageForInterface">Choose the language for the application interface.</p>
</div>
<div>
<label for="transcription_language" class="block text-sm font-medium text-[var(--text-muted)]" data-i18n="account.transcriptionLanguage">Langue de Transcription</label>
<select name="transcription_language" id="transcription_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 value="" {% if not current_user.transcription_language %}selected{% endif %}>Détection automatique</option>
<option value="en" {% if current_user.transcription_language == 'en' %}selected{% endif %}>Anglais</option>
<option value="es" {% if current_user.transcription_language == 'es' %}selected{% endif %}>Espagnol</option>
<option value="fr" {% if current_user.transcription_language == 'fr' %}selected{% endif %}>Français</option>
<option value="de" {% if current_user.transcription_language == 'de' %}selected{% endif %}>Allemand</option>
<option value="it" {% if current_user.transcription_language == 'it' %}selected{% endif %}>Italien</option>
<option value="pt" {% if current_user.transcription_language == 'pt' %}selected{% endif %}>Portugais</option>
<option value="nl" {% if current_user.transcription_language == 'nl' %}selected{% endif %}>Néerlandais</option>
<option value="ru" {% if current_user.transcription_language == 'ru' %}selected{% endif %}>Russe</option>
<option value="zh" {% if current_user.transcription_language == 'zh' %}selected{% endif %}>Chinois</option>
<option value="ja" {% if current_user.transcription_language == 'ja' %}selected{% endif %}>Japonais</option>
<option value="ko" {% if current_user.transcription_language == 'ko' %}selected{% endif %}>Coréen</option>
<option value="ar" {% if current_user.transcription_language == 'ar' %}selected{% endif %}>Arabe</option>
<option value="hi" {% if current_user.transcription_language == 'hi' %}selected{% endif %}>Hindi</option>
<option value="pl" {% if current_user.transcription_language == 'pl' %}selected{% endif %}>Polonais</option>
<option value="uk" {% if current_user.transcription_language == 'uk' %}selected{% endif %}>Ukrainien</option>
<option value="vi" {% if current_user.transcription_language == 'vi' %}selected{% endif %}>Vietnamien</option>
<option value="th" {% if current_user.transcription_language == 'th' %}selected{% endif %}>Thaï</option>
<option value="tr" {% if current_user.transcription_language == 'tr' %}selected{% endif %}>Turc</option>
<option value="id" {% if current_user.transcription_language == 'id' %}selected{% endif %}>Indonésien</option>
<option value="sv" {% if current_user.transcription_language == 'sv' %}selected{% endif %}>Suédois</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="account.leaveBlankForAutoDetect">Sélectionner détection automatique pour laisser le service de transcription déterminer la langue.</p>
</div>
<div>
<label for="output_language" class="block text-sm font-medium text-[var(--text-muted)]" data-i18n="account.preferredOutputLanguage">Langue Préférée pour Chatbot et Résumés</label>
<input type="text" name="output_language" id="output_language" value="{{ current_user.output_language or '' }}" placeholder="ex., Français, Anglais, Espagnol" 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)]">
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="account.languageForSummaries">Langue pour les titres, résumés et chat. Laisser vide pour le défaut (comportement par défaut de vos modèles choisis).</p>
</div>
</div>
</div>
<button type="submit" 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-save mr-2"></i> <span data-i18n="account.saveAllPreferences">Save All Preferences</span>
</button>
</form>
</div>
<!-- Right Column: Stats, User Details, Account Actions -->
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2" data-i18n="account.statistics">Account Statistics</h3>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-3xl font-bold text-[var(--text-accent)]">{{ current_user.recordings|length }}</span>
<span class="block text-sm text-[var(--text-muted)]" data-i18n="account.totalRecordings">Total Recordings</span>
</div>
<div class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-3xl font-bold text-[var(--text-accent)]">
{% set completed_count = current_user.recordings|selectattr('status', 'equalto', 'COMPLETED')|list|length %}
{{ completed_count }}
</span>
<span class="block text-sm text-[var(--text-muted)]" data-i18n="account.completedRecordings">Completed</span>
</div>
<div class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-3xl font-bold text-[var(--text-warn-strong)]">
{% set processing_count = current_user.recordings|selectattr('status', 'in', ['PENDING', 'PROCESSING', 'SUMMARIZING'])|list|length %}
{{ processing_count }}
</span>
<span class="block text-sm text-[var(--text-muted)]" data-i18n="account.processingRecordings">Processing</span>
</div>
<div class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-3xl font-bold text-[var(--text-danger)]">
{% set failed_count = current_user.recordings|selectattr('status', 'equalto', 'FAILED')|list|length %}
{{ failed_count }}
</span>
<span class="block text-sm text-[var(--text-muted)]" data-i18n="account.failedRecordings">Failed</span>
</div>
</div>
</div>
</div>
<div class="mt-6"> <!-- Added mt-6 for spacing, was mb-6 on User Details -->
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2" data-i18n="account.userDetails">User Details</h3>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<div class="mb-3">
<span class="block text-sm font-medium text-[var(--text-muted)]" data-i18n="account.username">Username</span>
<span class="block text-[var(--text-primary)]">{{ current_user.username }}</span>
</div>
<div class="mb-3">
<span class="block text-sm font-medium text-[var(--text-muted)]">Email</span>
<span class="block text-[var(--text-primary)]">{{ current_user.email }}</span>
</div>
</div>
</div>
{% if sso_enabled %}
<div class="mt-6">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2">Single Sign-On</h3>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-3">
<div class="flex items-center justify-between">
<div>
<span class="block text-sm font-medium text-[var(--text-muted)]" data-i18n="account.ssoProvider">Provider</span>
<span class="block text-[var(--text-primary)]">{{ sso_provider_name }}</span>
</div>
{% if sso_linked %}
<span class="px-2 py-1 text-xs rounded bg-[var(--bg-success-light)] text-[var(--text-success-strong)]" data-i18n="account.ssoLinked">Linked</span>
{% else %}
<span class="px-2 py-1 text-xs rounded bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]" data-i18n="account.ssoNotLinked">Not linked</span>
{% endif %}
</div>
{% if sso_linked %}
<div class="text-xs text-[var(--text-muted)] break-all">
<span class="font-medium text-[var(--text-secondary)]" data-i18n="account.ssoSubject">Subject:</span> {{ sso_subject }}
</div>
{% if has_password %}
<form method="POST" action="{{ url_for('auth.sso_unlink') }}" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" onclick="return confirm(t('account.ssoUnlinkConfirm'))" class="w-full inline-flex items-center justify-center px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-danger)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-unlink mr-2"></i> <span data-i18n="account.ssoUnlinkAccount" data-i18n-params='{"provider": "{{ sso_provider_name }}"}'>Unlink {{ sso_provider_name }} account</span>
</button>
</form>
{% elif not password_login_disabled or current_user.is_admin %}
<div class="mt-2 text-xs text-[var(--text-muted)]">
<i class="fas fa-info-circle mr-1"></i> <span data-i18n="account.ssoSetPasswordFirst">To unlink SSO, you must first set a password.</span>
</div>
{% endif %}
{% else %}
<form method="POST" action="{{ url_for('auth.sso_link') }}" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" 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-link mr-2"></i> <span data-i18n="account.ssoLinkAccount" data-i18n-params='{"provider": "{{ sso_provider_name }}"}'>Link {{ sso_provider_name }} account</span>
</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
<div class="mt-6"> <!-- Added mt-6 for spacing, was mt-8 on Account Actions -->
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2" data-i18n="account.accountActions">Account Actions</h3>
<div class="flex flex-col space-y-3">
<a href="{{ url_for('recordings.index') }}" class="inline-flex items-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-microphone mr-2"></i> <span data-i18n="account.goToRecordings">Go to Recordings</span>
</a>
{% if not password_login_disabled or current_user.is_admin %}
<button id="changePasswordBtn" class="inline-flex items-center px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-key mr-2"></i> <span data-i18n="account.changePassword">{% if has_password %}Change Password{% else %}Set Password{% endif %}</span>
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div id="content-prompts" class="hidden tab-content pt-6">
<!-- Header with Auto-summarization toggle -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)]" data-i18n="accountTabs.promptOptions">Prompt Options</h2>
{% if not admin_disabled_auto_summarization %}
<div class="flex items-center gap-3" title="Automatically generate summaries after transcription completes">
<span class="text-sm text-[var(--text-secondary)]" data-i18n="account.autoSummarize">Auto-summarize</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="autoSummarizationToggle" class="sr-only peer" {% if auto_summarization %}checked{% endif %}>
<div class="w-11 h-6 bg-[var(--bg-tertiary)] peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-[var(--border-focus)] rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-[var(--text-muted)] after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--bg-accent)] peer-checked:after:bg-white"></div>
</label>
</div>
{% else %}
<div class="text-sm text-[var(--text-muted)] italic" title="Auto-summarization has been disabled by the administrator">
<i class="fas fa-lock mr-1"></i> <span data-i18n="account.autoSummarizationDisabled">Auto-summarization disabled by admin</span>
</div>
{% endif %}
</div>
<form id="customPromptsForm" method="POST" action="{{ url_for('auth.account') }}" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- Event Extraction Settings -->
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-4">
<i class="fas fa-calendar-alt mr-2"></i>
<span data-i18n="eventExtraction.title">Event Extraction</span>
</h3>
<div class="mb-4">
<label class="flex items-start space-x-3">
<input type="checkbox" name="extract_events" id="extract_events"
{% if current_user.extract_events %}checked{% endif %}
class="mt-1 h-4 w-4 text-[var(--border-accent)] focus:ring-[var(--border-focus)] border-[var(--border-secondary)] rounded">
<div>
<span class="block text-sm font-medium text-[var(--text-primary)]" data-i18n="eventExtraction.enableLabel">
Enable automatic event extraction from transcripts
</span>
<span class="block text-xs text-[var(--text-muted)] mt-1" data-i18n="eventExtraction.description">
When enabled, the AI will identify meetings, appointments, and deadlines mentioned in your recordings and create downloadable calendar events.
</span>
</div>
</label>
</div>
<div class="mt-4 p-3 bg-[var(--bg-info-light)] text-[var(--text-info-strong)] rounded-lg text-sm">
<i class="fas fa-info-circle mr-1"></i>
<span data-i18n="eventExtraction.info">
Extracted events will appear in a new "Events" tab on recordings where calendar items are detected.
</span>
</div>
</div>
<!-- Custom Prompts Section -->
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-4" data-i18n="customPrompts.summaryGeneration">Summary Generation Prompt</h3>
<div class="mb-4">
<label for="summary_prompt_custom" class="block text-sm font-medium text-[var(--text-muted)] mb-2">
<span data-i18n="customPrompts.yourCustomPrompt">Your Custom Summary Prompt</span>
</label>
<textarea name="summary_prompt" id="summary_prompt_custom" rows="8"
placeholder="Describe how you want your summaries structured. Leave blank to use the admin's default prompt."
data-i18n-placeholder="customPrompts.promptPlaceholder"
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)]">{{ current_user.summary_prompt or '' }}</textarea>
<p class="text-xs text-[var(--text-light)] mt-2">
<span data-i18n="customPrompts.promptDescription">This prompt will be used to generate summaries for your transcriptions. It overrides the admin's default prompt.</span>
</p>
</div>
<div class="mt-6 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
<h4 class="text-sm font-semibold text-[var(--text-accent)] mb-2">
<i class="fas fa-info-circle mr-1"></i>
<span data-i18n="customPrompts.currentDefaultPrompt">Current Default Prompt (Used if you leave the above blank)</span>
</h4>
<div class="bg-[var(--bg-primary)] p-3 rounded border border-[var(--border-primary)]">
<pre class="text-xs text-[var(--text-muted)] whitespace-pre-wrap">{{ default_summary_prompt_text }}</pre>
</div>
</div>
<div class="mt-4">
<details class="cursor-pointer">
<summary class="text-sm text-[var(--text-accent)] hover:text-[var(--text-accent-hover)]">
<i class="fas fa-question-circle mr-1"></i>
<span data-i18n="customPrompts.tipsTitle">Tips for Writing Effective Prompts</span>
</summary>
<div class="mt-3 p-3 bg-[var(--bg-info-light)] text-[var(--text-info-strong)] rounded-lg text-sm">
<ul class="list-disc list-inside space-y-1">
<li data-i18n="customPrompts.tip1">Be specific about the sections you want in your summary</li>
<li data-i18n="customPrompts.tip2">Use clear formatting instructions (e.g., "Use bullet points", "Create numbered lists")</li>
<li data-i18n="customPrompts.tip3">Specify if you want certain information prioritized</li>
<li data-i18n="customPrompts.tip4">The system will automatically provide the transcript content to the AI</li>
<li data-i18n="customPrompts.tip5">Your output language preference (if set) will be applied automatically</li>
</ul>
</div>
</details>
</div>
<!-- Transcription Hints Section -->
<div class="mt-8 pt-6 border-t border-[var(--border-secondary)]">
<h3 class="text-base font-semibold text-[var(--text-primary)] mb-1">
<i class="fas fa-spell-check mr-2 text-[var(--text-accent)]"></i>
<span data-i18n="account.transcriptionHints">Transcription Hints</span>
</h3>
<p class="text-xs text-[var(--text-muted)] mb-4" data-i18n="account.transcriptionHintsDesc">
Ces paramètres par défaut s'appliquent quand aucune étiquette ou dossier ne définit ses propres réglages. Ils améliorent la précision de la transcription pour votre contexte.
</p>
<div class="space-y-4">
<div>
<label for="transcription_hotwords" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="account.defaultHotwords">
Mots-clés par défaut
</label>
<input type="text" name="transcription_hotwords" id="transcription_hotwords"
value="{{ current_user.transcription_hotwords or '' }}"
data-i18n-placeholder="account.defaultHotwordsPlaceholder"
placeholder="ex., DictIA, CTranslate2, PyAnnote"
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)]">
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="help.defaultHotwordsHelp">
Mots ou expressions séparés par des virgules que le modèle de transcription devrait prioriser (noms de marques, acronymes, termes techniques).
</p>
</div>
<div>
<label for="transcription_initial_prompt" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="account.defaultInitialPrompt">
Invite initiale par défaut
</label>
<textarea name="transcription_initial_prompt" id="transcription_initial_prompt" rows="2"
data-i18n-placeholder="account.defaultInitialPromptPlaceholder"
placeholder="ex., C'est une réunion sur les outils de transcription IA. Les intervenants discutent de CTranslate2, PyAnnote et SDRs."
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)]">{{ current_user.transcription_initial_prompt or '' }}</textarea>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="help.defaultInitialPromptHelp">
Contexte pour orienter le style et le vocabulaire du modèle de transcription. Décrivez le sujet ou le contenu attendu de vos enregistrements.
</p>
</div>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="inline-flex items-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-save mr-2"></i> <span data-i18n="buttons.saveSettings">Save Settings</span>
</button>
</div>
</form>
</div>
<div id="content-shares" class="hidden tab-content pt-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6" data-i18n="accountTabs.sharedTranscripts">Shared Transcripts</h2>
<div id="shares-container" class="space-y-4">
<!-- Shares will be loaded here by JavaScript -->
</div>
</div>
<div id="content-speakers" class="hidden tab-content pt-6 flex flex-col h-full">
<!-- Header with Auto-label toggle -->
<div class="flex-shrink-0 mb-4 flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-1" data-i18n="accountTabs.speakersManagement">Speakers Management</h2>
<p class="text-sm text-[var(--text-secondary)] hidden sm:block" data-i18n="speakersManagement.description">Manage your saved speakers. These are automatically saved when you use speaker names in your recordings.</p>
</div>
{% if speaker_embeddings_enabled %}
<div class="flex items-center gap-2 sm:gap-3 ml-4 flex-shrink-0" title="Automatically label speakers in new recordings when voice profiles match">
<span class="text-sm text-[var(--text-secondary)] hidden sm:inline" data-i18n="account.autoLabel">Auto-label</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="autoSpeakerLabellingToggle" class="sr-only peer" {% if auto_speaker_labelling %}checked{% endif %}>
<div class="w-11 h-6 bg-[var(--bg-tertiary)] peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-[var(--border-focus)] rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-[var(--text-muted)] after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--bg-accent)] peer-checked:after:bg-white"></div>
</label>
<select id="autoSpeakerLabellingThreshold" class="text-sm bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-md px-2 py-1 text-[var(--text-primary)] focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" {% if not auto_speaker_labelling %}disabled{% endif %}>
<option value="high" {% if auto_speaker_labelling_threshold == 'high' %}selected{% endif %}>High</option>
<option value="medium" {% if auto_speaker_labelling_threshold == 'medium' %}selected{% endif %}>Medium</option>
<option value="low" {% if auto_speaker_labelling_threshold == 'low' %}selected{% endif %}>Low</option>
</select>
</div>
{% endif %}
</div>
<!-- Top toolbar with bulk actions -->
<div class="flex-shrink-0 flex flex-wrap justify-between items-center gap-2 py-2 sm:py-3 px-3 sm:px-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] mb-3">
<div class="flex items-center gap-2 sm:gap-4">
<label class="flex items-center text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors">
<input type="checkbox" id="selectAllSpeakersTop" class="select-all-checkbox mr-1.5 sm:mr-2 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span class="hidden sm:inline">Select All</span>
<span class="sm:hidden">All</span>
</label>
<span class="text-sm text-[var(--text-muted)]">
<span id="speakersTabCountTop" class="speakers-count">0</span> <span class="hidden sm:inline" data-i18n="speakersManagement.totalSpeakers">speakers saved</span>
</span>
<input type="text" id="speakerSearchInput" placeholder="Filter by name..." class="speaker-search-input px-2 py-1 text-sm rounded-md border border-[var(--border-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-[var(--text-light)] focus:outline-none focus:border-[var(--border-accent)] focus:ring-1 focus:ring-[var(--border-accent)] w-32 sm:w-40" autocomplete="off">
</div>
<div class="flex space-x-1 sm:space-x-2">
<button class="bulk-delete-btn px-2 sm:px-3 py-1 sm:py-1.5 text-sm bg-red-600 dark:bg-red-500 text-white rounded-md hover:bg-red-700 dark:hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled title="Delete">
<i class="fas fa-trash"></i>
<span class="hidden sm:inline ml-1">Delete</span>
</button>
<button class="bulk-clear-btn px-2 sm:px-3 py-1 sm:py-1.5 text-sm bg-orange-600 dark:bg-orange-500 text-white rounded-md hover:bg-orange-700 dark:hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled title="Clear Profiles">
<i class="fas fa-eraser"></i>
<span class="hidden sm:inline ml-1">Clear</span>
</button>
<button class="bulk-merge-btn px-2 sm:px-3 py-1 sm:py-1.5 text-sm bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-md hover:bg-[var(--bg-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled title="Merge">
<i class="fas fa-object-group"></i>
<span class="hidden sm:inline ml-1">Merge</span>
</button>
</div>
</div>
<!-- Scrollable content area -->
<div class="flex-1 overflow-y-auto min-h-0 pr-2" style="scrollbar-width: thin; scrollbar-color: var(--border-secondary) transparent;">
<!-- Loading state -->
<div id="speakersTabLoading" class="text-center py-8 hidden">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="text-[var(--text-muted)] mt-2" data-i18n="speakersManagement.loadingSpeakers">Loading speakers...</p>
</div>
<!-- Error state -->
<div id="speakersTabError" class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span id="speakersTabErrorMessage" data-i18n="speakersManagement.failedToLoad">Failed to load speakers</span>
</div>
<!-- Empty state -->
<div id="speakersTabEmpty" class="text-center py-12 hidden">
<i class="fas fa-users text-4xl text-[var(--text-light)] mb-4"></i>
<p class="text-[var(--text-muted)] text-lg mb-2" data-i18n="speakersManagement.noSpeakersYet">No speakers saved yet</p>
<p class="text-sm text-[var(--text-light)]" data-i18n="speakersManagement.speakersWillAppear">Speakers will appear here when you use speaker names in your recordings</p>
</div>
<!-- Speakers grid -->
<div id="speakersTabGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<!-- Speakers will be populated here -->
</div>
</div>
<!-- Bottom toolbar with bulk actions -->
<div class="flex-shrink-0 flex flex-wrap justify-between items-center gap-2 py-2 sm:py-3 px-3 sm:px-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] mt-3">
<div class="flex items-center gap-2 sm:gap-4">
<label class="flex items-center text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors">
<input type="checkbox" id="selectAllSpeakersBottom" class="select-all-checkbox mr-1.5 sm:mr-2 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span class="hidden sm:inline">Select All</span>
<span class="sm:hidden">All</span>
</label>
<span class="text-sm text-[var(--text-muted)]">
<span id="speakersTabCountBottom" class="speakers-count">0</span> <span class="hidden sm:inline" data-i18n="speakersManagement.totalSpeakers">speakers saved</span>
</span>
</div>
<div class="flex space-x-1 sm:space-x-2">
<button class="bulk-delete-btn px-2 sm:px-3 py-1 sm:py-1.5 text-sm bg-red-600 dark:bg-red-500 text-white rounded-md hover:bg-red-700 dark:hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled title="Delete">
<i class="fas fa-trash"></i>
<span class="hidden sm:inline ml-1">Delete</span>
</button>
<button class="bulk-clear-btn px-2 sm:px-3 py-1 sm:py-1.5 text-sm bg-orange-600 dark:bg-orange-500 text-white rounded-md hover:bg-orange-700 dark:hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled title="Clear Profiles">
<i class="fas fa-eraser"></i>
<span class="hidden sm:inline ml-1">Clear</span>
</button>
<button class="bulk-merge-btn px-2 sm:px-3 py-1 sm:py-1.5 text-sm bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-md hover:bg-[var(--bg-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled title="Merge">
<i class="fas fa-object-group"></i>
<span class="hidden sm:inline ml-1">Merge</span>
</button>
</div>
</div>
</div>
<!-- Folders Tab Content -->
<div id="content-folders" class="hidden tab-content pt-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6" data-i18n="folderManagement.title">Folder Management</h2>
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<p class="text-[var(--text-secondary)]" data-i18n="folderManagement.description">Organize your recordings into folders. Unlike tags, a recording can only belong to one folder. Folder prompts are applied before user prompts but after tag prompts.</p>
<button id="createFolderBtn" class="inline-flex items-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-plus mr-2"></i> <span data-i18n="folderManagement.createFolder">Create Folder</span>
</button>
</div>
<!-- Loading state -->
<div id="foldersLoading" class="text-center py-8 hidden">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="text-[var(--text-muted)] mt-2" data-i18n="common.loading">Loading...</p>
</div>
<!-- Error state -->
<div id="foldersError" class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span id="foldersErrorMessage" data-i18n="common.error">Failed to load folders</span>
</div>
<!-- Empty state -->
<div id="foldersEmpty" class="text-center py-12 hidden">
<i class="fas fa-folder text-4xl text-[var(--text-light)] mb-4"></i>
<p class="text-[var(--text-muted)] text-lg mb-2" data-i18n="folderManagement.noFolders">No folders created yet</p>
<p class="text-sm text-[var(--text-light)]" data-i18n="folderManagement.noFoldersDescription">Create your first folder to organize your recordings</p>
</div>
<!-- Folders grid -->
<div id="foldersGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Folders will be populated here -->
</div>
</div>
</div>
<div id="content-tags" class="hidden tab-content pt-6">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6" data-i18n="accountTabs.tagManagement">Tag Management</h2>
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<p class="text-[var(--text-secondary)]" data-i18n="tagManagement.description">Organize your recordings with custom tags. Each tag can have its own summary prompt and default ASR settings.</p>
<button id="createTagBtn" class="inline-flex items-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-plus mr-2"></i> <span data-i18n="tagManagement.createTag">Create Tag</span>
</button>
</div>
<!-- Loading state -->
<div id="tagsLoading" class="text-center py-8 hidden">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="text-[var(--text-muted)] mt-2">Loading tags...</p>
</div>
<!-- Error state -->
<div id="tagsError" class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span id="tagsErrorMessage">Failed to load tags</span>
</div>
<!-- Empty state -->
<div id="tagsEmpty" class="text-center py-12 hidden">
<i class="fas fa-tags text-4xl text-[var(--text-light)] mb-4"></i>
<p class="text-[var(--text-muted)] text-lg mb-2">No tags created yet</p>
<p class="text-sm text-[var(--text-light)]">Create your first tag to organize your recordings</p>
</div>
<!-- Tags grid -->
<div id="tagsGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Tags will be populated here -->
</div>
</div>
</div>
<div id="content-templates" class="hidden tab-content pt-6">
<div class="space-y-6">
<!-- Sub-tabs for Templates (sticky) -->
<div class="sticky top-0 z-10 bg-[var(--bg-secondary)] -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 -mt-6 pt-6 pb-0 border-b border-[var(--border-primary)]">
<nav class="flex gap-4" id="templates-subtabs">
<button id="subtab-transcript" class="pb-2 px-1 text-sm font-medium border-b-2 border-[var(--border-accent)] text-[var(--text-accent)]" data-i18n="transcriptTemplates.tabTitle">
Transcription
</button>
<button id="subtab-export" class="pb-2 px-1 text-sm font-medium border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]" style="display: none;" data-i18n="exportTemplates.tabTitle">
Exportation
</button>
<button id="subtab-naming" class="pb-2 px-1 text-sm font-medium border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]" data-i18n="namingTemplates.tabTitle">
Nommage
</button>
</nav>
</div>
<!-- Transcript Templates Sub-content -->
<div id="subcontent-transcript" class="space-y-6">
<!-- Templates Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)]" data-i18n="transcriptTemplates.title">Modèles de Transcription</h2>
<p class="text-[var(--text-secondary)]" data-i18n="transcriptTemplates.description">Personnalisez le format des transcriptions pour le téléchargement et l'exportation.</p>
</div>
<button id="createTemplateBtn" class="inline-flex items-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-plus mr-2"></i>
<span data-i18n="transcriptTemplates.createNew">Create Template</span>
</button>
</div>
<!-- Templates Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Templates List (Left) -->
<div class="lg:col-span-1">
<div class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-4">
<h3 class="text-sm font-medium text-[var(--text-secondary)] mb-3" data-i18n="transcriptTemplates.availableTemplates">Available Templates</h3>
<div id="templatesList" class="space-y-2">
<!-- Templates will be populated here -->
</div>
<button id="createDefaultTemplatesBtn" class="mt-4 w-full px-3 py-2 text-sm bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-accent-light)] transition-colors">
<i class="fas fa-magic mr-2"></i>
<span data-i18n="transcriptTemplates.createDefaults">Create Default Templates</span>
</button>
</div>
</div>
<!-- Template Editor (Right) -->
<div class="lg:col-span-2">
<div id="templateEditor" class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-6 hidden">
<form id="templateForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="transcriptTemplates.templateName">Template Name</label>
<input type="text" id="templateName" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]" required>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="transcriptTemplates.description">Description</label>
<input type="text" id="templateDescription" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="transcriptTemplates.template">Template</label>
<textarea id="templateContent" rows="6" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] font-mono text-sm" required></textarea>
<!-- Available Variables -->
<div class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md">
<p class="text-xs font-medium text-[var(--text-secondary)] mb-2" data-i18n="transcriptTemplates.availableVars">Available Variables:</p>
<div class="flex flex-wrap gap-2">
{% raw %}<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{index}}</code>
<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{speaker}}</code>
<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{text}}</code>
<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{start_time}}</code>
<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{end_time}}</code>{% endraw %}
</div>
<p class="text-xs text-[var(--text-muted)] mt-2" data-i18n="transcriptTemplates.filters">Filters: |upper for uppercase, |srt for subtitle time format</p>
</div>
</div>
<div class="flex items-center">
<input type="checkbox" id="templateIsDefault" class="mr-2">
<label for="templateIsDefault" class="text-sm text-[var(--text-secondary)]" data-i18n="transcriptTemplates.setDefault">Set as default template</label>
</div>
<div class="flex gap-3">
<button type="submit" class="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-save mr-2"></i>
<span data-i18n="transcriptTemplates.save">Save</span>
</button>
<button type="button" id="cancelTemplateBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-secondary)]">
<span data-i18n="transcriptTemplates.cancel">Cancel</span>
</button>
<button type="button" id="deleteTemplateBtn" class="px-4 py-2 bg-[var(--bg-danger)] text-white rounded-md hover:bg-[var(--bg-danger-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200 hidden">
<i class="fas fa-trash mr-2"></i>
<span data-i18n="transcriptTemplates.delete">Delete</span>
</button>
</div>
</form>
</div>
<!-- Empty State -->
<div id="templateEmptyState" class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-12 text-center">
<i class="fas fa-file-alt text-4xl text-[var(--text-muted)] mb-4"></i>
<p class="text-[var(--text-muted)]" data-i18n="transcriptTemplates.selectOrCreate">Sélectionnez un modèle à modifier ou créez-en un nouveau</p>
</div>
</div>
</div>
</div>
<!-- Export Templates Sub-content -->
<div id="subcontent-export" class="space-y-6 hidden">
<!-- Export Templates Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)]" data-i18n="exportTemplates.title">Export Templates</h2>
<p class="text-[var(--text-secondary)]" data-i18n="exportTemplates.description">Customize how recordings are exported to markdown files.</p>
</div>
<button id="createExportTemplateBtn" class="inline-flex items-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-plus mr-2"></i>
<span data-i18n="exportTemplates.createNew">Create Template</span>
</button>
</div>
<!-- Export Templates Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Export Templates List (Left) -->
<div class="lg:col-span-1">
<div class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-4">
<h3 class="text-sm font-medium text-[var(--text-secondary)] mb-3" data-i18n="exportTemplates.availableTemplates">Available Templates</h3>
<div id="exportTemplatesList" class="space-y-2">
<!-- Export templates will be populated here -->
</div>
<button id="createDefaultExportTemplatesBtn" class="mt-4 w-full px-3 py-2 text-sm bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-accent-light)] transition-colors">
<i class="fas fa-magic mr-2"></i>
<span data-i18n="exportTemplates.createDefaults">Create Default Template</span>
</button>
</div>
</div>
<!-- Export Template Editor (Right) -->
<div class="lg:col-span-2">
<div id="exportTemplateEditor" class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-6 hidden">
<form id="exportTemplateForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="exportTemplates.templateName">Template Name</label>
<input type="text" id="exportTemplateName" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]" required>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="exportTemplates.templateDescription">Description</label>
<input type="text" id="exportTemplateDescription" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="exportTemplates.template">Template</label>
<textarea id="exportTemplateContent" rows="12" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] font-mono text-sm" required></textarea>
<!-- Available Variables -->
<div class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md space-y-3">
<div>
<p class="text-xs font-medium text-[var(--text-secondary)] mb-2" data-i18n="exportTemplates.recordingData">Recording Data:</p>
<div class="flex flex-wrap gap-2">
{% raw %}<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{title}}" title="Click to copy"><code>{{title}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{meeting_date}}" title="Click to copy"><code>{{meeting_date}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{created_at}}" title="Click to copy"><code>{{created_at}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{original_filename}}" title="Click to copy"><code>{{original_filename}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{file_size}}" title="Click to copy"><code>{{file_size}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{participants}}" title="Click to copy"><code>{{participants}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{tags}}" title="Click to copy"><code>{{tags}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>{% endraw %}
</div>
</div>
<div>
<p class="text-xs font-medium text-[var(--text-secondary)] mb-2" data-i18n="exportTemplates.contentSections">Content Sections:</p>
<div class="flex flex-wrap gap-2">
{% raw %}<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{notes}}" title="Click to copy"><code>{{notes}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{summary}}" title="Click to copy"><code>{{summary}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{transcription}}" title="Click to copy"><code>{{transcription}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>{% endraw %}
</div>
</div>
<div>
<p class="text-xs font-medium text-[var(--text-secondary)] mb-2" data-i18n="exportTemplates.availableLabels">Localized Labels (auto-translated):</p>
<div class="flex flex-wrap gap-2">
{% raw %}<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{label.metadata}}" title="Click to copy"><code>{{label.metadata}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{label.notes}}" title="Click to copy"><code>{{label.notes}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{label.summary}}" title="Click to copy"><code>{{label.summary}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{label.transcription}}" title="Click to copy"><code>{{label.transcription}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{label.date}}" title="Click to copy"><code>{{label.date}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>
<button type="button" class="copy-var-btn text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-pointer hover:bg-[var(--bg-accent-light)] transition-colors flex items-center gap-1" data-copy="{{label.created}}" title="Click to copy"><code>{{label.created}}</code><i class="fas fa-copy text-[var(--text-muted)] text-[10px]"></i></button>{% endraw %}
</div>
</div>
<div>
<p class="text-xs font-medium text-[var(--text-secondary)] mb-2" data-i18n="exportTemplates.conditionals">Conditionals:</p>
<p class="text-xs text-[var(--text-muted)]" data-i18n="exportTemplates.conditionalsHint">Use {% raw %}{{#if variable}}...{{/if}}{% endraw %} to conditionally include content</p>
</div>
</div>
</div>
<div class="flex items-center">
<input type="checkbox" id="exportTemplateIsDefault" class="mr-2">
<label for="exportTemplateIsDefault" class="text-sm text-[var(--text-secondary)]" data-i18n="exportTemplates.setDefault">Set as default template</label>
</div>
<div class="flex gap-3">
<button type="submit" class="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-save mr-2"></i>
<span data-i18n="exportTemplates.save">Save</span>
</button>
<button type="button" id="cancelExportTemplateBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-secondary)]">
<span data-i18n="exportTemplates.cancel">Cancel</span>
</button>
<button type="button" id="deleteExportTemplateBtn" class="px-4 py-2 bg-[var(--bg-danger)] text-white rounded-md hover:bg-[var(--bg-danger-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200 hidden">
<i class="fas fa-trash mr-2"></i>
<span data-i18n="exportTemplates.delete">Delete</span>
</button>
</div>
</form>
</div>
<!-- Empty State -->
<div id="exportTemplateEmptyState" class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-12 text-center">
<i class="fas fa-file-export text-4xl text-[var(--text-muted)] mb-4"></i>
<p class="text-[var(--text-muted)]" data-i18n="exportTemplates.selectOrCreate">Select a template to edit or create a new one</p>
</div>
</div>
</div>
</div>
<!-- Naming Templates Sub-content -->
<div id="subcontent-naming" class="space-y-6 hidden">
<!-- Naming Templates Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)]" data-i18n="namingTemplates.title">Modèles de Nommage</h2>
<p class="text-[var(--text-secondary)]" data-i18n="namingTemplates.description">Définissez comment les titres d'enregistrement sont générés à partir des noms de fichiers et du contenu de transcription.</p>
</div>
<button id="createNamingTemplateBtn" class="inline-flex items-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-plus mr-2"></i>
<span data-i18n="namingTemplates.createNew">Créer un Modèle</span>
</button>
</div>
<!-- Naming Templates Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Naming Templates List (Left) -->
<div class="lg:col-span-1">
<div class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-4">
<h3 class="text-sm font-medium text-[var(--text-secondary)] mb-3" data-i18n="namingTemplates.availableTemplates">Modèles Disponibles</h3>
<div id="namingTemplatesList" class="space-y-2">
<!-- Templates will be populated here -->
</div>
<button id="createDefaultNamingTemplatesBtn" class="mt-4 w-full px-3 py-2 text-sm bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-accent-light)] transition-colors">
<i class="fas fa-magic mr-2"></i>
<span data-i18n="namingTemplates.createDefaults">Créer des Modèles par Défaut</span>
</button>
</div>
<!-- User Default Selection -->
<div class="mt-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-4">
<h3 class="text-sm font-medium text-[var(--text-secondary)] mb-3" data-i18n="namingTemplates.userDefault">Modèle par Défaut</h3>
<select id="userDefaultNamingTemplate" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
<option value="" data-i18n="namingTemplates.noDefault">Pas de défaut (titre IA uniquement)</option>
</select>
<p class="mt-2 text-xs text-[var(--text-muted)]" data-i18n="namingTemplates.userDefaultHint">Appliqué quand aucune étiquette n'a de modèle de nommage.</p>
</div>
</div>
<!-- Naming Template Editor (Right) -->
<div class="lg:col-span-2">
<div id="namingTemplateEditor" class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-6 hidden">
<form id="namingTemplateForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="namingTemplates.templateName">Nom du Modèle</label>
<input type="text" id="namingTemplateName" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]" required>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="namingTemplates.descriptionLabel">Description</label>
<input type="text" id="namingTemplateDescription" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="namingTemplates.template">Modèle</label>
<input type="text" id="namingTemplateContent" class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] font-mono text-sm" required placeholder="{% raw %}{{date}} - {{ai_title}}{% endraw %}">
<!-- Available Variables -->
<div class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md">
<p class="text-xs font-medium text-[var(--text-secondary)] mb-2" data-i18n="namingTemplates.availableVars">Variables Disponibles :</p>
<div class="flex flex-wrap gap-2 mb-2">
{% raw %}<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-help" title="Titre généré par l'IA depuis la transcription">{{ai_title}}</code>
<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-help" title="Nom de fichier original sans extension">{{filename}}</code>
<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-help" title="Date de l'enregistrement (AAAA-MM-JJ)">{{date}}</code>
<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-help" title="Date et heure de l'enregistrement">{{datetime}}</code>
<code class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded cursor-help" title="Heure seulement (HH:MM)">{{time}}</code>{% endraw %}
</div>
<p class="text-xs text-[var(--text-muted)]" data-i18n="namingTemplates.customVarsHint">Définissez des motifs regex ci-dessous pour extraire des variables personnalisées des noms de fichiers.</p>
</div>
</div>
<!-- Regex Patterns -->
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="namingTemplates.regexPatterns">Motifs Regex (Optionnel)</label>
<div id="regexPatternsContainer" class="space-y-2">
<!-- Regex patterns will be added here dynamically -->
</div>
<button type="button" id="addRegexPatternBtn" class="mt-2 text-sm text-[var(--text-accent)] hover:text-[var(--text-primary)]">
<i class="fas fa-plus mr-1"></i>
<span data-i18n="namingTemplates.addPattern">Ajouter un Motif</span>
</button>
<p class="mt-1 text-xs text-[var(--text-muted)]" data-i18n="namingTemplates.regexHint">Extraire des données des noms de fichiers. Utilisez des groupes de capture () pour la correspondance. Exemple : (\d{10}) pour les numéros de téléphone.</p>
</div>
<!-- Test Section -->
<div class="border-t border-[var(--border-primary)] pt-4 mt-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="namingTemplates.testTemplate">Tester le Modèle</label>
<div class="flex gap-2">
<input type="text" id="testFilename" class="flex-1 px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm" placeholder="sample-file-name.mp3">
<button type="button" id="testNamingTemplateBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-accent-light)]">
<i class="fas fa-play mr-1"></i>
<span data-i18n="namingTemplates.test">Tester</span>
</button>
</div>
<div id="testResult" class="mt-2 p-2 bg-[var(--bg-tertiary)] rounded-md hidden">
<p class="text-xs text-[var(--text-muted)]" data-i18n="namingTemplates.result">Résultat :</p>
<p id="testResultText" class="text-sm text-[var(--text-primary)] font-medium"></p>
</div>
</div>
<div class="flex gap-3">
<button type="submit" class="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-save mr-2"></i>
<span data-i18n="namingTemplates.save">Save</span>
</button>
<button type="button" id="cancelNamingTemplateBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-secondary)]">
<span data-i18n="namingTemplates.cancel">Cancel</span>
</button>
<button type="button" id="deleteNamingTemplateBtn" class="px-4 py-2 bg-[var(--bg-danger)] text-white rounded-md hover:bg-[var(--bg-danger-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200 hidden">
<i class="fas fa-trash mr-2"></i>
<span data-i18n="namingTemplates.delete">Delete</span>
</button>
</div>
</form>
</div>
<!-- Empty State -->
<div id="namingTemplateEmptyState" class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-12 text-center">
<i class="fas fa-heading text-4xl text-[var(--text-muted)] mb-4"></i>
<p class="text-[var(--text-muted)]" data-i18n="namingTemplates.selectOrCreate">Sélectionnez un modèle à modifier ou créez-en un nouveau</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- API Tokens Tab Content -->
<div id="content-tokens" class="hidden tab-content pt-6">
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)]">
<i class="fas fa-key mr-2 text-[var(--text-accent)]"></i>
<span data-i18n="apiTokens.title">API Tokens</span>
</h2>
<p class="mt-2 text-sm text-[var(--text-muted)]" data-i18n="apiTokens.description">
Create and manage API tokens for programmatic access to your account.
</p>
</div>
<button id="createTokenBtn" class="inline-flex items-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-plus mr-2"></i>
<span data-i18n="apiTokens.createToken">Create Token</span>
</button>
</div>
<!-- Security Notice -->
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800" data-i18n="apiTokens.securityNotice">Security Notice</h3>
<div class="mt-2 text-sm text-amber-700">
<p data-i18n="apiTokens.securityWarning">
Treat API tokens like passwords. They provide full access to your account. Never share tokens in publicly accessible areas such as GitHub, client-side code, or logs.
</p>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="tokensLoading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-[var(--text-muted)] mb-4"></i>
<p class="text-[var(--text-muted)]">Loading tokens...</p>
</div>
<!-- Token List Container -->
<div id="tokensContainer" class="hidden">
<!-- Active Tokens Section -->
<div id="activeTokensSection" class="hidden">
<h3 class="text-sm font-medium text-[var(--text-muted)] mb-3">
<span data-i18n="apiTokens.activeTokens">Active Tokens</span>
<span id="activeTokensCount" class="ml-1 text-xs"></span>
</h3>
<div id="activeTokensList" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<!-- Populated dynamically -->
</div>
</div>
<!-- No Tokens Message -->
<div id="noTokensMessage" class="hidden text-center py-12 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg">
<i class="fas fa-key text-4xl text-[var(--text-muted)] mb-4"></i>
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2" data-i18n="apiTokens.noTokens">No API Tokens</h3>
<p class="text-sm text-[var(--text-muted)] mb-4" data-i18n="apiTokens.createFirstToken">Create your first API token to enable programmatic access.</p>
<button id="createTokenBtnEmpty" class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors">
<i class="fas fa-plus mr-2"></i>
<span data-i18n="apiTokens.createToken">Create Token</span>
</button>
</div>
<!-- Usage Examples -->
<div class="mt-8">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-3">
<i class="fas fa-book mr-2"></i>
<span data-i18n="apiTokens.usageExamples">Usage Examples</span>
</h3>
<div class="bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg p-4 space-y-4">
<div>
<h4 class="text-sm font-semibold text-[var(--text-primary)] mb-2">Using with curl:</h4>
<pre class="bg-[var(--bg-tertiary)] p-3 rounded text-xs overflow-x-auto"><code>curl -H "Authorization: Bearer YOUR_TOKEN_HERE" \
https://your-speakr-instance.com/api/recordings</code></pre>
</div>
<div>
<h4 class="text-sm font-semibold text-[var(--text-primary)] mb-2">Alternative header formats:</h4>
<pre class="bg-[var(--bg-tertiary)] p-3 rounded text-xs overflow-x-auto"><code># X-API-Token header
curl -H "X-API-Token: YOUR_TOKEN_HERE" ...
# API-Token header
curl -H "API-Token: YOUR_TOKEN_HERE" ...
# Query parameter
curl "https://your-speakr-instance.com/api/recordings?token=YOUR_TOKEN_HERE"</code></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- About Tab Content -->
<div id="content-about" class="hidden tab-content pt-6">
<div class="max-w-2xl">
<div class="flex items-center mb-6">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-12 w-12 mr-4">
<div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)]">DictIA</h2>
<p class="text-sm text-[var(--text-muted)]">Application de transcription audio par IA</p>
</div>
</div>
<div class="space-y-6">
<!-- Version -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<h3 class="text-sm font-medium text-[var(--text-muted)] mb-1">Version</h3>
<p class="text-lg font-mono text-[var(--text-primary)]"><span id="about-version">...</span></p>
</div>
<!-- Code source -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<h3 class="text-sm font-medium text-[var(--text-muted)] mb-2">Code source</h3>
<p class="text-sm text-[var(--text-secondary)] mb-3">
DictIA est un logiciel libre distribué sous licence <strong>AGPL-3.0-or-later</strong>.
Vous pouvez consulter, télécharger et modifier le code source.
</p>
<a href="https://gitea.innova-ai.ca/Innova-AI/dictia" target="_blank" rel="noopener"
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors text-sm">
<i class="fas fa-code-branch mr-2"></i>
Accéder au code source
</a>
</div>
<!-- Technologies -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<h3 class="text-sm font-medium text-[var(--text-muted)] mb-2">Technologies</h3>
<ul class="text-sm text-[var(--text-secondary)] space-y-1">
<li><i class="fas fa-microphone-alt mr-2 text-[var(--text-accent)]"></i>WhisperX — Transcription avancée</li>
<li><i class="fas fa-users mr-2 text-[var(--text-accent)]"></i>Pyannote Audio — Diarisation des locuteurs</li>
<li><i class="fas fa-brain mr-2 text-[var(--text-accent)]"></i>OpenAI Whisper — Reconnaissance vocale</li>
</ul>
</div>
<!-- Copyright -->
<div class="text-center text-sm text-[var(--text-muted)] pt-4 border-t border-[var(--border-primary)]">
<p>&copy; {{ now.year }} InnovA AI &middot; Licence AGPL-3.0-or-later</p>
</div>
</div>
</div>
</div>
<!-- Help/Documentation Tab Content -->
{% include 'components/dictia/help-tab.html' %}
</div>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
</footer>
</div>
<!-- Change Password Modal -->
<div id="changePasswordModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="account.changePassword">{% if has_password %}Change Password{% else %}Set Password{% endif %}</h3>
<button id="closeModalBtn" class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">&times;</button>
</div>
<form id="changePasswordForm" method="POST" action="{{ url_for('auth.change_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if has_password %}
<div class="mb-4">
<label for="current_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="changePasswordModal.currentPassword">Current Password</label>
<input type="password" id="current_password" name="current_password" autocomplete="current-password" required class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
{% else %}
<div class="mb-4 p-3 bg-[var(--bg-tertiary)] rounded-md">
<p class="text-sm text-[var(--text-muted)]"><i class="fas fa-info-circle mr-1"></i> You don't have a password set. Create one to enable local login.</p>
</div>
{% endif %}
<div class="mb-4">
<label for="new_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="changePasswordModal.newPassword">New Password</label>
<input type="password" id="new_password" name="new_password" autocomplete="new-password" required minlength="8" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<p class="text-xs text-[var(--text-muted)] mt-1" data-i18n="changePasswordModal.passwordRequirement">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" data-i18n="changePasswordModal.confirmPassword">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" required class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="flex justify-end space-x-3">
<button type="button" id="cancelBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">Cancel</button>
<button type="submit" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]" data-i18n="account.changePassword">{% if has_password %}Change Password{% else %}Set Password{% endif %}</button>
</div>
</form>
</div>
</div>
<!-- Manage Speakers Modal -->
<div id="manageSpeakersModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="account.manageSpeakers">Manage Speakers</h3>
<button id="closeSpeakersModalBtn" class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">&times;</button>
</div>
<div class="mb-4">
<p class="text-sm text-[var(--text-muted)] mb-4">
<span data-i18n="manageSpeakersModal.description">Manage your saved speakers. These are automatically saved when you use speaker names in your recordings.</span>
</p>
<!-- Loading state -->
<div id="speakersLoading" class="text-center py-8 hidden">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="text-[var(--text-muted)] mt-2" data-i18n="manageSpeakersModal.loadingSpeakers">Loading speakers...</p>
</div>
<!-- Error state -->
<div id="speakersError" class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span id="speakersErrorMessage" data-i18n="manageSpeakersModal.failedToLoad">Failed to load speakers</span>
</div>
<!-- Empty state -->
<div id="speakersEmpty" class="text-center py-8 hidden">
<i class="fas fa-users text-4xl text-[var(--text-light)] mb-4"></i>
<p class="text-[var(--text-muted)]" data-i18n="manageSpeakersModal.noSpeakersYet">No speakers saved yet</p>
<p class="text-sm text-[var(--text-light)]" data-i18n="manageSpeakersModal.speakersWillAppear">Speakers will appear here when you use speaker names in your recordings</p>
</div>
<!-- Speakers list -->
<div id="speakersList" class="space-y-2 max-h-96 overflow-y-auto">
<!-- Speakers will be populated here -->
</div>
</div>
<div class="flex justify-between items-center pt-4 border-t border-[var(--border-primary)]">
<div class="text-sm text-[var(--text-muted)]">
<span><span id="speakersCount">0</span> <span data-i18n="manageSpeakersModal.speakersSaved">speakers saved</span></span>
</div>
<div class="flex space-x-3">
<button id="deleteAllSpeakersBtn" class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)] disabled:opacity-50 disabled:cursor-not-allowed" disabled>
<i class="fas fa-trash mr-2"></i> <span data-i18n="buttons.deleteAll">Delete All</span>
</button>
<button id="closeSpeakersBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">
<span data-i18n="buttons.close">Close</span>
</button>
</div>
</div>
</div>
</div>
<!-- Delete All Speakers Confirmation Modal -->
<div id="deleteAllSpeakersModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex items-center mb-4">
<i class="fas fa-exclamation-triangle text-[var(--text-danger)] text-2xl mr-3"></i>
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="buttons.deleteAll">Delete All Speakers</h3>
</div>
<p class="text-[var(--text-secondary)] mb-6">
<span data-i18n="deleteAllSpeakersModal.confirmMessage">Are you sure you want to delete all saved speakers? This action cannot be undone.</span>
</p>
<div class="flex justify-end space-x-3">
<button id="cancelDeleteAllBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">
<span data-i18n="buttons.cancel">Cancel</span>
</button>
<button id="confirmDeleteAllBtn" class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)]">
<i class="fas fa-trash mr-2"></i> Delete All
</button>
</div>
</div>
</div>
<!-- Delete Selected Speakers Confirmation Modal -->
<div id="deleteSelectedSpeakersModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm hidden">
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md transform transition-all duration-300 ease-in-out">
<!-- Header -->
<div class="p-6 border-b border-[var(--border-primary)]">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-full flex items-center justify-center shadow-lg">
<i class="fas fa-trash text-white text-lg"></i>
</div>
<div>
<h3 class="text-xl font-bold text-[var(--text-primary)]">Delete Speakers</h3>
<p class="text-sm text-[var(--text-muted)] mt-0.5">This action cannot be undone</p>
</div>
</div>
</div>
<!-- Content -->
<div class="p-6">
<div class="mb-4">
<p class="text-[var(--text-secondary)] mb-3">
You are about to delete <strong id="deleteSelectedCount" class="text-[var(--text-primary)]">0</strong> speaker(s):
</p>
<div id="deleteSelectedSpeakersList" class="max-h-48 overflow-y-auto space-y-2 bg-[var(--bg-tertiary)] rounded-lg p-3 border border-[var(--border-primary)]">
<!-- Populated dynamically -->
</div>
</div>
<div class="flex items-start space-x-3 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<div class="w-6 h-6 bg-red-100 dark:bg-red-900/40 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-xs"></i>
</div>
<div>
<p class="text-sm text-red-800 dark:text-red-200">
All voice profiles, recordings associations, and usage data will be permanently deleted.
</p>
</div>
</div>
</div>
<!-- Footer -->
<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)]">
<button id="cancelDeleteSelectedBtn" 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 id="confirmDeleteSelectedBtn" class="px-5 py-2.5 bg-gradient-to-r from-red-500 to-red-600 text-white rounded-lg hover:from-red-600 hover:to-red-700 transition-all duration-200 flex items-center shadow-lg font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Speakers
</button>
</div>
</div>
</div>
<!-- Clear Voice Profiles Confirmation Modal -->
<div id="clearVoiceProfilesModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm hidden">
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md transform transition-all duration-300 ease-in-out">
<!-- Header -->
<div class="bg-gradient-to-r from-orange-500 to-[var(--bg-secondary)] p-5 rounded-t-xl flex items-center">
<div class="flex items-center">
<div class="w-12 h-12 bg-gradient-to-br from-orange-500 to-amber-600 rounded-full flex items-center justify-center mr-4 shadow-lg">
<i class="fas fa-eraser text-white text-lg"></i>
</div>
<div>
<h3 class="text-xl font-bold text-[var(--text-primary)] mb-1">Clear Voice Profiles</h3>
<p class="text-sm text-[var(--text-muted)]">Remove voice recognition data</p>
</div>
</div>
</div>
<!-- Content -->
<div class="p-6 modal-content">
<div class="mb-4">
<p class="text-[var(--text-secondary)] mb-3">
Clear voice profiles for <strong id="clearVoiceProfilesCount" class="text-[var(--text-primary)]">0</strong> speaker(s):
</p>
<div id="clearVoiceProfilesList" class="max-h-48 overflow-y-auto space-y-2 bg-[var(--bg-tertiary)] rounded-lg p-3 border border-[var(--border-primary)]">
<!-- Populated dynamically -->
</div>
</div>
<div class="space-y-3">
<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-info-circle text-amber-600 text-xs"></i>
</div>
<div>
<p class="text-[var(--text-secondary)] font-medium mb-1">What will happen:</p>
<p class="text-sm text-[var(--text-muted)]">
All voice embeddings and recognition data will be removed. Speaker names and usage history will be preserved.
</p>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<i class="fas fa-check text-green-600 text-xs"></i>
</div>
<div>
<p class="text-sm text-[var(--text-muted)]">
New voice profiles will be created when you identify these speakers in future recordings.
</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<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)]">
<button id="cancelClearVoiceProfilesBtn" 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 id="confirmClearVoiceProfilesBtn" class="px-5 py-2.5 bg-gradient-to-r from-orange-500 to-amber-600 text-white rounded-lg hover:from-orange-600 hover:to-amber-700 transition-all duration-200 flex items-center shadow-lg font-medium">
<i class="fas fa-eraser mr-2"></i>
Clear Profiles
</button>
</div>
</div>
</div>
<!-- Merge Speakers Modal -->
<div id="mergeSpeakersModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-60 p-4 backdrop-blur-sm hidden">
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col transform transition-all duration-300 ease-in-out">
<!-- Header -->
<div class="bg-gradient-to-r from-[var(--bg-accent)] to-[var(--bg-secondary)] p-5 rounded-t-xl flex items-center justify-between flex-shrink-0">
<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-object-group text-white text-lg"></i>
</div>
<div>
<h3 class="text-xl font-bold text-[var(--text-primary)] mb-1">Merge Speakers</h3>
<p class="text-sm text-[var(--text-muted)]">Combine speaker profiles</p>
</div>
</div>
<button id="closeMergeModal" class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--bg-tertiary)] transition-colors">&times;</button>
</div>
<!-- Info Section -->
<div class="p-6 flex-shrink-0 border-b border-[var(--border-primary)]">
<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-info-circle text-blue-600 text-xs"></i>
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">
Click on the speaker you want to keep. All voice data, snippets, and usage statistics from other speakers will be merged into it.
</p>
</div>
</div>
</div>
<!-- Scrollable speaker list -->
<div class="flex-1 overflow-y-auto min-h-0 p-6">
<div id="mergeSpeakersList" class="space-y-2">
<!-- Populated dynamically -->
</div>
</div>
<!-- Footer -->
<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 id="cancelMergeBtn" 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 id="executeMergeBtn" 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 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center shadow-lg font-medium" disabled>
<i class="fas fa-object-group mr-2"></i>
<span>Merge Speakers</span>
</button>
</div>
</div>
</div>
<!-- Edit Speaker Modal -->
<div id="editSpeakerModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-60 p-4 backdrop-blur-sm hidden">
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md transform transition-all duration-300 ease-in-out">
<!-- Header -->
<div class="p-5 border-b border-[var(--border-primary)]">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-10 h-10 bg-[var(--bg-accent)] rounded-full flex items-center justify-center mr-3">
<i class="fas fa-edit text-[var(--text-button)]"></i>
</div>
<h3 class="text-lg font-bold text-[var(--text-primary)]">Edit Speaker Name</h3>
</div>
<button id="closeEditSpeakerModal" class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--bg-tertiary)] transition-colors">&times;</button>
</div>
</div>
<!-- Body -->
<div class="p-6">
<label for="editSpeakerNameInput" class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Speaker Name
</label>
<input
type="text"
id="editSpeakerNameInput"
class="w-full px-4 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
placeholder="Enter speaker name">
<div id="editSpeakerError" class="mt-2 text-sm text-[var(--text-danger)] hidden"></div>
</div>
<!-- Footer -->
<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)]">
<button id="cancelEditSpeakerBtn" 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 id="saveEditSpeakerBtn" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
<i class="fas fa-save mr-2"></i>
Save
</button>
</div>
</div>
</div>
<!-- Create/Edit Tag Modal -->
<div id="tagModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 id="tagModalTitle" class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="editTagModal.createTitle">Create Tag</h3>
<button id="closeTagModalBtn" class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">&times;</button>
</div>
<form id="tagForm" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" id="tagId" name="tag_id">
<!-- Name and Color (always visible, outside tabs) -->
<div>
<label for="tagName" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="editTagModal.tagName">Tag Name *</label>
<input type="text" id="tagName" name="name" required maxlength="50" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="editTagModal.tagNamePlaceholder" placeholder="e.g., Meetings, Interviews">
</div>
<div>
<label for="tagColor" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="editTagModal.color">Color</label>
<div class="flex items-center space-x-3">
<input type="color" id="tagColor" name="color" value="#3B82F6" class="w-12 h-10 rounded border border-[var(--border-secondary)]">
<span class="text-sm text-[var(--text-muted)]" data-i18n="editTagModal.colorDescription">Choose a color for easy identification</span>
</div>
</div>
<!-- Tab Navigation -->
<div class="border-b border-[var(--border-primary)]">
<nav class="flex -mb-px space-x-1" id="tagModalTabs">
<button type="button" class="tag-modal-tab active px-3 py-2 text-xs font-medium rounded-t-md border-b-2 border-[var(--border-focus)] text-[var(--text-accent)]" data-tab="tagTabTranscription">
<i class="fas fa-microphone mr-1"></i><span data-i18n="folderManagement.tabTranscription">Transcription</span>
</button>
<button type="button" class="tag-modal-tab px-3 py-2 text-xs font-medium rounded-t-md border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]" data-tab="tagTabSummary">
<i class="fas fa-file-alt mr-1"></i><span data-i18n="folderManagement.tabSummaryTemplates">Summary &amp; Templates</span>
</button>
<button type="button" id="tagTabSharingBtn" class="tag-modal-tab px-3 py-2 text-xs font-medium rounded-t-md border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hidden" data-tab="tagTabSharing">
<i class="fas fa-users mr-1"></i><span data-i18n="folderManagement.tabSharing">Sharing</span>
</button>
</nav>
</div>
<!-- Tab: Transcription & Retention -->
<div id="tagTabTranscription" class="tag-modal-tab-content space-y-4">
{% if connector_supports_diarization %}
<div>
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3" data-i18n="editTagModal.asrDefaultSettings">Transcription Default Settings</h4>
<div class="space-y-3">
<div>
<label for="tagLanguage" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="editTagModal.defaultLanguage">Default Language</label>
<input type="text" id="tagLanguage" name="default_language" maxlength="10" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="folderManagement.defaultLanguagePlaceholder" placeholder="e.g., en, es, zh">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="tagMinSpeakers" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="editTagModal.minSpeakers">Min Speakers</label>
<input type="number" id="tagMinSpeakers" name="default_min_speakers" min="1" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div>
<label for="tagMaxSpeakers" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="editTagModal.maxSpeakers">Max Speakers</label>
<input type="number" id="tagMaxSpeakers" name="default_max_speakers" min="1" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
</div>
</div>
</div>
{% endif %}
<!-- Hotwords & Initial Prompt -->
<div>
<label for="tagHotwords" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="form.hotwords">Hotwords</label>
<input type="text" id="tagHotwords" name="default_hotwords" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="form.hotwordsPlaceholder" placeholder="e.g., Speakr, CTranslate2, PyAnnote">
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="form.hotwordsHelp">Comma-separated words to improve recognition of domain-specific terms</p>
</div>
<div>
<label for="tagInitialPrompt" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="form.initialPrompt">Initial Prompt</label>
<textarea id="tagInitialPrompt" name="default_initial_prompt" rows="2" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="form.initialPromptPlaceholder" placeholder="e.g., This is a meeting about AI transcription tools."></textarea>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="form.initialPromptHelp">Context to steer the transcription model's style and vocabulary</p>
</div>
<!-- Retention Period Override -->
<div id="tagRetentionSection" class="border-t border-[var(--border-primary)] pt-4 mt-2">
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3">
<i class="fas fa-clock mr-2"></i><span data-i18n="folderManagement.retentionSettings">Retention &amp; Auto-Deletion</span>
</h4>
<div id="tagRetentionWarning" class="hidden p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md mb-3">
<p class="text-sm text-amber-700 dark:text-amber-300">
<i class="fas fa-info-circle mr-1"></i>
<span data-i18n="folderManagement.retentionDisabledWarning">Auto-deletion is currently disabled. These settings will take effect when enabled by an admin.</span>
</p>
</div>
<div class="space-y-3">
<div id="tagRetentionDaysContainer">
<label for="tagRetentionDays" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="folderManagement.retentionPeriod">Retention Period (days)</label>
<input type="number" id="tagRetentionDays" name="retention_days" min="1" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="folderManagement.retentionPlaceholder" placeholder="Leave empty to use global retention">
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.retentionDaysHelp">Leave empty to use global retention period.</p>
</div>
<div class="flex items-start space-x-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
<input type="checkbox" id="tagProtectFromDeletion" class="mt-1 h-4 w-4 text-[var(--bg-accent)] border-gray-300 rounded focus:ring-[var(--border-focus)]">
<div class="flex-1">
<label for="tagProtectFromDeletion" class="block text-sm font-medium text-[var(--text-secondary)] cursor-pointer">
<i class="fas fa-shield-alt mr-1 text-yellow-600 dark:text-yellow-400"></i>
<span data-i18n="folderManagement.protectFromDeletion">Protect from Auto-Deletion (Infinite Retention)</span>
</label>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.protectFromDeletionHelp">Recordings with this tag will never be automatically deleted.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Summary & Templates -->
<div id="tagTabSummary" class="tag-modal-tab-content space-y-4 hidden">
<div>
<label for="tagCustomPrompt" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="editTagModal.customPrompt">Custom Summary Prompt</label>
<textarea id="tagCustomPrompt" name="custom_prompt" rows="4" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" placeholder="Optional: Custom prompt for generating summaries for recordings with this tag" data-i18n-placeholder="editTagModal.customPromptPlaceholder"></textarea>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="editTagModal.leaveBlankPrompt">Leave blank to use your default summary prompt</p>
</div>
<div>
<label for="tagNamingTemplate" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="editTagModal.namingTemplate">Naming Template</label>
<select id="tagNamingTemplate" name="naming_template_id" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="" data-i18n="editTagModal.noNamingTemplate">No template (use user default or AI title)</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="editTagModal.namingTemplateHint">Select a naming template to automatically format titles for recordings with this tag</p>
</div>
{% if enable_auto_export %}
<div>
<label for="tagExportTemplate" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="editTagModal.exportTemplate">Export Template</label>
<select id="tagExportTemplate" name="export_template_id" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="" data-i18n="editTagModal.noExportTemplate">No template (use user default)</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="editTagModal.exportTemplateHint">Select an export template to use when exporting recordings with this tag</p>
</div>
{% endif %}
</div>
<!-- Tab: Sharing -->
<div id="tagTabSharing" class="tag-modal-tab-content space-y-4 hidden">
<div id="tagGroupSelectionSection" class="hidden">
<label for="tagGroupId" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Group Assignment (Optional)</label>
<select id="tagGroupId" name="group_id" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="" data-i18n="account.personalTag">Personal Tag (Not Associated with a Group)</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1">Assign this tag to a group to enable auto-sharing when applied to recordings</p>
</div>
<div id="tagGroupSettingsSection" class="hidden">
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3">
<i class="fas fa-users mr-2"></i><span data-i18n="folderManagement.groupSharingSettings">Group Sharing Settings</span>
</h4>
<div class="space-y-3">
<div class="flex items-start space-x-3">
<input type="checkbox" id="tagAutoShareOnApply" name="auto_share_on_apply" checked class="mt-1 h-4 w-4 text-[var(--bg-accent)] border-gray-300 rounded focus:ring-[var(--border-focus)]">
<div class="flex-1">
<label for="tagAutoShareOnApply" class="block text-sm font-medium text-[var(--text-secondary)] cursor-pointer">
<i class="fas fa-share-alt mr-1"></i>Auto-share with all group members
</label>
<p class="text-xs text-[var(--text-light)] mt-1">All group members will automatically get access to recordings with this tag</p>
</div>
</div>
<div class="flex items-start space-x-3">
<input type="checkbox" id="tagShareWithGroupLead" name="share_with_group_lead" checked class="mt-1 h-4 w-4 text-[var(--bg-accent)] border-gray-300 rounded focus:ring-[var(--border-focus)]">
<div class="flex-1">
<label for="tagShareWithGroupLead" class="block text-sm font-medium text-[var(--text-secondary)] cursor-pointer">
<i class="fas fa-user-shield mr-1"></i>Share with group admins only
</label>
<p class="text-xs text-[var(--text-light)] mt-1">Only group admins will get access (not all members)</p>
</div>
</div>
</div>
<p class="text-xs text-[var(--text-light)] italic mt-3">Note: If both are enabled, all group members will have access.</p>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<button type="button" id="cancelTagBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]" data-i18n="buttons.cancel">Cancel</button>
<button type="submit" id="saveTagBtn" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
<i class="fas fa-save mr-2"></i> <span data-i18n="buttons.createTag">Save Tag</span>
</button>
</div>
</form>
</div>
</div>
<!-- Create/Edit Folder Modal -->
<div id="folderModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 id="folderModalTitle" class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="folderManagement.createFolder">Create Folder</h3>
<button id="closeFolderModalBtn" class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">&times;</button>
</div>
<form id="folderForm" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" id="folderId" name="folder_id">
<!-- Name and Color (always visible, outside tabs) -->
<div>
<label for="folderName" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"><span data-i18n="folderManagement.folderName">Folder Name</span> *</label>
<input type="text" id="folderName" name="name" required maxlength="50" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="folderManagement.folderNamePlaceholder" placeholder="e.g., Office Calls, Interviews">
</div>
<div>
<label for="folderColor" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="folderManagement.folderColor">Color</label>
<div class="flex items-center space-x-3">
<input type="color" id="folderColor" name="color" value="#10B981" class="w-12 h-10 rounded border border-[var(--border-secondary)]">
<span class="text-sm text-[var(--text-muted)]" data-i18n="editTagModal.colorDescription">Choose a color for easy identification</span>
</div>
</div>
<!-- Tab Navigation -->
<div class="border-b border-[var(--border-primary)]">
<nav class="flex -mb-px space-x-1" id="folderModalTabs">
<button type="button" class="folder-modal-tab active px-3 py-2 text-xs font-medium rounded-t-md border-b-2 border-[var(--border-focus)] text-[var(--text-accent)]" data-tab="folderTabTranscription">
<i class="fas fa-microphone mr-1"></i><span data-i18n="folderManagement.tabTranscription">Transcription</span>
</button>
<button type="button" class="folder-modal-tab px-3 py-2 text-xs font-medium rounded-t-md border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]" data-tab="folderTabSummary">
<i class="fas fa-file-alt mr-1"></i><span data-i18n="folderManagement.tabSummaryTemplates">Summary &amp; Templates</span>
</button>
<button type="button" id="folderTabSharingBtn" class="folder-modal-tab px-3 py-2 text-xs font-medium rounded-t-md border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hidden" data-tab="folderTabSharing">
<i class="fas fa-users mr-1"></i><span data-i18n="folderManagement.tabSharing">Sharing</span>
</button>
</nav>
</div>
<!-- Tab: Transcription & Retention -->
<div id="folderTabTranscription" class="folder-modal-tab-content space-y-4">
{% if connector_supports_diarization %}
<div>
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3" data-i18n="editTagModal.asrDefaultSettings">Transcription Default Settings</h4>
<p class="text-xs text-[var(--text-light)] mb-3" data-i18n="folderManagement.tagPriorityNote">Tag settings take priority over folder settings</p>
<div class="space-y-3">
<div>
<label for="folderLanguage" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="folderManagement.defaultLanguage">Default Language</label>
<input type="text" id="folderLanguage" name="default_language" maxlength="10" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="folderManagement.defaultLanguagePlaceholder" placeholder="e.g., en, es, zh">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="folderMinSpeakers" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="folderManagement.minSpeakers">Min Speakers</label>
<input type="number" id="folderMinSpeakers" name="default_min_speakers" min="1" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div>
<label for="folderMaxSpeakers" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="folderManagement.maxSpeakers">Max Speakers</label>
<input type="number" id="folderMaxSpeakers" name="default_max_speakers" min="1" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
</div>
</div>
</div>
{% endif %}
<!-- Hotwords & Initial Prompt -->
<div>
<label for="folderHotwords" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="form.hotwords">Hotwords</label>
<input type="text" id="folderHotwords" name="default_hotwords" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="form.hotwordsPlaceholder" placeholder="e.g., Speakr, CTranslate2, PyAnnote">
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="form.hotwordsHelp">Comma-separated words to improve recognition of domain-specific terms</p>
</div>
<div>
<label for="folderInitialPrompt" class="block text-sm font-medium text-[var(--text-muted)] mb-1" data-i18n="form.initialPrompt">Initial Prompt</label>
<textarea id="folderInitialPrompt" name="default_initial_prompt" rows="2" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="form.initialPromptPlaceholder" placeholder="e.g., This is a meeting about AI transcription tools."></textarea>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="form.initialPromptHelp">Context to steer the transcription model's style and vocabulary</p>
</div>
<!-- Retention Period Override (only shown when auto-deletion is enabled) -->
<div id="folderRetentionSection" class="hidden border-t border-[var(--border-primary)] pt-4 mt-2">
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3">
<i class="fas fa-clock mr-2"></i><span data-i18n="folderManagement.retentionSettings">Retention &amp; Auto-Deletion</span>
</h4>
<div class="space-y-3">
<div id="folderRetentionDaysContainer">
<label for="folderRetentionDays" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="folderManagement.retentionPeriod">Retention Period (days)</label>
<input type="number" id="folderRetentionDays" name="retention_days" min="1" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="folderManagement.retentionPlaceholder" placeholder="Leave empty to use global retention">
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.retentionDaysHelp">Leave empty to use global retention period.</p>
</div>
<div class="flex items-start space-x-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
<input type="checkbox" id="folderProtectFromDeletion" class="mt-1 h-4 w-4 text-[var(--bg-accent)] border-gray-300 rounded focus:ring-[var(--border-focus)]">
<div class="flex-1">
<label for="folderProtectFromDeletion" class="block text-sm font-medium text-[var(--text-secondary)] cursor-pointer">
<i class="fas fa-shield-alt mr-1 text-yellow-600 dark:text-yellow-400"></i>
<span data-i18n="folderManagement.protectFromDeletion">Protect from Auto-Deletion (Infinite Retention)</span>
</label>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.protectFromDeletionHelp">Recordings in this folder will never be automatically deleted.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Summary & Templates -->
<div id="folderTabSummary" class="folder-modal-tab-content space-y-4 hidden">
<div>
<label for="folderCustomPrompt" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="folderManagement.customPrompt">Custom Summary Prompt</label>
<textarea id="folderCustomPrompt" name="custom_prompt" rows="4" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="folderManagement.customPromptPlaceholder" placeholder="Optional: Custom prompt for generating summaries for recordings in this folder"></textarea>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.customPromptHelp">Leave blank to use your default summary prompt. Tag prompts take priority over folder prompts.</p>
</div>
<div>
<label for="folderNamingTemplate" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="folderManagement.namingTemplate">Naming Template</label>
<select id="folderNamingTemplate" name="naming_template_id" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="" data-i18n="editTagModal.noNamingTemplate">No template (use user default or AI title)</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.namingTemplateHint">Select a naming template to automatically format titles for recordings in this folder</p>
</div>
{% if enable_auto_export %}
<div>
<label for="folderExportTemplate" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="editTagModal.exportTemplate">Export Template</label>
<select id="folderExportTemplate" name="export_template_id" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="" data-i18n="editTagModal.noExportTemplate">No template (use user default)</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.exportTemplateHint">Select an export template to use when exporting recordings in this folder</p>
</div>
{% endif %}
</div>
<!-- Tab: Sharing -->
<div id="folderTabSharing" class="folder-modal-tab-content space-y-4 hidden">
<div id="folderGroupSelectionSection" class="hidden">
<label for="folderGroupId" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="folderManagement.groupAssignment">Group Assignment (Optional)</label>
<select id="folderGroupId" name="group_id" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="" data-i18n="account.personalFolder">Personal Folder (Not Associated with a Group)</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.groupAssignmentHelp">Assign this folder to a group to enable auto-sharing when recordings are moved to it</p>
</div>
<div id="folderGroupSettingsSection" class="hidden">
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3">
<i class="fas fa-users mr-2"></i><span data-i18n="folderManagement.groupSharingSettings">Group Sharing Settings</span>
</h4>
<div class="space-y-3">
<div class="flex items-start space-x-3">
<input type="checkbox" id="folderAutoShareOnApply" name="auto_share_on_apply" checked class="mt-1 h-4 w-4 text-[var(--bg-accent)] border-gray-300 rounded focus:ring-[var(--border-focus)]">
<div class="flex-1">
<label for="folderAutoShareOnApply" class="block text-sm font-medium text-[var(--text-secondary)] cursor-pointer">
<i class="fas fa-share-alt mr-1"></i><span data-i18n="folderManagement.autoShareOnApply">Auto-share with all group members</span>
</label>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.autoShareAllHelp">All group members will automatically get access to recordings in this folder</p>
</div>
</div>
<div class="flex items-start space-x-3">
<input type="checkbox" id="folderShareWithGroupLead" name="share_with_group_lead" checked class="mt-1 h-4 w-4 text-[var(--bg-accent)] border-gray-300 rounded focus:ring-[var(--border-focus)]">
<div class="flex-1">
<label for="folderShareWithGroupLead" class="block text-sm font-medium text-[var(--text-secondary)] cursor-pointer">
<i class="fas fa-user-shield mr-1"></i><span data-i18n="folderManagement.shareWithGroupLead">Share with group admins only</span>
</label>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="folderManagement.shareAdminsOnlyHelp">Only group admins will get access (not all members)</p>
</div>
</div>
</div>
<p class="text-xs text-[var(--text-light)] italic mt-3" data-i18n="folderManagement.bothEnabledNote">Note: If both are enabled, all group members will have access.</p>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<button type="button" id="cancelFolderBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]" data-i18n="common.cancel">Cancel</button>
<button type="submit" id="saveFolderBtn" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
<i class="fas fa-save mr-2"></i> <span data-i18n="folderManagement.saveFolder">Save Folder</span>
</button>
</div>
</form>
</div>
</div>
<!-- Create API Token Modal -->
<div id="createTokenModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">
<i class="fas fa-key mr-2 text-[var(--text-accent)]"></i><span data-i18n="apiTokens.createTitle">Create API Token</span>
</h3>
<button id="closeCreateTokenModal" class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">&times;</button>
</div>
<form id="createTokenForm" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<label for="tokenNameInput" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="apiTokens.tokenName">Token Name *</label>
<input type="text" id="tokenNameInput" name="name" required maxlength="100" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]" data-i18n-placeholder="apiTokens.tokenNamePlaceholder" placeholder="e.g., n8n automation, CLI access">
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="apiTokens.tokenNameHelp">A descriptive name to help you identify this token</p>
</div>
<div>
<label for="tokenExpirationSelect" class="block text-sm font-medium text-[var(--text-secondary)] mb-1" data-i18n="apiTokens.expiration">Expiration</label>
<select id="tokenExpirationSelect" name="expires_in_days" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="0" data-i18n="apiTokens.noExpiration">Never expires</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">1 year</option>
</select>
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="apiTokens.expirationHelp">When this token should expire</p>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div class="flex">
<i class="fas fa-info-circle text-amber-500 mr-2 mt-0.5"></i>
<p class="text-xs text-amber-700" data-i18n="apiTokens.onceShownWarning">
The token will only be shown once after creation. Make sure to copy and save it securely.
</p>
</div>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" id="cancelCreateTokenBtn" class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]" data-i18n="buttons.cancel">Cancel</button>
<button type="submit" id="submitCreateTokenBtn" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
<i class="fas fa-plus mr-2"></i><span data-i18n="apiTokens.createToken">Create Token</span>
</button>
</div>
</form>
</div>
</div>
<!-- Token Secret Modal (shown after creation) -->
<div id="tokenSecretModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">
<i class="fas fa-check-circle mr-2 text-green-500"></i>Token Created!
</h3>
</div>
<div class="space-y-4">
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
<div class="flex">
<i class="fas fa-exclamation-triangle text-red-500 mr-2 mt-0.5"></i>
<p class="text-xs text-red-700">
<strong>Important!</strong> This is the only time you'll see this token. Copy it now and store it securely.
</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Your API Token</label>
<div class="relative">
<input type="text" id="newTokenValue" readonly class="w-full px-3 py-2 pr-12 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-tertiary)] text-[var(--text-primary)] font-mono text-sm">
<button type="button" id="copyTokenBtn" class="absolute right-2 top-1/2 transform -translate-y-1/2 px-3 py-1.5 hover:bg-[var(--bg-tertiary)] rounded-md transition-all duration-200 flex items-center gap-1.5 border border-transparent hover:border-[var(--border-secondary)]" title="Copy to clipboard">
<i id="copyTokenIcon" class="fas fa-copy text-[var(--text-muted)]"></i>
<span id="copyTokenText" class="text-xs text-[var(--text-muted)] hidden">Copied!</span>
</button>
</div>
</div>
<div id="newTokenInfo" class="space-y-2 text-sm">
<!-- Populated dynamically -->
</div>
<div class="flex justify-end pt-2">
<button type="button" id="closeTokenSecretModal" class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700">
<i class="fas fa-check mr-2"></i>I've Saved My Token
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', async function() {
try {
// Initialize i18n
await initializeI18n();
} catch (error) {
console.error('Error initializing i18n:', error);
}
// Setup auto speaker labelling toggle and dropdown
const autoLabelToggle = document.getElementById('autoSpeakerLabellingToggle');
const autoLabelThreshold = document.getElementById('autoSpeakerLabellingThreshold');
async function updateAutoSpeakerLabelling() {
const enabled = autoLabelToggle ? autoLabelToggle.checked : false;
const threshold = autoLabelThreshold ? autoLabelThreshold.value : 'medium';
// Enable/disable dropdown based on toggle
if (autoLabelThreshold) {
autoLabelThreshold.disabled = !enabled;
}
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
try {
const response = await fetch('/api/user/auto-speaker-labelling', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
enabled: enabled,
threshold: threshold
})
});
if (!response.ok) {
console.error('Failed to update auto speaker labelling settings');
}
} catch (error) {
console.error('Error updating auto speaker labelling:', error);
}
}
if (autoLabelToggle) {
autoLabelToggle.addEventListener('change', updateAutoSpeakerLabelling);
}
if (autoLabelThreshold) {
autoLabelThreshold.addEventListener('change', updateAutoSpeakerLabelling);
}
// Setup auto summarization toggle
const autoSummarizationToggle = document.getElementById('autoSummarizationToggle');
async function updateAutoSummarization() {
if (!autoSummarizationToggle) return;
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
try {
const response = await fetch('/api/user/auto-summarization', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
enabled: autoSummarizationToggle.checked
})
});
if (!response.ok) {
console.error('Failed to update auto summarization setting');
}
} catch (error) {
console.error('Error updating auto summarization:', error);
}
}
if (autoSummarizationToggle) {
autoSummarizationToggle.addEventListener('change', updateAutoSummarization);
}
// Setup merge modal event listeners
const closeMergeModalBtn = document.getElementById('closeMergeModal');
const cancelMergeBtn = document.getElementById('cancelMergeBtn');
const executeMergeBtn = document.getElementById('executeMergeBtn');
const mergeSpeakersModal = document.getElementById('mergeSpeakersModal');
if (closeMergeModalBtn) {
closeMergeModalBtn.addEventListener('click', () => {
mergeSpeakersModal.classList.add('hidden');
});
}
if (cancelMergeBtn) {
cancelMergeBtn.addEventListener('click', () => {
mergeSpeakersModal.classList.add('hidden');
});
}
if (executeMergeBtn) {
executeMergeBtn.addEventListener('click', () => {
executeSpeakerMerge();
});
}
// Setup edit speaker modal event listeners
const closeEditSpeakerModalBtn = document.getElementById('closeEditSpeakerModal');
const cancelEditSpeakerBtn = document.getElementById('cancelEditSpeakerBtn');
const saveEditSpeakerBtn = document.getElementById('saveEditSpeakerBtn');
const editSpeakerModal = document.getElementById('editSpeakerModal');
const editSpeakerNameInput = document.getElementById('editSpeakerNameInput');
const editSpeakerError = document.getElementById('editSpeakerError');
let currentEditingSpeakerId = null;
function closeEditSpeakerModalFn() {
editSpeakerModal.classList.add('hidden');
editSpeakerNameInput.value = '';
editSpeakerError.classList.add('hidden');
currentEditingSpeakerId = null;
}
if (closeEditSpeakerModalBtn) {
closeEditSpeakerModalBtn.addEventListener('click', closeEditSpeakerModalFn);
}
if (cancelEditSpeakerBtn) {
cancelEditSpeakerBtn.addEventListener('click', closeEditSpeakerModalFn);
}
// Close modal when clicking outside
editSpeakerModal.addEventListener('click', (event) => {
if (event.target === editSpeakerModal) {
closeEditSpeakerModalFn();
}
});
// Handle Enter key to save
editSpeakerNameInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
saveEditSpeakerBtn.click();
}
});
if (saveEditSpeakerBtn) {
saveEditSpeakerBtn.addEventListener('click', async () => {
const newName = editSpeakerNameInput.value.trim();
if (!newName) {
editSpeakerError.textContent = 'Speaker name cannot be empty';
editSpeakerError.classList.remove('hidden');
return;
}
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const response = await fetch(`/speakers/${currentEditingSpeakerId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ name: newName })
});
const data = await response.json();
if (!response.ok) {
editSpeakerError.textContent = data.error || 'Failed to update speaker';
editSpeakerError.classList.remove('hidden');
return;
}
showToast('Speaker name updated successfully');
closeEditSpeakerModalFn();
// Reload speakers - reset flag to force reload
speakersTabLoaded = false;
await loadSpeakersTab();
} catch (error) {
console.error('Error updating speaker:', error);
editSpeakerError.textContent = 'Failed to update speaker';
editSpeakerError.classList.remove('hidden');
}
});
}
// Setup delete selected speakers modal event listeners
const cancelDeleteSelectedBtn = document.getElementById('cancelDeleteSelectedBtn');
const confirmDeleteSelectedBtn = document.getElementById('confirmDeleteSelectedBtn');
const deleteSelectedSpeakersModal = document.getElementById('deleteSelectedSpeakersModal');
if (cancelDeleteSelectedBtn) {
cancelDeleteSelectedBtn.addEventListener('click', () => {
deleteSelectedSpeakersModal.classList.add('hidden');
});
}
if (confirmDeleteSelectedBtn) {
confirmDeleteSelectedBtn.addEventListener('click', () => {
executeDeleteSelectedSpeakers();
});
}
// Setup clear voice profiles modal event listeners
const cancelClearVoiceProfilesBtn = document.getElementById('cancelClearVoiceProfilesBtn');
const confirmClearVoiceProfilesBtn = document.getElementById('confirmClearVoiceProfilesBtn');
const clearVoiceProfilesModal = document.getElementById('clearVoiceProfilesModal');
if (cancelClearVoiceProfilesBtn) {
cancelClearVoiceProfilesBtn.addEventListener('click', () => {
clearVoiceProfilesModal.classList.add('hidden');
});
}
if (confirmClearVoiceProfilesBtn) {
confirmClearVoiceProfilesBtn.addEventListener('click', () => {
executeClearVoiceProfiles();
});
}
// Hide loading overlay
if (window.AppLoader) {
window.AppLoader.hide();
}
// Setup scroll indicators for tabs
const tabsContainer = document.getElementById('tabs-container');
const leftIndicator = document.getElementById('left-scroll-indicator');
const rightIndicator = document.getElementById('right-scroll-indicator');
function updateScrollIndicators() {
if (tabsContainer && leftIndicator && rightIndicator) {
const scrollLeft = tabsContainer.scrollLeft;
const scrollWidth = tabsContainer.scrollWidth;
const clientWidth = tabsContainer.clientWidth;
// Show/hide left indicator
if (scrollLeft > 10) {
leftIndicator.classList.remove('opacity-0');
leftIndicator.classList.add('opacity-100');
} else {
leftIndicator.classList.add('opacity-0');
leftIndicator.classList.remove('opacity-100');
}
// Show/hide right indicator
if (scrollLeft < scrollWidth - clientWidth - 10) {
rightIndicator.classList.remove('opacity-0');
rightIndicator.classList.add('opacity-100');
} else {
rightIndicator.classList.add('opacity-0');
rightIndicator.classList.remove('opacity-100');
}
}
}
if (tabsContainer) {
tabsContainer.addEventListener('scroll', updateScrollIndicators);
window.addEventListener('resize', updateScrollIndicators);
// Initial check - run immediately and after a delay
updateScrollIndicators();
setTimeout(updateScrollIndicators, 100);
setTimeout(updateScrollIndicators, 500);
// Add click handlers for scroll indicators
if (leftIndicator) {
leftIndicator.addEventListener('click', () => {
tabsContainer.scrollBy({ left: -200, behavior: 'smooth' });
});
}
if (rightIndicator) {
rightIndicator.addEventListener('click', () => {
tabsContainer.scrollBy({ left: 200, behavior: 'smooth' });
});
}
}
const tabAccount = document.getElementById('tab-account');
const tabPrompts = document.getElementById('tab-prompts');
const tabShares = document.getElementById('tab-shares');
const tabFolders = document.getElementById('tab-folders');
const tabTags = document.getElementById('tab-tags');
const tabTemplates = document.getElementById('tab-templates');
const tabTokens = document.getElementById('tab-tokens');
const contentAccount = document.getElementById('content-account');
const contentPrompts = document.getElementById('content-prompts');
const contentShares = document.getElementById('content-shares');
const contentSpeakers = document.getElementById('content-speakers');
const contentFolders = document.getElementById('content-folders');
const contentTags = document.getElementById('content-tags');
const contentTemplates = document.getElementById('content-templates');
const contentTokens = document.getElementById('content-tokens');
const tabAbout = document.getElementById('tab-about');
const contentAbout = document.getElementById('content-about');
const tabHelp = document.getElementById('tab-help');
const contentHelp = document.getElementById('content-help');
const sharesContainer = document.getElementById('shares-container');
// Simplified tab switching
const tabSpeakers = document.getElementById('tab-speakers');
const tabs = [
{ tab: tabAccount, content: contentAccount, name: 'account' },
{ tab: tabPrompts, content: contentPrompts, name: 'prompts' },
{ tab: tabShares, content: contentShares, name: 'shares' },
{ tab: tabSpeakers, content: contentSpeakers, name: 'speakers' },
{ tab: tabFolders, content: contentFolders, name: 'folders' },
{ tab: tabTags, content: contentTags, name: 'tags' },
{ tab: tabTemplates, content: contentTemplates, name: 'templates' },
{ tab: tabTokens, content: contentTokens, name: 'tokens' },
{ tab: tabAbout, content: contentAbout, name: 'about' },
{ tab: tabHelp, content: contentHelp, name: 'help' }
];
function switchToTab(targetName) {
tabs.forEach(({ tab, content, name }) => {
if (name === targetName) {
// Active tab
tab.classList.add('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
tab.classList.remove('border-transparent', 'text-[var(--text-muted)]', 'hover:text-[var(--text-secondary)]', 'hover:border-[var(--border-secondary)]');
content.classList.remove('hidden');
} else {
// Inactive tab
tab.classList.add('border-transparent', 'text-[var(--text-muted)]', 'hover:text-[var(--text-secondary)]', 'hover:border-[var(--border-secondary)]');
tab.classList.remove('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
content.classList.add('hidden');
}
});
// Load content when switching tabs
if (targetName === 'speakers') {
loadSpeakersTab();
} else if (targetName === 'shares') {
loadShares();
} else if (targetName === 'folders') {
loadFolders();
} else if (targetName === 'tags') {
loadTags();
} else if (targetName === 'templates') {
loadTemplates();
} else if (targetName === 'tokens') {
loadTokensTab();
} else if (targetName === 'help') {
loadHelpTab();
}
}
function showAccountTab() {
switchToTab('account');
}
function showPromptsTab() {
switchToTab('prompts');
}
function showSharesTab() {
switchToTab('shares');
}
function showSpeakersTab() {
switchToTab('speakers');
}
function showTagsTab() {
switchToTab('tags');
}
function showFoldersTab() {
switchToTab('folders');
}
function showTemplatesTab() {
switchToTab('templates');
}
function showTokensTab() {
switchToTab('tokens');
}
function showAboutTab() {
switchToTab('about');
}
function showHelpTab() {
switchToTab('help');
}
tabAccount.addEventListener('click', (e) => {
e.preventDefault();
showAccountTab();
});
tabPrompts.addEventListener('click', (e) => {
e.preventDefault();
showPromptsTab();
});
tabShares.addEventListener('click', (e) => {
e.preventDefault();
showSharesTab();
});
tabSpeakers.addEventListener('click', (e) => {
e.preventDefault();
showSpeakersTab();
});
tabTags.addEventListener('click', (e) => {
e.preventDefault();
showTagsTab();
});
if (tabFolders) {
tabFolders.addEventListener('click', (e) => {
e.preventDefault();
showFoldersTab();
});
}
tabTemplates.addEventListener('click', (e) => {
e.preventDefault();
showTemplatesTab();
});
tabTokens.addEventListener('click', (e) => {
e.preventDefault();
showTokensTab();
});
tabAbout.addEventListener('click', (e) => {
e.preventDefault();
showAboutTab();
});
tabHelp.addEventListener('click', (e) => {
e.preventDefault();
showHelpTab();
});
// === Documentation Viewer ===
let helpNavData = null;
let helpCurrentSection = '';
let helpCurrentPage = '';
let helpTabLoaded = false;
async function loadHelpTab() {
if (helpTabLoaded) return;
helpTabLoaded = true;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/docs/nav', {
headers: { 'X-CSRFToken': csrfToken }
});
if (!response.ok) throw new Error('Failed to load navigation');
const data = await response.json();
helpNavData = data.sections;
renderHelpNav();
// Show admin card if admin section exists
if (helpNavData.some(s => s.slug === 'guide-admin')) {
const adminCard = document.getElementById('help-admin-card');
if (adminCard) adminCard.classList.replace('hidden', 'flex');
}
} catch (err) {
console.error('Error loading help nav:', err);
}
}
function renderHelpNav() {
const nav = document.getElementById('help-nav');
if (!nav || !helpNavData) return;
nav.innerHTML = helpNavData.map(section => `
<div class="mb-1">
<button onclick="toggleHelpSection('${section.slug}')" class="w-full flex items-center justify-between px-3 py-2 text-sm font-medium text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<span class="flex items-center gap-2">
<i class="fas ${section.icon} text-xs text-[var(--text-accent)]"></i>
${section.title}
</span>
<i class="fas fa-chevron-down text-xs text-[var(--text-muted)] transition-transform duration-200" id="help-chevron-${section.slug}"></i>
</button>
<div id="help-section-${section.slug}" class="ml-4 mt-1 space-y-0.5 overflow-hidden transition-all duration-200" style="max-height: 0;">
${section.pages.map(page => `
<button onclick="loadDocPage('${section.slug}', '${page.slug}')"
id="help-link-${section.slug}-${page.slug}"
class="w-full text-left px-3 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded-md transition-colors truncate">
${page.title}
</button>
`).join('')}
</div>
</div>
`).join('');
}
window.toggleHelpSection = toggleHelpSection;
function toggleHelpSection(slug) {
const section = document.getElementById(`help-section-${slug}`);
const chevron = document.getElementById(`help-chevron-${slug}`);
if (!section) return;
if (section.style.maxHeight === '0px' || section.style.maxHeight === '0') {
section.style.maxHeight = section.scrollHeight + 'px';
if (chevron) chevron.style.transform = 'rotate(180deg)';
} else {
section.style.maxHeight = '0';
if (chevron) chevron.style.transform = '';
}
}
window.loadDocPage = loadDocPage;
async function loadDocPage(section, page) {
// Handle root index (no section)
if (!section) section = '';
const welcome = document.getElementById('help-welcome');
const loading = document.getElementById('help-loading');
const docContent = document.getElementById('help-doc-content');
welcome.classList.add('hidden');
docContent.classList.add('hidden');
loading.classList.remove('hidden');
// Close mobile sidebar
const sidebar = document.getElementById('help-sidebar');
if (sidebar) sidebar.classList.remove('open');
try {
// Build URL - handle root index vs section pages
let url;
if (!section) {
// Root index - just show welcome
showHelpWelcome();
return;
}
url = `/api/docs/page/${section}/${page}`;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(url, {
headers: { 'X-CSRFToken': csrfToken }
});
if (!response.ok) throw new Error('Page not found');
const data = await response.json();
// Update content
document.getElementById('help-rendered-content').innerHTML = data.html;
// Update breadcrumb
const sectionInfo = helpNavData?.find(s => s.slug === section);
document.getElementById('help-breadcrumb-section').textContent = sectionInfo?.title || section;
const pageInfo = sectionInfo?.pages?.find(p => p.slug === page);
document.getElementById('help-breadcrumb-page').textContent = pageInfo?.title || page;
// Update current tracking
helpCurrentSection = section;
helpCurrentPage = page;
// Highlight active nav item
document.querySelectorAll('[id^="help-link-"]').forEach(el => {
el.classList.remove('bg-[var(--bg-accent-light)]', 'text-[var(--text-accent)]', 'font-medium');
el.classList.add('text-[var(--text-muted)]');
});
const activeLink = document.getElementById(`help-link-${section}-${page}`);
if (activeLink) {
activeLink.classList.add('bg-[var(--bg-accent-light)]', 'text-[var(--text-accent)]', 'font-medium');
activeLink.classList.remove('text-[var(--text-muted)]');
}
// Expand section in nav
const sectionEl = document.getElementById(`help-section-${section}`);
const chevron = document.getElementById(`help-chevron-${section}`);
if (sectionEl) {
sectionEl.style.maxHeight = sectionEl.scrollHeight + 'px';
if (chevron) chevron.style.transform = 'rotate(180deg)';
}
// Update prev/next navigation
updateDocNavFooter(section, page);
// Show content
loading.classList.add('hidden');
docContent.classList.remove('hidden');
// Scroll to top
document.getElementById('help-content-area').scrollTop = 0;
// Intercept internal links
document.getElementById('help-rendered-content').querySelectorAll('a').forEach(link => {
const href = link.getAttribute('href');
if (href && !href.startsWith('http') && !href.startsWith('mailto:') && !href.startsWith('#')) {
link.addEventListener('click', (e) => {
e.preventDefault();
// Parse relative links like ../guide-admin/retention.md
const parts = href.replace('.md', '').split('/');
const targetPage = parts[parts.length - 1];
let targetSection = section;
if (parts.length > 1) {
targetSection = parts[parts.length - 2];
}
loadDocPage(targetSection, targetPage);
});
}
});
} catch (err) {
console.error('Error loading doc page:', err);
loading.classList.add('hidden');
docContent.classList.remove('hidden');
document.getElementById('help-rendered-content').innerHTML = `
<div class="text-center py-12">
<i class="fas fa-exclamation-triangle text-3xl text-[var(--text-warning)] mb-4"></i>
<p class="text-[var(--text-secondary)]">Impossible de charger cette page.</p>
<button onclick="showHelpWelcome()" class="mt-4 text-sm text-[var(--text-accent)] hover:underline">Retour à l'accueil</button>
</div>
`;
}
}
function showHelpWelcome() {
document.getElementById('help-welcome').classList.remove('hidden');
document.getElementById('help-doc-content').classList.add('hidden');
document.getElementById('help-loading').classList.add('hidden');
helpCurrentSection = '';
helpCurrentPage = '';
// Remove active nav highlights
document.querySelectorAll('[id^="help-link-"]').forEach(el => {
el.classList.remove('bg-[var(--bg-accent-light)]', 'text-[var(--text-accent)]', 'font-medium');
el.classList.add('text-[var(--text-muted)]');
});
}
function updateDocNavFooter(currentSection, currentPage) {
if (!helpNavData) return;
// Build flat list of all pages
const allPages = [];
helpNavData.forEach(section => {
section.pages.forEach(page => {
allPages.push({ section: section.slug, sectionTitle: section.title, ...page });
});
});
const currentIdx = allPages.findIndex(p => p.section === currentSection && p.slug === currentPage);
const prevBtn = document.getElementById('help-prev-page');
const nextBtn = document.getElementById('help-next-page');
const prevTitle = document.getElementById('help-prev-title');
const nextTitle = document.getElementById('help-next-title');
if (currentIdx > 0) {
const prev = allPages[currentIdx - 1];
prevBtn.classList.remove('hidden');
prevBtn.onclick = () => loadDocPage(prev.section, prev.slug);
prevTitle.textContent = prev.title;
} else {
prevBtn.classList.add('hidden');
}
if (currentIdx < allPages.length - 1 && currentIdx >= 0) {
const next = allPages[currentIdx + 1];
nextBtn.classList.remove('hidden');
nextBtn.onclick = () => loadDocPage(next.section, next.slug);
nextTitle.textContent = next.title;
} else {
nextBtn.classList.add('hidden');
}
}
function toggleHelpSidebar() {
const sidebar = document.getElementById('help-sidebar');
if (sidebar) sidebar.classList.toggle('open');
}
// Search functionality
let helpSearchTimeout = null;
const helpSearchInput = document.getElementById('help-search');
if (helpSearchInput) {
helpSearchInput.addEventListener('input', (e) => {
clearTimeout(helpSearchTimeout);
const query = e.target.value.trim();
if (query.length < 2) {
document.getElementById('help-search-results').classList.add('hidden');
return;
}
helpSearchTimeout = setTimeout(async () => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/docs/search?q=${encodeURIComponent(query)}`, {
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
const resultsDiv = document.getElementById('help-search-results');
if (data.results.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-sm text-[var(--text-muted)]">Aucun résultat trouvé.</div>';
} else {
resultsDiv.innerHTML = data.results.map(r => `
<button onclick="loadDocPage('${r.section}', '${r.page}'); document.getElementById('help-search').value=''; document.getElementById('help-search-results').classList.add('hidden');"
class="w-full text-left px-3 py-2 hover:bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)] last:border-0 transition-colors">
<div class="text-sm font-medium text-[var(--text-primary)]">${r.page_title}</div>
<div class="text-xs text-[var(--text-muted)]">${r.section_title}</div>
${r.snippet ? `<div class="text-xs text-[var(--text-secondary)] mt-1 truncate">${r.snippet}</div>` : ''}
</button>
`).join('');
}
resultsDiv.classList.remove('hidden');
} catch (err) {
console.error('Search error:', err);
}
}, 300);
});
// Close search results when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#help-search') && !e.target.closest('#help-search-results')) {
document.getElementById('help-search-results')?.classList.add('hidden');
}
});
}
// Load speakers for the Speakers Management tab
let speakersTabData = [];
let speakersTabLoaded = false;
async function loadSpeakersTab() {
// Don't reload if already loaded
if (speakersTabLoaded) return;
const speakersTabLoading = document.getElementById('speakersTabLoading');
const speakersTabError = document.getElementById('speakersTabError');
const speakersTabErrorMessage = document.getElementById('speakersTabErrorMessage');
const speakersTabEmpty = document.getElementById('speakersTabEmpty');
const speakersTabGrid = document.getElementById('speakersTabGrid');
const speakersTabCount = document.getElementById('speakersTabCount');
// Show loading state
speakersTabLoading.classList.remove('hidden');
speakersTabError.classList.add('hidden');
speakersTabEmpty.classList.add('hidden');
speakersTabGrid.classList.add('hidden');
try {
const response = await fetch('/speakers');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
speakersTabData = await response.json();
speakersTabLoaded = true;
// Hide loading state
speakersTabLoading.classList.add('hidden');
if (speakersTabData.length === 0) {
speakersTabEmpty.classList.remove('hidden');
speakersTabGrid.classList.add('hidden');
} else {
speakersTabEmpty.classList.add('hidden');
speakersTabGrid.classList.remove('hidden');
renderSpeakersTabGrid();
}
// Update counts in all locations
document.querySelectorAll('.speakers-count').forEach(el => {
el.textContent = speakersTabData.length;
});
} catch (error) {
console.error('Error loading speakers:', error);
speakersTabLoading.classList.add('hidden');
speakersTabError.classList.remove('hidden');
speakersTabEmpty.classList.add('hidden');
speakersTabGrid.classList.add('hidden');
speakersTabErrorMessage.textContent = error.message;
document.querySelectorAll('.speakers-count').forEach(el => {
el.textContent = '0';
});
}
}
function renderSpeakersTabGrid() {
const speakersTabGrid = document.getElementById('speakersTabGrid');
speakersTabGrid.innerHTML = '';
// Re-apply active filter after render
const searchInput = document.getElementById('speakerSearchInput');
const activeFilter = searchInput ? searchInput.value : '';
speakersTabData.forEach(speaker => {
const createdDate = new Date(speaker.created_at).toLocaleDateString();
const lastUsedDate = new Date(speaker.last_used).toLocaleDateString();
// Voice profile status
const hasVoiceProfile = speaker.embedding_count && speaker.embedding_count > 0;
const confidenceLevel = speaker.confidence_score
? (speaker.confidence_score >= 0.8 ? 'high' : speaker.confidence_score >= 0.6 ? 'medium' : 'low')
: null;
const confidenceBadge = hasVoiceProfile && confidenceLevel
? `<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium" style="background-color: ${confidenceLevel === 'high' ? '#10b981' : confidenceLevel === 'medium' ? '#f59e0b' : '#f97316'}; color: white;">
<i class="fas fa-shield-alt mr-1"></i>${confidenceLevel}
</span>`
: '';
const speakerCard = document.createElement('div');
speakerCard.className = 'bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:border-[var(--border-accent)] transition-colors duration-200';
speakerCard.dataset.speakerId = speaker.id;
speakerCard.dataset.speakerName = speaker.name.toLowerCase();
speakerCard.innerHTML = `
<div class="p-3">
<!-- Header with checkbox, name, and action buttons -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center flex-1 min-w-0 gap-2">
<input type="checkbox" class="speaker-select-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 flex-shrink-0"
data-speaker-id="${speaker.id}" data-speaker-name="${escapeHtml(speaker.name)}">
<i class="fas fa-user text-[var(--text-accent)] text-sm flex-shrink-0"></i>
<h3 class="font-medium text-sm text-[var(--text-primary)] truncate">${escapeHtml(speaker.name)}</h3>
</div>
<div class="flex gap-1 flex-shrink-0">
<button class="edit-speaker-tab-btn p-1 text-[var(--text-accent)] hover:bg-[var(--bg-accent-light)] rounded transition-colors"
data-speaker-id="${speaker.id}"
data-speaker-name="${escapeHtml(speaker.name)}"
title="Edit speaker name">
<i class="fas fa-edit text-xs"></i>
</button>
<button class="delete-speaker-tab-btn p-1 text-[var(--text-danger)] hover:bg-[var(--bg-danger-light)] rounded transition-colors"
data-speaker-id="${speaker.id}"
data-speaker-name="${escapeHtml(speaker.name)}"
title="Delete speaker">
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
<!-- Voice Profile Badge (if exists) -->
${hasVoiceProfile ? `
<div class="flex items-center justify-between gap-2 mb-2 px-2 py-1 bg-[var(--bg-secondary)] rounded">
<div class="flex items-center gap-1.5 min-w-0">
<i class="fas fa-eraser text-blue-500 text-[10px] flex-shrink-0"></i>
<span class="text-[10px] text-[var(--text-muted)] truncate">${speaker.embedding_count} sample${speaker.embedding_count !== 1 ? 's' : ''}</span>
</div>
${confidenceBadge}
</div>
` : ''}
<!-- Stats in compact grid -->
<div class="grid grid-cols-2 gap-1 text-[10px] text-[var(--text-muted)] mb-2">
<div class="flex items-center gap-1">
<i class="fas fa-chart-line opacity-60"></i>
<span>${speaker.use_count}x</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-clock opacity-60"></i>
<span class="truncate">${lastUsedDate}</span>
</div>
</div>
<!-- View Voice Samples Button -->
<button class="view-snippets-btn w-full px-2 py-1 text-[10px] bg-[var(--bg-secondary)] hover:bg-[var(--bg-primary)] text-[var(--text-secondary)] rounded border border-[var(--border-secondary)] transition-colors"
data-speaker-id="${speaker.id}">
<i class="fas fa-volume-up mr-1"></i>
<span>Voice Samples</span>
<i class="fas fa-chevron-down ml-1 transition-transform duration-200"></i>
</button>
<!-- Snippets Container -->
<div class="snippets-container mt-2 hidden">
<div class="snippets-loading text-center py-3">
<i class="fas fa-spinner fa-spin text-[var(--text-muted)] text-xs"></i>
</div>
<div class="snippets-content"></div>
</div>
</div>
`;
// Add edit functionality
const editBtn = speakerCard.querySelector('.edit-speaker-tab-btn');
editBtn.addEventListener('click', () => openEditSpeakerModal(speaker.id, speaker.name));
// Add delete functionality
const deleteBtn = speakerCard.querySelector('.delete-speaker-tab-btn');
deleteBtn.addEventListener('click', () => deleteSpeakerFromTab(speaker.id, speaker.name));
// Add view snippets functionality
const viewSnippetsBtn = speakerCard.querySelector('.view-snippets-btn');
const snippetsContainer = speakerCard.querySelector('.snippets-container');
viewSnippetsBtn.addEventListener('click', () => toggleSpeakerSnippets(speaker.id, viewSnippetsBtn, snippetsContainer));
speakersTabGrid.appendChild(speakerCard);
});
// Update bulk operation buttons state
updateBulkOperationButtons();
// Re-apply search filter if active
if (activeFilter) {
filterSpeakers(activeFilter);
}
}
function openEditSpeakerModal(speakerId, currentName) {
currentEditingSpeakerId = speakerId;
editSpeakerNameInput.value = currentName;
editSpeakerError.classList.add('hidden');
editSpeakerModal.classList.remove('hidden');
// Focus input and select text for easy editing
setTimeout(() => {
editSpeakerNameInput.focus();
editSpeakerNameInput.select();
}, 100);
}
async function deleteSpeakerFromTab(speakerId, speakerName) {
if (!confirm(`Are you sure you want to delete "${speakerName}"?`)) {
return;
}
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/speakers/${speakerId}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
// Remove from local array and re-render
speakersTabData = speakersTabData.filter(s => s.id !== speakerId);
if (speakersTabData.length === 0) {
document.getElementById('speakersTabEmpty').classList.remove('hidden');
document.getElementById('speakersTabGrid').classList.add('hidden');
} else {
renderSpeakersTabGrid();
}
document.querySelectorAll('.speakers-count').forEach(el => {
el.textContent = speakersTabData.length;
});
showToast(`Speaker "${speakerName}" deleted successfully`);
} catch (error) {
console.error('Error deleting speaker:', error);
showToast(`Failed to delete speaker: ${error.message}`, 'error');
}
}
// Toggle speaker snippets
async function toggleSpeakerSnippets(speakerId, button, container) {
const isExpanded = !container.classList.contains('hidden');
const chevron = button.querySelector('.fa-chevron-down');
if (isExpanded) {
container.classList.add('hidden');
chevron.classList.remove('rotate-180');
// Pause all audio players in this container
container.querySelectorAll('audio').forEach(audio => audio.pause());
return;
}
// Expand and load snippets
container.classList.remove('hidden');
chevron.classList.add('rotate-180');
const loadingEl = container.querySelector('.snippets-loading');
const contentEl = container.querySelector('.snippets-content');
loadingEl.classList.remove('hidden');
contentEl.classList.add('hidden');
try {
const response = await fetch(`/speakers/${speakerId}/snippets?limit=3`);
if (!response.ok) throw new Error('Failed to load snippets');
const data = await response.json();
const snippets = data.snippets || [];
loadingEl.classList.add('hidden');
contentEl.classList.remove('hidden');
if (snippets.length === 0) {
contentEl.innerHTML = `
<div class="text-xs text-[var(--text-muted)] text-center py-3 px-2">
<i class="fas fa-info-circle mb-2 text-blue-500"></i>
<p class="mb-1">No voice samples found</p>
<p class="text-[10px] leading-relaxed">Voice samples are created when you identify speakers in recordings</p>
</div>
`;
} else {
contentEl.innerHTML = `
<div class="space-y-2 mt-2">
${snippets.map((snippet, idx) => `
<div class="bg-[var(--bg-secondary)] p-2 rounded border border-[var(--border-secondary)]">
<!-- Compact audio player -->
<div class="flex items-center gap-2">
<div
class="flex items-center justify-center w-5 h-5 rounded-full bg-[var(--bg-accent)] text-[var(--text-accent)] text-[10px] font-medium flex-shrink-0 cursor-help"
title="${escapeHtml(snippet.recording_title)}">
${idx + 1}
</div>
<div class="snippet-audio-container">
<button class="snippet-audio-btn snippet-play-btn" data-audio-id="snippet-${speakerId}-${idx}">
<i class="fas fa-play"></i>
</button>
<div class="snippet-audio-progress" data-audio-id="snippet-${speakerId}-${idx}">
<div class="snippet-audio-progress-bar"></div>
</div>
<div class="snippet-audio-time">
<span class="snippet-current-time">0:00</span>
</div>
<audio
class="snippet-audio"
id="snippet-${speakerId}-${idx}"
preload="none"
src="/speakers/snippet-audio/${snippet.recording_id}?start=${snippet.start_time}&duration=${snippet.duration}">
</audio>
</div>
</div>
</div>
`).join('')}
</div>
`;
// Initialize custom audio players
initSnippetAudioPlayers(contentEl, speakerId);
}
} catch (error) {
console.error('Error loading snippets:', error);
loadingEl.classList.add('hidden');
contentEl.classList.remove('hidden');
contentEl.innerHTML = '<p class="text-xs text-red-500 text-center py-2">Failed to load snippets</p>';
}
}
// Initialize custom audio players for snippets
function initSnippetAudioPlayers(container, speakerId) {
const savedVolume = window.vueApp?.playerVolume ?? 1.0;
container.querySelectorAll('.snippet-audio').forEach(audio => {
const audioId = audio.id;
const playBtn = container.querySelector(`.snippet-play-btn[data-audio-id="${audioId}"]`);
const progressBar = container.querySelector(`.snippet-audio-progress[data-audio-id="${audioId}"]`);
const progressFill = progressBar.querySelector('.snippet-audio-progress-bar');
const timeDisplay = playBtn.closest('.snippet-audio-container').querySelector('.snippet-current-time');
const icon = playBtn.querySelector('i');
// Set volume
audio.volume = savedVolume;
// Play/pause button
playBtn.addEventListener('click', () => {
if (audio.paused) {
// Pause all other snippet audios
container.querySelectorAll('.snippet-audio').forEach(otherAudio => {
if (otherAudio !== audio && !otherAudio.paused) {
otherAudio.pause();
}
});
audio.play();
} else {
audio.pause();
}
});
// Update play/pause icon
audio.addEventListener('play', () => {
icon.classList.remove('fa-play');
icon.classList.add('fa-pause');
});
audio.addEventListener('pause', () => {
icon.classList.remove('fa-pause');
icon.classList.add('fa-play');
});
// Update progress and time
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
const percent = (audio.currentTime / audio.duration) * 100;
progressFill.style.width = percent + '%';
timeDisplay.textContent = formatTime(audio.currentTime);
}
});
// Reset on end
audio.addEventListener('ended', () => {
progressFill.style.width = '0%';
timeDisplay.textContent = '0:00';
icon.classList.remove('fa-pause');
icon.classList.add('fa-play');
});
// Seek by clicking progress bar
progressBar.addEventListener('click', (e) => {
const rect = progressBar.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
audio.currentTime = percent * audio.duration;
});
});
// Helper to format time
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}
// Update bulk operation buttons based on checkboxes
function updateBulkOperationButtons() {
const checkboxes = document.querySelectorAll('.speaker-select-checkbox:checked');
const count = checkboxes.length;
// Update all merge buttons
document.querySelectorAll('.bulk-merge-btn').forEach(btn => {
btn.disabled = count < 2;
const btnText = btn.querySelector('span');
if (btnText) {
btnText.textContent = count >= 2 ? `Merge (${count})` : 'Merge';
}
});
// Update all delete buttons
document.querySelectorAll('.bulk-delete-btn').forEach(btn => {
btn.disabled = count === 0;
const btnText = btn.querySelector('span');
if (btnText) {
btnText.textContent = count > 0 ? `Delete (${count})` : 'Delete';
}
});
// Update all clear profile buttons
document.querySelectorAll('.bulk-clear-btn').forEach(btn => {
btn.disabled = count === 0;
const btnText = btn.querySelector('span');
if (btnText) {
btnText.textContent = count > 0 ? `Clear (${count})` : 'Clear Profiles';
}
});
// Update speaker counts (respect active filter)
const searchInput = document.getElementById('speakerSearchInput');
const currentFilter = searchInput ? searchInput.value.trim() : '';
if (!currentFilter) {
document.querySelectorAll('.speakers-count').forEach(el => {
el.textContent = speakersTabData.length;
});
}
// Update select-all checkboxes state
const allCheckboxes = document.querySelectorAll('.speaker-select-checkbox');
const checkedCheckboxes = document.querySelectorAll('.speaker-select-checkbox:checked');
const allSelected = allCheckboxes.length === checkedCheckboxes.length && allCheckboxes.length > 0;
document.querySelectorAll('.select-all-checkbox').forEach(cb => {
cb.checked = allSelected;
});
}
// Filter speakers by name
function filterSpeakers(query) {
const cards = document.querySelectorAll('#speakersTabGrid > div[data-speaker-name]');
const lowerQuery = query.toLowerCase().trim();
let visibleCount = 0;
cards.forEach(card => {
const match = !lowerQuery || card.dataset.speakerName.includes(lowerQuery);
card.style.display = match ? '' : 'none';
if (match) visibleCount++;
});
const total = speakersTabData.length;
const label = lowerQuery ? `${visibleCount} of ${total}` : `${total}`;
document.querySelectorAll('.speakers-count').forEach(el => {
el.textContent = label;
});
}
document.getElementById('speakerSearchInput').addEventListener('input', (e) => {
filterSpeakers(e.target.value);
});
// Handle checkbox changes
document.addEventListener('change', (e) => {
// Handle select-all checkboxes
if (e.target.classList.contains('select-all-checkbox')) {
const checkboxes = document.querySelectorAll('#speakersTabGrid > div[data-speaker-name]:not([style*="display: none"]) .speaker-select-checkbox');
checkboxes.forEach(cb => cb.checked = e.target.checked);
// Also uncheck hidden cards when unchecking all
if (!e.target.checked) {
document.querySelectorAll('.speaker-select-checkbox').forEach(cb => cb.checked = false);
}
// Sync all select-all checkboxes
document.querySelectorAll('.select-all-checkbox').forEach(cb => {
cb.checked = e.target.checked;
});
updateBulkOperationButtons();
} else if (e.target.classList.contains('speaker-select-checkbox')) {
updateBulkOperationButtons();
}
});
// Handle bulk operation button clicks
document.addEventListener('click', (e) => {
const btn = e.target.closest('.bulk-delete-btn, .bulk-clear-btn, .bulk-merge-btn');
if (!btn) return;
if (btn.classList.contains('bulk-delete-btn')) {
deleteSelectedSpeakers();
} else if (btn.classList.contains('bulk-clear-btn')) {
clearSelectedVoiceProfiles();
} else if (btn.classList.contains('bulk-merge-btn')) {
openMergeSpeakersModal();
}
});
// Track selected target speaker for merge
let mergeTargetSpeakerId = null;
// Open merge modal
async function openMergeSpeakersModal() {
const checkboxes = document.querySelectorAll('.speaker-select-checkbox:checked');
if (checkboxes.length < 2) {
showToast('Please select at least 2 speakers to merge', 'error');
return;
}
const selectedSpeakers = Array.from(checkboxes).map(cb => ({
id: parseInt(cb.dataset.speakerId),
name: cb.dataset.speakerName
}));
const modal = document.getElementById('mergeSpeakersModal');
const list = document.getElementById('mergeSpeakersList');
const executeBtn = document.getElementById('executeMergeBtn');
// Reset target selection
mergeTargetSpeakerId = null;
executeBtn.disabled = true;
// Populate list with clickable cards
list.innerHTML = selectedSpeakers.map(s => `
<div class="merge-speaker-card flex items-center justify-between px-4 py-3 bg-[var(--bg-tertiary)] hover:bg-[var(--bg-primary)] border-2 border-transparent rounded-lg cursor-pointer transition-all"
data-speaker-id="${s.id}">
<div class="flex items-center">
<i class="fas fa-user text-[var(--text-accent)] mr-3"></i>
<span class="text-[var(--text-primary)] font-medium">${escapeHtml(s.name)}</span>
</div>
<i class="fas fa-check text-green-500 text-lg hidden check-icon"></i>
</div>
`).join('');
// Add click handlers to speaker cards
list.querySelectorAll('.merge-speaker-card').forEach(card => {
card.addEventListener('click', function() {
// Remove selection from all cards
list.querySelectorAll('.merge-speaker-card').forEach(c => {
c.classList.remove('border-green-500', 'bg-[var(--bg-accent)]', 'bg-opacity-10');
c.querySelector('.check-icon').classList.add('hidden');
});
// Select this card
this.classList.add('border-green-500', 'bg-[var(--bg-accent)]', 'bg-opacity-10');
this.querySelector('.check-icon').classList.remove('hidden');
mergeTargetSpeakerId = parseInt(this.dataset.speakerId);
executeBtn.disabled = false;
});
});
modal.classList.remove('hidden');
}
// Execute speaker merge
async function executeSpeakerMerge() {
const targetId = mergeTargetSpeakerId;
if (!targetId) {
showToast('Please select which speaker to keep', 'error');
return;
}
const checkboxes = document.querySelectorAll('.speaker-select-checkbox:checked');
const allIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.speakerId));
const sourceIds = allIds.filter(id => id !== targetId);
if (sourceIds.length === 0) {
showToast('No source speakers to merge', 'error');
return;
}
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const response = await fetch('/speakers/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
target_id: targetId,
source_ids: sourceIds
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Merge failed');
showToast(data.message || 'Speakers merged successfully');
document.getElementById('mergeSpeakersModal').classList.add('hidden');
// Reload speakers - reset flag to force reload
speakersTabLoaded = false;
await loadSpeakersTab();
} catch (error) {
console.error('Error merging speakers:', error);
showToast(`Failed to merge speakers: ${error.message}`, 'error');
}
}
// Open delete selected speakers modal
function deleteSelectedSpeakers() {
const checkboxes = document.querySelectorAll('.speaker-select-checkbox:checked');
if (checkboxes.length === 0) {
showToast('Please select speakers to delete', 'error');
return;
}
const selectedSpeakers = Array.from(checkboxes).map(cb => ({
id: parseInt(cb.dataset.speakerId),
name: cb.dataset.speakerName
}));
const modal = document.getElementById('deleteSelectedSpeakersModal');
const countEl = document.getElementById('deleteSelectedCount');
const listEl = document.getElementById('deleteSelectedSpeakersList');
// Update count
countEl.textContent = selectedSpeakers.length;
// Populate list
listEl.innerHTML = selectedSpeakers.map(s => `
<div class="flex items-center px-3 py-2 bg-[var(--bg-secondary)] rounded border border-[var(--border-secondary)]">
<i class="fas fa-user text-[var(--text-accent)] mr-2"></i>
<span class="text-[var(--text-primary)]">${escapeHtml(s.name)}</span>
</div>
`).join('');
modal.classList.remove('hidden');
}
// Execute deletion of selected speakers
async function executeDeleteSelectedSpeakers() {
const checkboxes = document.querySelectorAll('.speaker-select-checkbox:checked');
const selectedSpeakers = Array.from(checkboxes).map(cb => ({
id: parseInt(cb.dataset.speakerId),
name: cb.dataset.speakerName
}));
// Hide modal
document.getElementById('deleteSelectedSpeakersModal').classList.add('hidden');
try{
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = csrfToken ? { 'X-CSRFToken': csrfToken } : {};
const deletePromises = selectedSpeakers.map(speaker =>
fetch(`/speakers/${speaker.id}`, {
method: 'DELETE',
headers: headers
})
);
const results = await Promise.allSettled(deletePromises);
const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) {
console.error('Some speakers failed to delete:', failures);
showToast(`${failures.length} speaker(s) failed to delete`, 'error');
}
// Reset and reload
speakersTabLoaded = false;
await loadSpeakersTab();
const successCount = results.length - failures.length;
if (successCount > 0) {
showToast(`${successCount} speaker(s) deleted successfully`);
}
} catch (error) {
console.error('Error deleting speakers:', error);
showToast(`Failed to delete speakers: ${error.message}`, 'error');
}
}
// Open clear voice profiles modal
function clearSelectedVoiceProfiles() {
const checkboxes = document.querySelectorAll('.speaker-select-checkbox:checked');
if (checkboxes.length === 0) {
showToast('Please select speakers to clear voice profiles', 'error');
return;
}
const selectedSpeakers = Array.from(checkboxes).map(cb => ({
id: parseInt(cb.dataset.speakerId),
name: cb.dataset.speakerName
}));
const modal = document.getElementById('clearVoiceProfilesModal');
const countEl = document.getElementById('clearVoiceProfilesCount');
const listEl = document.getElementById('clearVoiceProfilesList');
// Update count
countEl.textContent = selectedSpeakers.length;
// Populate list
listEl.innerHTML = selectedSpeakers.map(s => `
<div class="flex items-center px-3 py-2 bg-[var(--bg-secondary)] rounded border border-[var(--border-secondary)]">
<i class="fas fa-user text-[var(--text-accent)] mr-2"></i>
<span class="text-[var(--text-primary)]">${escapeHtml(s.name)}</span>
</div>
`).join('');
modal.classList.remove('hidden');
}
// Execute clearing voice profiles
async function executeClearVoiceProfiles() {
const checkboxes = document.querySelectorAll('.speaker-select-checkbox:checked');
const selectedSpeakers = Array.from(checkboxes).map(cb => ({
id: parseInt(cb.dataset.speakerId),
name: cb.dataset.speakerName
}));
// Hide modal
document.getElementById('clearVoiceProfilesModal').classList.add('hidden');
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const clearPromises = selectedSpeakers.map(speaker =>
fetch(`/speakers/${speaker.id}/clear_embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
})
);
const results = await Promise.allSettled(clearPromises);
const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) {
console.error('Some voice profiles failed to clear:', failures);
showToast(`${failures.length} voice profile(s) failed to clear`, 'error');
}
// Reset and reload
speakersTabLoaded = false;
await loadSpeakersTab();
const successCount = results.length - failures.length;
if (successCount > 0) {
showToast(`${successCount} voice profile(s) cleared successfully`);
}
} catch (error) {
console.error('Error clearing voice profiles:', error);
showToast(`Failed to clear voice profiles: ${error.message}`, 'error');
}
}
async function loadSystemInfo() {
try {
const response = await fetch('/api/system/info');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Update version in multiple places
document.getElementById('system-version').textContent = data.version;
// Update LLM info
document.getElementById('llm-endpoint').textContent = data.llm_endpoint;
document.getElementById('llm-model').textContent = data.llm_model;
// Update Speech Recognition info
// Display model name from connector info, with fallback
const transcriptionModel = data.transcription?.model || data.transcription?.connector || 'whisper-1';
document.getElementById('transcription-model').textContent = transcriptionModel;
// Show the active transcription endpoint (ASR or OpenAI depending on config)
document.getElementById('transcription-endpoint').textContent = data.transcription_endpoint || data.whisper_endpoint;
} catch (error) {
document.getElementById('system-version').textContent = 'Failed to load';
document.getElementById('llm-endpoint').textContent = 'Failed to load';
document.getElementById('llm-model').textContent = 'Failed to load';
document.getElementById('transcription-model').textContent = 'Failed to load';
document.getElementById('transcription-endpoint').textContent = 'Failed to load';
}
}
async function loadShares() {
sharesContainer.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin text-2xl"></i></div>';
try {
const response = await fetch('/api/shares');
const shares = await response.json();
if (!response.ok) throw new Error(shares.error || 'Failed to load');
if (shares.length === 0) {
sharesContainer.innerHTML = `<p class="text-center text-[var(--text-muted)]">${t('sharedTranscriptsPage.noSharedTranscripts')}</p>`;
return;
}
sharesContainer.innerHTML = '';
shares.forEach(share => {
const shareEl = document.createElement('div');
shareEl.className = 'bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]';
shareEl.innerHTML = `
<div class="flex justify-between items-start">
<div>
<p class="font-semibold">${escapeHtml(share.recording_title)}</p>
<p class="text-sm text-[var(--text-muted)]">${t('sharedTranscripts.sharedOn')}: ${share.created_at}</p>
</div>
<button data-share-id="${share.id}" class="delete-share-btn 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" data-share-id="${share.id}" data-share-prop="share_summary" class="share-checkbox h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" ${share.share_summary ? 'checked' : ''}>
<span class="ml-2">${t('sharedTranscripts.shareSummary')}</span>
</label>
<label class="flex items-center text-sm">
<input type="checkbox" data-share-id="${share.id}" data-share-prop="share_notes" class="share-checkbox h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" ${share.share_notes ? 'checked' : ''}>
<span class="ml-2">${t('sharedTranscripts.shareNotes')}</span>
</label>
</div>
<div class="mt-4 relative">
<input value="{{ request.url_root }}share/${share.public_id}" readonly class="share-link-input 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 class="copy-share-link-btn 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="fas fa-copy text-xs"></i>
</button>
</div>
`;
sharesContainer.appendChild(shareEl);
});
document.querySelectorAll('.delete-share-btn').forEach(btn => {
btn.addEventListener('click', handleDeleteShare);
});
document.querySelectorAll('.share-checkbox').forEach(box => {
box.addEventListener('change', handleUpdateShare);
});
document.querySelectorAll('.copy-share-link-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
const button = e.currentTarget;
const input = button.previousElementSibling;
navigator.clipboard.writeText(input.value).then(() => {
const icon = button.querySelector('i');
// Change to success state
icon.className = 'fas fa-check text-xs';
button.style.transition = 'background-color 0.2s ease';
button.style.backgroundColor = 'var(--bg-success, #10b981)';
// Revert after delay
setTimeout(() => {
button.style.backgroundColor = '';
icon.className = 'fas fa-copy text-xs';
setTimeout(() => {
button.style.transition = '';
}, 200);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
});
});
} catch (error) {
sharesContainer.innerHTML = `<p class="text-center text-red-500">${t('errors.loadingShares')}: ${error.message}</p>`;
}
}
async function handleDeleteShare(event) {
const shareId = event.currentTarget.dataset.shareId;
if (!confirm('Are you sure you want to delete this share? This will revoke access to the public link.')) return;
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/api/share/${shareId}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) throw new Error('Failed to delete');
loadShares();
} catch (error) {
alert('Error deleting share.');
}
}
async function handleUpdateShare(event) {
const shareId = event.currentTarget.dataset.shareId;
const prop = event.currentTarget.dataset.shareProp;
const value = event.currentTarget.checked;
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/api/share/${shareId}`, {
method: 'PUT',
headers: headers,
body: JSON.stringify({ [prop]: value })
});
if (!response.ok) throw new Error('Failed to update');
} catch (error) {
alert('Error updating share.');
event.currentTarget.checked = !value; // Revert on failure
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Update footer with version
async function updateFooter() {
try {
const response = await fetch('/api/system/info');
if (response.ok) {
const data = await response.json();
const footerText = document.getElementById('footer-text');
const aboutVersion = document.getElementById('about-version');
if (aboutVersion && data.version) {
aboutVersion.textContent = data.version;
}
if (footerText && data.version) {
footerText.textContent = `DictIA ${data.version} © {{ now.year }} InnovA AI`;
}
}
} catch (error) {
console.log('Could not fetch version for footer');
}
}
// Update footer on page load
updateFooter();
// Dark mode toggle functionality
const darkModeToggle = document.getElementById('darkModeToggle');
const darkModeIcon = document.getElementById('darkModeIcon');
// Initialize dark mode based on localStorage or system preference
function initializeDarkMode() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedMode = localStorage.getItem('darkMode');
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
darkModeIcon.classList.remove('fa-moon');
darkModeIcon.classList.add('fa-sun');
} else {
document.documentElement.classList.remove('dark');
darkModeIcon.classList.remove('fa-sun');
darkModeIcon.classList.add('fa-moon');
}
}
// Toggle dark mode
darkModeToggle.addEventListener('click', () => {
const isDarkMode = document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', isDarkMode);
if (isDarkMode) {
darkModeIcon.classList.remove('fa-moon');
darkModeIcon.classList.add('fa-sun');
} else {
darkModeIcon.classList.remove('fa-sun');
darkModeIcon.classList.add('fa-moon');
}
});
// User dropdown functionality
const userDropdownButton = document.getElementById('userDropdownButton');
const userDropdownMenu = document.getElementById('userDropdownMenu');
userDropdownButton.addEventListener('click', () => {
userDropdownMenu.classList.toggle('hidden');
});
// Close dropdown when clicking outside
document.addEventListener('click', (event) => {
if (!userDropdownButton.contains(event.target) && !userDropdownMenu.contains(event.target)) {
userDropdownMenu.classList.add('hidden');
}
});
// Initialize dark mode on page load
initializeDarkMode();
// Change Password Modal functionality
const changePasswordBtn = document.getElementById('changePasswordBtn');
const changePasswordModal = document.getElementById('changePasswordModal');
const closeModalBtn = document.getElementById('closeModalBtn');
const cancelBtn = document.getElementById('cancelBtn');
const changePasswordForm = document.getElementById('changePasswordForm');
const newPasswordInput = document.getElementById('new_password');
const confirmPasswordInput = document.getElementById('confirm_password');
// Open modal (only if button exists)
if (changePasswordBtn) {
changePasswordBtn.addEventListener('click', () => {
changePasswordModal.classList.remove('hidden');
// Reset form
changePasswordForm.reset();
});
}
// Close modal functions
function closeModal() {
changePasswordModal.classList.add('hidden');
}
closeModalBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
// Close modal when clicking outside
changePasswordModal.addEventListener('click', (event) => {
if (event.target === changePasswordModal) {
closeModal();
}
});
// Form validation
changePasswordForm.addEventListener('submit', (event) => {
if (newPasswordInput.value !== confirmPasswordInput.value) {
event.preventDefault();
alert('New password and confirmation do not match.');
}
});
// Keep the modal functionality for backward compatibility if button still exists
const manageSpeakersBtn = document.getElementById('manageSpeakersBtn');
const manageSpeakersModal = document.getElementById('manageSpeakersModal');
const closeSpeakersModalBtn = document.getElementById('closeSpeakersModalBtn');
const closeSpeakersBtn = document.getElementById('closeSpeakersBtn');
const deleteAllSpeakersBtn = document.getElementById('deleteAllSpeakersBtn');
const deleteAllSpeakersModal = document.getElementById('deleteAllSpeakersModal');
const cancelDeleteAllBtn = document.getElementById('cancelDeleteAllBtn');
const confirmDeleteAllBtn = document.getElementById('confirmDeleteAllBtn');
const speakersLoading = document.getElementById('speakersLoading');
const speakersError = document.getElementById('speakersError');
const speakersErrorMessage = document.getElementById('speakersErrorMessage');
const speakersEmpty = document.getElementById('speakersEmpty');
const speakersList = document.getElementById('speakersList');
const speakersCount = document.getElementById('speakersCount');
let speakers = [];
// Open speakers modal only if button exists
if (manageSpeakersBtn) {
manageSpeakersBtn.addEventListener('click', () => {
manageSpeakersModal.classList.remove('hidden');
loadSpeakers();
});
}
// Close speakers modal functions
function closeSpeakersModal() {
manageSpeakersModal.classList.add('hidden');
}
closeSpeakersModalBtn.addEventListener('click', closeSpeakersModal);
closeSpeakersBtn.addEventListener('click', closeSpeakersModal);
// Close modal when clicking outside
manageSpeakersModal.addEventListener('click', (event) => {
if (event.target === manageSpeakersModal) {
closeSpeakersModal();
}
});
// Load speakers from API
async function loadSpeakers() {
showLoadingState();
try {
const response = await fetch('/speakers');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
speakers = await response.json();
displaySpeakers();
} catch (error) {
console.error('Error loading speakers:', error);
showErrorState(error.message);
}
}
// Show loading state
function showLoadingState() {
speakersLoading.classList.remove('hidden');
speakersError.classList.add('hidden');
speakersEmpty.classList.add('hidden');
speakersList.classList.add('hidden');
}
// Show error state
function showErrorState(message) {
speakersLoading.classList.add('hidden');
speakersError.classList.remove('hidden');
speakersEmpty.classList.add('hidden');
speakersList.classList.add('hidden');
speakersErrorMessage.textContent = message;
updateSpeakersCount(0);
}
// Display speakers
function displaySpeakers() {
speakersLoading.classList.add('hidden');
speakersError.classList.add('hidden');
if (speakers.length === 0) {
speakersEmpty.classList.remove('hidden');
speakersList.classList.add('hidden');
deleteAllSpeakersBtn.disabled = true;
} else {
speakersEmpty.classList.add('hidden');
speakersList.classList.remove('hidden');
deleteAllSpeakersBtn.disabled = false;
renderSpeakersList();
}
updateSpeakersCount(speakers.length);
}
// Render speakers list
function renderSpeakersList() {
speakersList.innerHTML = '';
speakers.forEach(speaker => {
const speakerItem = createSpeakerItem(speaker);
speakersList.appendChild(speakerItem);
});
}
// Create speaker item element
function createSpeakerItem(speaker) {
const div = document.createElement('div');
div.className = 'flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:bg-[var(--bg-accent)] transition-colors duration-200';
const createdDate = new Date(speaker.created_at).toLocaleDateString();
const lastUsedDate = new Date(speaker.last_used).toLocaleDateString();
div.innerHTML = `
<div class="flex-1">
<div class="flex items-center">
<i class="fas fa-user text-[var(--text-muted)] mr-2"></i>
<span class="font-medium text-[var(--text-primary)]">${escapeHtml(speaker.name)}</span>
</div>
<div class="text-xs text-[var(--text-muted)] mt-1">
Used ${speaker.use_count} time${speaker.use_count !== 1 ? 's' : ''}
Last used: ${lastUsedDate}
Created: ${createdDate}
</div>
</div>
<button class="delete-speaker-btn ml-3 p-2 text-[var(--text-danger)] hover:bg-[var(--bg-danger-light)] rounded-md transition-colors duration-200"
data-speaker-id="${speaker.id}"
data-i18n-title="buttons.deleteSpeaker">
<i class="fas fa-trash text-sm"></i>
</button>
`;
// Add delete functionality
const deleteBtn = div.querySelector('.delete-speaker-btn');
deleteBtn.addEventListener('click', () => deleteSpeaker(speaker.id, speaker.name));
return div;
}
// Delete individual speaker
async function deleteSpeaker(speakerId, speakerName) {
if (!confirm(`Are you sure you want to delete "${speakerName}"?`)) {
return;
}
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/speakers/${speakerId}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
// Remove from local array
speakers = speakers.filter(s => s.id !== speakerId);
displaySpeakers();
showToast(`Speaker "${speakerName}" deleted successfully`);
} catch (error) {
console.error('Error deleting speaker:', error);
showToast(`Failed to delete speaker: ${error.message}`, 'error');
}
}
// Delete all speakers
deleteAllSpeakersBtn.addEventListener('click', () => {
deleteAllSpeakersModal.classList.remove('hidden');
});
// Cancel delete all
cancelDeleteAllBtn.addEventListener('click', () => {
deleteAllSpeakersModal.classList.add('hidden');
});
// Close delete all modal when clicking outside
deleteAllSpeakersModal.addEventListener('click', (event) => {
if (event.target === deleteAllSpeakersModal) {
deleteAllSpeakersModal.classList.add('hidden');
}
});
// Confirm delete all speakers
confirmDeleteAllBtn.addEventListener('click', async () => {
deleteAllSpeakersModal.classList.add('hidden');
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
// Delete all speakers one by one
const deletePromises = speakers.map(speaker =>
fetch(`/speakers/${speaker.id}`, {
method: 'DELETE',
headers: headers
})
);
const results = await Promise.allSettled(deletePromises);
// Check for any failures
const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) {
console.error('Some speakers failed to delete:', failures);
showToast(`${failures.length} speaker(s) failed to delete`, 'error');
}
// Reload speakers list
await loadSpeakers();
showToast(`${speakers.length === 0 ? 'All speakers' : (results.length - failures.length) + ' speakers'} deleted successfully`);
} catch (error) {
console.error('Error deleting all speakers:', error);
showToast(`Failed to delete speakers: ${error.message}`, 'error');
}
});
// Update speakers count
function updateSpeakersCount(count) {
speakersCount.textContent = count;
}
// Utility function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Toast notification function
function showToast(message, type = 'success') {
// Create toast element
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg transition-all duration-300 transform translate-x-full ${
type === 'error'
? 'bg-[var(--bg-danger)] text-[var(--text-button)]'
: 'bg-[var(--bg-success-light)] text-[var(--text-success-strong)]'
}`;
toast.style.cursor = 'pointer';
toast.innerHTML = `
<div class="flex items-center">
<i class="fas ${type === 'error' ? 'fa-exclamation-circle' : 'fa-check-circle'} mr-2"></i>
<span>${escapeHtml(message)}</span>
</div>
`;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.classList.remove('translate-x-full');
}, 10);
// Function to dismiss the toast
const dismissToast = () => {
toast.classList.add('translate-x-full');
setTimeout(() => {
if (toast.parentNode) {
document.body.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);
}
// ===== MODAL TAB SWITCHING =====
function switchModalTab(navId, tabClass, contentClass, targetTabId) {
const nav = document.getElementById(navId);
if (!nav) return;
// Update tab buttons
nav.querySelectorAll('.' + tabClass).forEach(btn => {
if (btn.dataset.tab === targetTabId) {
btn.classList.add('active', 'border-[var(--border-focus)]', 'text-[var(--text-accent)]');
btn.classList.remove('border-transparent', 'text-[var(--text-muted)]');
} else {
btn.classList.remove('active', 'border-[var(--border-focus)]', 'text-[var(--text-accent)]');
btn.classList.add('border-transparent', 'text-[var(--text-muted)]');
}
});
// Show/hide tab content
document.querySelectorAll('.' + contentClass).forEach(panel => {
panel.classList.toggle('hidden', panel.id !== targetTabId);
});
}
// Attach tab click handlers for tag modal
document.getElementById('tagModalTabs')?.addEventListener('click', function(e) {
const btn = e.target.closest('.tag-modal-tab');
if (btn && btn.dataset.tab) {
switchModalTab('tagModalTabs', 'tag-modal-tab', 'tag-modal-tab-content', btn.dataset.tab);
}
});
// Attach tab click handlers for folder modal
document.getElementById('folderModalTabs')?.addEventListener('click', function(e) {
const btn = e.target.closest('.folder-modal-tab');
if (btn && btn.dataset.tab) {
switchModalTab('folderModalTabs', 'folder-modal-tab', 'folder-modal-tab-content', btn.dataset.tab);
}
});
// Show sharing tabs when internal sharing is enabled
const isInternalSharingForTabs = {{ 'true' if enable_internal_sharing else 'false' }};
if (isInternalSharingForTabs) {
document.getElementById('tagTabSharingBtn')?.classList.remove('hidden');
document.getElementById('folderTabSharingBtn')?.classList.remove('hidden');
}
// ===== TAG MANAGEMENT FUNCTIONALITY =====
// Tag management elements
const createTagBtn = document.getElementById('createTagBtn');
const tagModal = document.getElementById('tagModal');
const closeTagModalBtn = document.getElementById('closeTagModalBtn');
const cancelTagBtn = document.getElementById('cancelTagBtn');
const tagForm = document.getElementById('tagForm');
const tagModalTitle = document.getElementById('tagModalTitle');
const saveTagBtn = document.getElementById('saveTagBtn');
const tagsLoading = document.getElementById('tagsLoading');
const tagsError = document.getElementById('tagsError');
const tagsErrorMessage = document.getElementById('tagsErrorMessage');
const tagsEmpty = document.getElementById('tagsEmpty');
const tagsGrid = document.getElementById('tagsGrid');
let tags = [];
let isEditingTag = false;
// Groups where current user is admin (passed from backend)
let userAdminGroups = {{ user_admin_groups | tojson }};
console.log('User admin groups:', userAdminGroups);
// Update visibility of group selection based on whether user is admin of any groups
function updateGroupSelectionVisibility() {
const groupSelectionSection = document.getElementById('tagGroupSelectionSection');
if (groupSelectionSection) {
const isInternalSharingEnabled = {{ 'true' if enable_internal_sharing else 'false' }};
if (isInternalSharingEnabled && userAdminGroups.length > 0) {
groupSelectionSection.classList.remove('hidden');
populateGroupDropdown();
} else {
groupSelectionSection.classList.add('hidden');
}
}
}
// Populate the group dropdown with groups where user is admin
function populateGroupDropdown() {
const groupSelect = document.getElementById('tagGroupId');
if (!groupSelect) return;
// Clear existing options except the first one
while (groupSelect.options.length > 1) {
groupSelect.remove(1);
}
// Add group options
userAdminGroups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
groupSelect.appendChild(option);
});
}
// Toggle group settings section based on selection
function toggleGroupSettings() {
const groupSelect = document.getElementById('tagGroupId');
const groupSettingsSection = document.getElementById('tagGroupSettingsSection');
if (groupSelect && groupSettingsSection) {
if (groupSelect.value) {
groupSettingsSection.classList.remove('hidden');
} else {
groupSettingsSection.classList.add('hidden');
}
}
}
// Add event listener for group selection changes
const tagGroupSelect = document.getElementById('tagGroupId');
if (tagGroupSelect) {
tagGroupSelect.addEventListener('change', toggleGroupSettings);
}
// Add event listener for protect from deletion checkbox
const tagProtectCheckbox = document.getElementById('tagProtectFromDeletion');
const tagRetentionDaysInput = document.getElementById('tagRetentionDays');
if (tagProtectCheckbox && tagRetentionDaysInput) {
tagProtectCheckbox.addEventListener('change', function() {
if (this.checked) {
// When protect is enabled, disable retention field and set to -1
tagRetentionDaysInput.value = '';
tagRetentionDaysInput.disabled = true;
tagRetentionDaysInput.classList.add('opacity-50', 'cursor-not-allowed');
tagRetentionDaysInput.placeholder = 'Infinite retention (protected)';
} else {
// When protect is disabled, re-enable retention field
tagRetentionDaysInput.disabled = false;
tagRetentionDaysInput.classList.remove('opacity-50', 'cursor-not-allowed');
tagRetentionDaysInput.placeholder = 'Leave empty to use global retention';
}
});
}
// Check if auto-deletion is enabled and show/hide the retention section
function checkAutoDeletionEnabled() {
const isAutoDeletionEnabled = {{ 'true' if enable_auto_deletion else 'false' }};
const warningEl = document.getElementById('tagRetentionWarning');
// Show warning when auto-deletion is disabled
if (warningEl && !isAutoDeletionEnabled) {
warningEl.classList.remove('hidden');
}
}
// Initialize visibility based on user's admin groups
updateGroupSelectionVisibility();
checkAutoDeletionEnabled();
// Load tags from API
async function loadTags() {
showTagsLoadingState();
try {
const response = await fetch('/api/tags');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
tags = await response.json();
displayTags();
} catch (error) {
console.error('Error loading tags:', error);
showTagsErrorState(error.message);
}
}
// Show loading state
function showTagsLoadingState() {
tagsLoading.classList.remove('hidden');
tagsError.classList.add('hidden');
tagsEmpty.classList.add('hidden');
tagsGrid.classList.add('hidden');
}
// Show error state
function showTagsErrorState(message) {
tagsLoading.classList.add('hidden');
tagsError.classList.remove('hidden');
tagsEmpty.classList.add('hidden');
tagsGrid.classList.add('hidden');
tagsErrorMessage.textContent = message;
}
// Display tags
function displayTags() {
tagsLoading.classList.add('hidden');
tagsError.classList.add('hidden');
if (tags.length === 0) {
tagsEmpty.classList.remove('hidden');
tagsGrid.classList.add('hidden');
} else {
tagsEmpty.classList.add('hidden');
tagsGrid.classList.remove('hidden');
renderTagsGrid();
}
}
// Render tags grid
function renderTagsGrid() {
tagsGrid.innerHTML = '';
tags.forEach(tag => {
const tagCard = createTagCard(tag);
tagsGrid.appendChild(tagCard);
});
}
// Create tag card element
function createTagCard(tag) {
const div = document.createElement('div');
const isGroupTag = tag.group_id !== null && tag.group_id !== undefined;
const canEdit = tag.can_edit !== undefined ? tag.can_edit : true;
div.className = `bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:shadow-md transition-shadow duration-200 overflow-hidden`;
// Build metadata badges
const metadata = [];
// Protection/Retention badge
if (tag.protect_from_deletion || tag.retention_days === -1) {
metadata.push(`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium" style="background-color: #f59e0b; color: white;" title="Protected with infinite retention">
<i class="fas fa-shield-alt"></i>
<span>∞</span>
</span>`);
} else if (tag.retention_days && tag.retention_days > 0) {
metadata.push(`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium" style="background-color: #2563eb; color: white;" title="Retention: ${tag.retention_days} days">
<i class="fas fa-clock"></i>
<span>${tag.retention_days}d</span>
</span>`);
}
// Sharing badge for group tags
if (isGroupTag) {
const shareIcon = tag.auto_share_on_apply ? 'user-friends' : (tag.share_with_group_lead ? 'user-tie' : 'user-lock');
const shareText = tag.auto_share_on_apply ? 'All members' : (tag.share_with_group_lead ? 'Admins only' : 'Manual');
const shareTooltip = tag.auto_share_on_apply ? 'Auto-shares with all group members' : (tag.share_with_group_lead ? 'Shares with group admins only' : 'Manual sharing only');
metadata.push(`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium" style="background-color: #9333ea; color: white;" title="${shareTooltip}">
<i class="fas fa-${shareIcon}"></i>
<span>${shareText}</span>
</span>`);
}
// ASR defaults badges
if (tag.default_language) {
metadata.push(`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium" style="background-color: #16a34a; color: white;" title="Language: ${escapeHtml(tag.default_language)}">
<i class="fas fa-language"></i>
<span>${escapeHtml(tag.default_language).toUpperCase()}</span>
</span>`);
}
if (tag.default_min_speakers || tag.default_max_speakers) {
const min = tag.default_min_speakers || '?';
const max = tag.default_max_speakers || '?';
metadata.push(`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium" style="background-color: #0d9488; color: white;" title="Speaker range: ${min}-${max}">
<i class="fas fa-users"></i>
<span>${min}-${max}</span>
</span>`);
}
// Custom prompt indicator
const hasPrompt = tag.custom_prompt && tag.custom_prompt.trim().length > 0;
div.innerHTML = `
<!-- Header with color indicator and name -->
<div class="flex items-center justify-between gap-2 px-3 py-2 bg-[var(--bg-secondary)] border-b border-[var(--border-primary)]">
<div class="flex items-center gap-2 flex-1 min-w-0">
${isGroupTag
? `<i class="fas fa-users flex-shrink-0 text-sm" style="color: ${escapeHtml(tag.color)}"></i>`
: `<div class="w-3 h-3 rounded-full flex-shrink-0 border border-white/20" style="background-color: ${escapeHtml(tag.color)}"></div>`
}
<h3 class="font-semibold text-sm text-[var(--text-primary)] truncate" title="${escapeHtml(tag.name)}">
${isGroupTag ? `<span class="text-xs opacity-60">${escapeHtml(tag.group_name || 'Group')}:</span> ` : ''}${escapeHtml(tag.name)}
</h3>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
${!canEdit ? `
<span class="px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] bg-[var(--bg-secondary)] rounded border border-[var(--border-secondary)]" title="Team tag - cannot edit">
<i class="fas fa-lock"></i>
</span>
` : ''}
${canEdit ? `
<button class="edit-tag-btn p-1 text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors rounded" data-tag-id="${tag.id}" title="Edit tag">
<i class="fas fa-edit text-xs"></i>
</button>
<button class="delete-tag-btn p-1 text-[var(--text-muted)] hover:text-red-500 transition-colors rounded" data-tag-id="${tag.id}" title="Delete tag">
<i class="fas fa-trash text-xs"></i>
</button>
` : ''}
</div>
</div>
<!-- Metadata badges -->
${metadata.length > 0 ? `
<div class="px-3 py-2 flex flex-wrap gap-1.5">
${metadata.join('')}
</div>
` : ''}
<!-- Custom prompt section (collapsible) -->
${hasPrompt ? `
<div class="border-t border-[var(--border-primary)]">
<button class="toggle-prompt-btn w-full px-3 py-2 flex items-center justify-between text-left hover:bg-[var(--bg-secondary)] transition-colors group" data-tag-id="${tag.id}">
<span class="text-xs font-medium text-[var(--text-secondary)] flex items-center gap-1.5">
<i class="fas fa-message-lines text-[10px]"></i>
Custom Prompt
</span>
<i class="fas fa-chevron-down text-[10px] text-[var(--text-muted)] transition-transform group-hover:text-[var(--text-secondary)]"></i>
</button>
<div class="prompt-content hidden px-3 pb-2">
<div class="text-xs text-[var(--text-muted)] bg-[var(--bg-secondary)] rounded p-2 max-h-24 overflow-y-auto custom-scrollbar border border-[var(--border-secondary)]">
${escapeHtml(tag.custom_prompt)}
</div>
</div>
</div>
` : ''}
`;
// Add event listeners
const editBtn = div.querySelector('.edit-tag-btn');
const deleteBtn = div.querySelector('.delete-tag-btn');
const toggleBtn = div.querySelector('.toggle-prompt-btn');
if (editBtn) {
editBtn.addEventListener('click', () => editTag(tag));
}
if (deleteBtn) {
deleteBtn.addEventListener('click', () => deleteTag(tag.id, tag.name));
}
if (toggleBtn) {
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
const content = this.nextElementSibling;
const icon = this.querySelector('.fa-chevron-down');
content.classList.toggle('hidden');
icon.style.transform = content.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(180deg)';
});
}
return div;
}
// Create tag modal functionality
createTagBtn.addEventListener('click', () => {
openTagModal();
});
// Close tag modal functions
function closeTagModal() {
tagModal.classList.add('hidden');
tagForm.reset();
isEditingTag = false;
document.getElementById('tagId').value = '';
}
closeTagModalBtn.addEventListener('click', closeTagModal);
cancelTagBtn.addEventListener('click', closeTagModal);
// Close modal when clicking outside
tagModal.addEventListener('click', (event) => {
if (event.target === tagModal) {
closeTagModal();
}
});
// Open tag modal for create
function openTagModal(tag = null) {
if (tag) {
// Edit mode
isEditingTag = true;
tagModalTitle.textContent = 'Edit Tag';
saveTagBtn.innerHTML = '<i class="fas fa-save mr-2"></i> Update Tag';
// Populate form
document.getElementById('tagId').value = tag.id;
document.getElementById('tagName').value = tag.name;
document.getElementById('tagColor').value = tag.color;
document.getElementById('tagCustomPrompt').value = tag.custom_prompt || '';
// Populate group selection
const groupIdSelect = document.getElementById('tagGroupId');
const groupSelectionSection = document.getElementById('tagGroupSelectionSection');
const isInternalSharingEnabled = {{ 'true' if enable_internal_sharing else 'false' }};
if (groupIdSelect && isInternalSharingEnabled) {
// If this tag has a group_id, force show the group selection section
if (tag.group_id) {
groupSelectionSection?.classList.remove('hidden');
// Ensure the current group is in the dropdown (in case async load hasn't finished)
if (tag.group_name) {
// Check if option already exists
const existingOption = Array.from(groupIdSelect.options).find(opt => opt.value == tag.group_id);
if (!existingOption) {
// Add the current group as an option
const option = document.createElement('option');
option.value = tag.group_id;
option.textContent = tag.group_name;
groupIdSelect.appendChild(option);
}
}
}
groupIdSelect.value = tag.group_id || '';
toggleGroupSettings();
}
// Populate group-specific settings
if (document.getElementById('tagAutoShareOnApply')) {
document.getElementById('tagAutoShareOnApply').checked = tag.auto_share_on_apply !== undefined ? tag.auto_share_on_apply : true;
}
if (document.getElementById('tagShareWithGroupLead')) {
document.getElementById('tagShareWithGroupLead').checked = tag.share_with_group_lead !== undefined ? tag.share_with_group_lead : true;
}
// Populate retention settings
// If retention_days is -1, it means protected from deletion
const retentionDaysInput = document.getElementById('tagRetentionDays');
const protectCheckbox = document.getElementById('tagProtectFromDeletion');
if (tag.retention_days === -1 || tag.protect_from_deletion) {
// Protected from deletion
if (protectCheckbox) {
protectCheckbox.checked = true;
}
if (retentionDaysInput) {
retentionDaysInput.value = '';
retentionDaysInput.disabled = true;
retentionDaysInput.classList.add('opacity-50', 'cursor-not-allowed');
retentionDaysInput.placeholder = 'Infinite retention (protected)';
}
} else {
// Not protected
if (protectCheckbox) {
protectCheckbox.checked = false;
}
if (retentionDaysInput) {
retentionDaysInput.value = tag.retention_days || '';
retentionDaysInput.disabled = false;
retentionDaysInput.classList.remove('opacity-50', 'cursor-not-allowed');
retentionDaysInput.placeholder = 'Leave empty to use global retention';
}
}
// Populate ASR settings
if (document.getElementById('tagLanguage')) {
document.getElementById('tagLanguage').value = tag.default_language || '';
}
if (document.getElementById('tagMinSpeakers')) {
document.getElementById('tagMinSpeakers').value = tag.default_min_speakers || '';
}
if (document.getElementById('tagMaxSpeakers')) {
document.getElementById('tagMaxSpeakers').value = tag.default_max_speakers || '';
}
if (document.getElementById('tagHotwords')) {
document.getElementById('tagHotwords').value = tag.default_hotwords || '';
}
if (document.getElementById('tagInitialPrompt')) {
document.getElementById('tagInitialPrompt').value = tag.default_initial_prompt || '';
}
// Populate naming template
if (document.getElementById('tagNamingTemplate')) {
document.getElementById('tagNamingTemplate').value = tag.naming_template_id || '';
}
// Populate export template
if (document.getElementById('tagExportTemplate')) {
document.getElementById('tagExportTemplate').value = tag.export_template_id || '';
}
} else {
// Create mode
isEditingTag = false;
tagModalTitle.textContent = 'Create Tag';
saveTagBtn.innerHTML = '<i class="fas fa-save mr-2"></i> Save Tag';
tagForm.reset();
document.getElementById('tagColor').value = '#3B82F6';
// Reset group selection
if (document.getElementById('tagGroupId')) {
document.getElementById('tagGroupId').value = '';
toggleGroupSettings();
}
// Set default values for group settings
if (document.getElementById('tagAutoShareOnApply')) {
document.getElementById('tagAutoShareOnApply').checked = true;
}
if (document.getElementById('tagShareWithGroupLead')) {
document.getElementById('tagShareWithGroupLead').checked = true;
}
// Clear retention settings and ensure field is enabled
const retentionInput = document.getElementById('tagRetentionDays');
const protectCheck = document.getElementById('tagProtectFromDeletion');
if (retentionInput) {
retentionInput.value = '';
retentionInput.disabled = false;
retentionInput.classList.remove('opacity-50', 'cursor-not-allowed');
retentionInput.placeholder = 'Leave empty to use global retention';
}
if (protectCheck) {
protectCheck.checked = false;
}
// Reset naming template
if (document.getElementById('tagNamingTemplate')) {
document.getElementById('tagNamingTemplate').value = '';
}
// Reset export template
if (document.getElementById('tagExportTemplate')) {
document.getElementById('tagExportTemplate').value = '';
}
}
// Load naming templates for dropdown
loadNamingTemplatesForTagModal();
// Load export templates for dropdown
loadExportTemplatesForTagModal();
// Reset to first tab
switchModalTab('tagModalTabs', 'tag-modal-tab', 'tag-modal-tab-content', 'tagTabTranscription');
tagModal.classList.remove('hidden');
}
// Load naming templates for the tag modal dropdown
async function loadNamingTemplatesForTagModal() {
const select = document.getElementById('tagNamingTemplate');
if (!select) return;
try {
const response = await fetch('/api/naming-templates');
if (!response.ok) return;
const templates = await response.json();
// Preserve current selection
const currentValue = select.value;
// Clear all options except the first one
select.innerHTML = '<option value="">No template (use user default or AI title)</option>';
// Add template options
templates.forEach(template => {
const option = document.createElement('option');
option.value = template.id;
option.textContent = template.name;
if (template.description) {
option.title = template.description;
}
select.appendChild(option);
});
// Restore selection
if (currentValue) {
select.value = currentValue;
}
} catch (error) {
console.error('Error loading naming templates for tag modal:', error);
}
}
// Load export templates for the tag modal dropdown
async function loadExportTemplatesForTagModal() {
const select = document.getElementById('tagExportTemplate');
if (!select) return;
try {
const response = await fetch('/api/export-templates');
if (!response.ok) return;
const templates = await response.json();
// Preserve current selection
const currentValue = select.value;
// Clear all options and add default
select.textContent = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'No template (use user default)';
select.appendChild(defaultOpt);
// Add template options
templates.forEach(template => {
const option = document.createElement('option');
option.value = template.id;
option.textContent = template.name + (template.is_default ? ' (Default)' : '');
select.appendChild(option);
});
// Restore selection
if (currentValue) {
select.value = currentValue;
}
} catch (error) {
console.error('Error loading export templates for tag modal:', error);
}
}
// Edit tag
function editTag(tag) {
openTagModal(tag);
}
// Delete tag
async function deleteTag(tagId, tagName) {
if (!confirm(`Are you sure you want to delete "${tagName}"?`)) {
return;
}
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/api/tags/${tagId}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
// Remove from local array
tags = tags.filter(t => t.id !== tagId);
displayTags();
showToast(`Tag "${tagName}" deleted successfully`);
} catch (error) {
console.error('Error deleting tag:', error);
showToast(`Failed to delete tag: ${error.message}`, 'error');
}
}
// Save tag form submission
tagForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(tagForm);
// Determine retention_days: -1 if protected, otherwise use the input value
const isProtected = document.getElementById('tagProtectFromDeletion')?.checked || false;
let retentionDays = null;
if (isProtected) {
retentionDays = -1; // -1 means protected/infinite retention
} else {
const retentionInput = formData.get('retention_days');
retentionDays = retentionInput ? parseInt(retentionInput) : null;
}
const namingTemplateId = formData.get('naming_template_id');
const exportTemplateId = formData.get('export_template_id');
const tagData = {
name: formData.get('name'),
color: formData.get('color'),
custom_prompt: formData.get('custom_prompt') || null,
naming_template_id: namingTemplateId ? parseInt(namingTemplateId) : null,
export_template_id: exportTemplateId ? parseInt(exportTemplateId) : null,
default_language: formData.get('default_language') || null,
default_min_speakers: formData.get('default_min_speakers') ? parseInt(formData.get('default_min_speakers')) : null,
default_max_speakers: formData.get('default_max_speakers') ? parseInt(formData.get('default_max_speakers')) : null,
default_hotwords: formData.get('default_hotwords') || null,
default_initial_prompt: formData.get('default_initial_prompt') || null,
retention_days: retentionDays
};
// Add group-related fields
const groupId = formData.get('group_id');
if (groupId) {
tagData.group_id = parseInt(groupId);
tagData.auto_share_on_apply = document.getElementById('tagAutoShareOnApply')?.checked || false;
tagData.share_with_group_lead = document.getElementById('tagShareWithGroupLead')?.checked || false;
} else {
tagData.group_id = null;
}
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
let url = '/api/tags';
let method = 'POST';
if (isEditingTag) {
url += `/${formData.get('tag_id')}`;
method = 'PUT';
}
const response = await fetch(url, {
method: method,
headers: headers,
body: JSON.stringify(tagData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
const savedTag = await response.json();
if (isEditingTag) {
// Update existing tag in local array
const index = tags.findIndex(t => t.id === savedTag.id);
if (index !== -1) {
tags[index] = savedTag;
}
showToast(`Tag "${savedTag.name}" updated successfully`);
} else {
// Add new tag to local array
tags.push(savedTag);
showToast(`Tag "${savedTag.name}" created successfully`);
}
displayTags();
closeTagModal();
} catch (error) {
console.error('Error saving tag:', error);
showToast(`Failed to save tag: ${error.message}`, 'error');
}
});
// ========== FOLDERS MANAGEMENT ==========
let folders = [];
let isEditingFolder = false;
let foldersEnabled = false;
// Folder management elements
const createFolderBtn = document.getElementById('createFolderBtn');
const folderModal = document.getElementById('folderModal');
const closeFolderModalBtn = document.getElementById('closeFolderModalBtn');
const cancelFolderBtn = document.getElementById('cancelFolderBtn');
const folderForm = document.getElementById('folderForm');
const folderModalTitle = document.getElementById('folderModalTitle');
const saveFolderBtn = document.getElementById('saveFolderBtn');
const foldersLoading = document.getElementById('foldersLoading');
const foldersError = document.getElementById('foldersError');
const foldersErrorMessage = document.getElementById('foldersErrorMessage');
const foldersEmpty = document.getElementById('foldersEmpty');
const foldersGrid = document.getElementById('foldersGrid');
// Check if folders feature is enabled and show/hide the tab
// Also check if auto-export is enabled for export templates sub-tab
async function checkFeaturesEnabled() {
try {
const response = await fetch('/api/config');
if (response.ok) {
const config = await response.json();
foldersEnabled = config.enable_folders === true;
if (foldersEnabled && tabFolders) {
tabFolders.style.display = '';
}
// Show export templates sub-tab if auto-export is enabled
const exportSubtab = document.getElementById('subtab-export');
if (config.enable_auto_export === true && exportSubtab) {
exportSubtab.style.display = '';
}
}
} catch (error) {
console.error('Error checking features enabled status:', error);
}
}
checkFeaturesEnabled();
// Load folders from API
async function loadFolders() {
if (!foldersEnabled) {
if (foldersError) {
foldersErrorMessage.textContent = 'Folders feature is not enabled';
foldersError.classList.remove('hidden');
}
return;
}
showFoldersLoadingState();
try {
const response = await fetch('/api/folders');
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
folders = await response.json();
displayFolders();
} catch (error) {
console.error('Error loading folders:', error);
showFoldersErrorState(error.message);
}
}
function showFoldersLoadingState() {
if (foldersLoading) foldersLoading.classList.remove('hidden');
if (foldersError) foldersError.classList.add('hidden');
if (foldersEmpty) foldersEmpty.classList.add('hidden');
if (foldersGrid) foldersGrid.classList.add('hidden');
}
function showFoldersErrorState(message) {
if (foldersLoading) foldersLoading.classList.add('hidden');
if (foldersError) foldersError.classList.remove('hidden');
if (foldersEmpty) foldersEmpty.classList.add('hidden');
if (foldersGrid) foldersGrid.classList.add('hidden');
if (foldersErrorMessage) foldersErrorMessage.textContent = message;
}
function displayFolders() {
if (foldersLoading) foldersLoading.classList.add('hidden');
if (foldersError) foldersError.classList.add('hidden');
if (folders.length === 0) {
if (foldersEmpty) foldersEmpty.classList.remove('hidden');
if (foldersGrid) foldersGrid.classList.add('hidden');
} else {
if (foldersEmpty) foldersEmpty.classList.add('hidden');
if (foldersGrid) foldersGrid.classList.remove('hidden');
renderFoldersGrid();
}
}
function renderFoldersGrid() {
if (!foldersGrid) return;
foldersGrid.textContent = '';
folders.forEach(folder => {
const folderCard = createFolderCard(folder);
foldersGrid.appendChild(folderCard);
});
}
function createFolderCard(folder) {
const div = document.createElement('div');
const isGroupFolder = folder.group_id !== null && folder.group_id !== undefined;
const canEdit = folder.can_edit !== undefined ? folder.can_edit : true;
div.className = 'bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:shadow-md transition-shadow duration-200 overflow-hidden';
// Create header
const header = document.createElement('div');
header.className = 'flex items-center px-4 py-3 border-b border-[var(--border-primary)]';
header.style.backgroundColor = folder.color + '15';
const colorDot = document.createElement('div');
colorDot.className = 'w-3 h-3 rounded-full mr-3 flex-shrink-0';
colorDot.style.backgroundColor = folder.color;
const titleWrapper = document.createElement('div');
titleWrapper.className = 'flex-grow';
const title = document.createElement('h3');
title.className = 'font-medium text-[var(--text-primary)] truncate';
title.textContent = folder.name;
titleWrapper.appendChild(title);
if (isGroupFolder) {
const groupLabel = document.createElement('span');
groupLabel.className = 'text-xs text-[var(--text-muted)]';
const groupIcon = document.createElement('i');
groupIcon.className = 'fas fa-users mr-1';
groupLabel.appendChild(groupIcon);
groupLabel.appendChild(document.createTextNode(folder.group_name || ''));
titleWrapper.appendChild(groupLabel);
}
header.appendChild(colorDot);
header.appendChild(titleWrapper);
if (canEdit) {
const actions = document.createElement('div');
actions.className = 'flex items-center space-x-2';
const editBtn = document.createElement('button');
editBtn.className = 'edit-folder-btn text-[var(--text-muted)] hover:text-[var(--text-accent)] p-1';
editBtn.title = 'Edit folder';
const editIcon = document.createElement('i');
editIcon.className = 'fas fa-edit';
editBtn.appendChild(editIcon);
editBtn.addEventListener('click', () => openFolderModal(folder));
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-folder-btn text-[var(--text-muted)] hover:text-red-500 p-1';
deleteBtn.title = 'Delete folder';
const deleteIcon = document.createElement('i');
deleteIcon.className = 'fas fa-trash';
deleteBtn.appendChild(deleteIcon);
deleteBtn.addEventListener('click', () => deleteFolder(folder.id, folder.name));
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
header.appendChild(actions);
} else {
const lockIcon = document.createElement('span');
lockIcon.className = 'text-xs text-[var(--text-muted)]';
lockIcon.title = "You don't have permission to edit this group folder";
const lock = document.createElement('i');
lock.className = 'fas fa-lock';
lockIcon.appendChild(lock);
header.appendChild(lockIcon);
}
// Create body
const body = document.createElement('div');
body.className = 'px-4 py-3';
// Metadata badges
const badges = document.createElement('div');
badges.className = 'flex flex-wrap gap-2 mb-2';
if (folder.protect_from_deletion || folder.retention_days === -1) {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium';
badge.style.backgroundColor = '#f59e0b';
badge.style.color = 'white';
badge.title = 'Protected with infinite retention';
const shieldIcon = document.createElement('i');
shieldIcon.className = 'fas fa-shield-alt';
badge.appendChild(shieldIcon);
badge.appendChild(document.createTextNode(' ∞'));
badges.appendChild(badge);
} else if (folder.retention_days && folder.retention_days > 0) {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium';
badge.style.backgroundColor = '#2563eb';
badge.style.color = 'white';
badge.title = 'Retention: ' + folder.retention_days + ' days';
const clockIcon = document.createElement('i');
clockIcon.className = 'fas fa-clock';
badge.appendChild(clockIcon);
badge.appendChild(document.createTextNode(' ' + folder.retention_days + 'd'));
badges.appendChild(badge);
}
if (isGroupFolder) {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium';
badge.style.backgroundColor = '#9333ea';
badge.style.color = 'white';
const shareIcon = document.createElement('i');
if (folder.auto_share_on_apply) {
shareIcon.className = 'fas fa-user-friends';
badge.title = 'Auto-shares with all group members';
badge.appendChild(shareIcon);
badge.appendChild(document.createTextNode(' All members'));
} else if (folder.share_with_group_lead) {
shareIcon.className = 'fas fa-user-tie';
badge.title = 'Shares with group admins only';
badge.appendChild(shareIcon);
badge.appendChild(document.createTextNode(' Admins only'));
} else {
shareIcon.className = 'fas fa-user-lock';
badge.title = 'Manual sharing only';
badge.appendChild(shareIcon);
badge.appendChild(document.createTextNode(' Manual'));
}
badges.appendChild(badge);
}
if (badges.childNodes.length > 0) {
body.appendChild(badges);
}
// Info row
const infoRow = document.createElement('div');
infoRow.className = 'text-sm text-[var(--text-muted)]';
if (folder.custom_prompt) {
const promptIcon = document.createElement('i');
promptIcon.className = 'fas fa-comment-alt mr-1';
promptIcon.title = 'Has custom prompt';
infoRow.appendChild(promptIcon);
}
if (folder.default_language) {
const langIcon = document.createElement('i');
langIcon.className = 'fas fa-language mr-1';
langIcon.title = 'Language: ' + folder.default_language;
infoRow.appendChild(langIcon);
}
if (folder.naming_template_name) {
const templateIcon = document.createElement('i');
templateIcon.className = 'fas fa-heading mr-1';
templateIcon.title = 'Naming template: ' + folder.naming_template_name;
infoRow.appendChild(templateIcon);
}
const countSpan = document.createElement('span');
countSpan.className = 'ml-auto';
const count = folder.recording_count || 0;
countSpan.textContent = count + ' recording' + (count !== 1 ? 's' : '');
infoRow.appendChild(countSpan);
body.appendChild(infoRow);
div.appendChild(header);
div.appendChild(body);
return div;
}
// Close folder modal
function closeFolderModal() {
if (folderModal) folderModal.classList.add('hidden');
if (folderForm) folderForm.reset();
isEditingFolder = false;
document.getElementById('folderId').value = '';
}
if (closeFolderModalBtn) closeFolderModalBtn.addEventListener('click', closeFolderModal);
if (cancelFolderBtn) cancelFolderBtn.addEventListener('click', closeFolderModal);
// Close modal when clicking outside
if (folderModal) {
folderModal.addEventListener('click', (event) => {
if (event.target === folderModal) {
closeFolderModal();
}
});
}
// Open folder modal
function openFolderModal(folder) {
folder = folder || null;
if (folder) {
// Edit mode
isEditingFolder = true;
if (folderModalTitle) folderModalTitle.textContent = 'Edit Folder';
if (saveFolderBtn) {
saveFolderBtn.textContent = '';
const saveIcon = document.createElement('i');
saveIcon.className = 'fas fa-save mr-2';
saveFolderBtn.appendChild(saveIcon);
saveFolderBtn.appendChild(document.createTextNode(' Update Folder'));
}
// Populate form
document.getElementById('folderId').value = folder.id;
document.getElementById('folderName').value = folder.name;
document.getElementById('folderColor').value = folder.color;
document.getElementById('folderCustomPrompt').value = folder.custom_prompt || '';
var langField = document.getElementById('folderLanguage');
if (langField) langField.value = folder.default_language || '';
var minField = document.getElementById('folderMinSpeakers');
if (minField) minField.value = folder.default_min_speakers || '';
var maxField = document.getElementById('folderMaxSpeakers');
if (maxField) maxField.value = folder.default_max_speakers || '';
var hwField = document.getElementById('folderHotwords');
if (hwField) hwField.value = folder.default_hotwords || '';
var ipField = document.getElementById('folderInitialPrompt');
if (ipField) ipField.value = folder.default_initial_prompt || '';
// Group selection
const folderGroupId = document.getElementById('folderGroupId');
if (folderGroupId) {
folderGroupId.value = folder.group_id || '';
updateFolderGroupSettingsVisibility();
}
// Group settings
var autoShare = document.getElementById('folderAutoShareOnApply');
if (autoShare) autoShare.checked = folder.auto_share_on_apply !== false;
var shareWithLead = document.getElementById('folderShareWithGroupLead');
if (shareWithLead) shareWithLead.checked = folder.share_with_group_lead !== false;
// Retention settings
const folderRetentionDays = document.getElementById('folderRetentionDays');
const folderProtectFromDeletion = document.getElementById('folderProtectFromDeletion');
if (folderProtectFromDeletion && folderRetentionDays) {
if (folder.retention_days === -1 || folder.protect_from_deletion) {
folderProtectFromDeletion.checked = true;
folderRetentionDays.value = '';
folderRetentionDays.disabled = true;
} else {
folderProtectFromDeletion.checked = false;
folderRetentionDays.value = folder.retention_days || '';
folderRetentionDays.disabled = false;
}
}
} else {
// Create mode
isEditingFolder = false;
if (folderModalTitle) folderModalTitle.textContent = 'Create Folder';
if (saveFolderBtn) {
saveFolderBtn.textContent = '';
const saveIcon = document.createElement('i');
saveIcon.className = 'fas fa-save mr-2';
saveFolderBtn.appendChild(saveIcon);
saveFolderBtn.appendChild(document.createTextNode(' Save Folder'));
}
if (folderForm) folderForm.reset();
document.getElementById('folderColor').value = '#10B981';
document.getElementById('folderId').value = '';
}
// Load naming templates for dropdown (pass folder to set initial value)
loadNamingTemplatesForFolderModal(folder);
// Load export templates for dropdown (pass folder to set initial value)
loadExportTemplatesForFolderModal(folder);
// Reset to first tab
switchModalTab('folderModalTabs', 'folder-modal-tab', 'folder-modal-tab-content', 'folderTabTranscription');
if (folderModal) folderModal.classList.remove('hidden');
}
// Load naming templates for folder modal
async function loadNamingTemplatesForFolderModal(folder) {
const select = document.getElementById('folderNamingTemplate');
if (!select) return;
try {
const response = await fetch('/api/naming-templates');
if (response.ok) {
const templates = await response.json();
select.textContent = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'No template (use user default or AI title)';
select.appendChild(defaultOpt);
templates.forEach(function(t) {
const option = document.createElement('option');
option.value = t.id;
option.textContent = t.name;
select.appendChild(option);
});
// Set value if editing
if (folder && folder.naming_template_id) {
select.value = folder.naming_template_id;
}
}
} catch (error) {
console.error('Error loading naming templates:', error);
}
}
// Load export templates for folder modal
async function loadExportTemplatesForFolderModal(folder) {
const select = document.getElementById('folderExportTemplate');
if (!select) return;
try {
const response = await fetch('/api/export-templates');
if (response.ok) {
const templates = await response.json();
select.textContent = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'No template (use user default)';
select.appendChild(defaultOpt);
templates.forEach(function(t) {
const option = document.createElement('option');
option.value = t.id;
option.textContent = t.name + (t.is_default ? ' (Default)' : '');
select.appendChild(option);
});
// Set value if editing
if (folder && folder.export_template_id) {
select.value = folder.export_template_id;
}
}
} catch (error) {
console.error('Error loading export templates:', error);
}
}
// Update folder group settings visibility
function updateFolderGroupSettingsVisibility() {
const folderGroupId = document.getElementById('folderGroupId');
const folderGroupSettingsSection = document.getElementById('folderGroupSettingsSection');
if (folderGroupId && folderGroupSettingsSection) {
if (folderGroupId.value) {
folderGroupSettingsSection.classList.remove('hidden');
} else {
folderGroupSettingsSection.classList.add('hidden');
}
}
}
// Folder group selection change
const folderGroupIdSelect = document.getElementById('folderGroupId');
if (folderGroupIdSelect) {
folderGroupIdSelect.addEventListener('change', updateFolderGroupSettingsVisibility);
}
// Folder protect from deletion checkbox
const folderProtectCheckbox = document.getElementById('folderProtectFromDeletion');
const folderRetentionInput = document.getElementById('folderRetentionDays');
if (folderProtectCheckbox && folderRetentionInput) {
folderProtectCheckbox.addEventListener('change', function() {
if (folderProtectCheckbox.checked) {
folderRetentionInput.value = '';
folderRetentionInput.disabled = true;
folderRetentionInput.classList.add('opacity-50', 'cursor-not-allowed');
} else {
folderRetentionInput.disabled = false;
folderRetentionInput.classList.remove('opacity-50', 'cursor-not-allowed');
}
});
}
// Delete folder
async function deleteFolder(folderId, folderName) {
if (!confirm('Are you sure you want to delete the folder "' + folderName + '"?\n\nRecordings in this folder will be removed from the folder but not deleted.')) {
return;
}
try {
const csrfToken = document.querySelector('meta[name=csrf-token]');
const csrfValue = csrfToken ? csrfToken.getAttribute('content') : '';
const response = await fetch('/api/folders/' + folderId, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfValue
}
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete folder');
}
folders = folders.filter(function(f) { return f.id !== folderId; });
displayFolders();
showToast('Folder "' + folderName + '" deleted successfully');
} catch (error) {
console.error('Error deleting folder:', error);
showToast('Failed to delete folder: ' + error.message, 'error');
}
}
// Create folder button
if (createFolderBtn) {
createFolderBtn.addEventListener('click', function() { openFolderModal(); });
}
// Save folder form
if (folderForm) {
folderForm.addEventListener('submit', async function(e) {
e.preventDefault();
const folderId = document.getElementById('folderId').value;
const folderName = document.getElementById('folderName').value.trim();
if (!folderName) {
showToast('Folder name is required', 'error');
return;
}
const folderData = {
name: folderName,
color: document.getElementById('folderColor').value,
custom_prompt: document.getElementById('folderCustomPrompt').value.trim() || null
};
var langEl = document.getElementById('folderLanguage');
folderData.default_language = langEl ? langEl.value.trim() || null : null;
var minEl = document.getElementById('folderMinSpeakers');
folderData.default_min_speakers = minEl && minEl.value ? parseInt(minEl.value) : null;
var maxEl = document.getElementById('folderMaxSpeakers');
folderData.default_max_speakers = maxEl && maxEl.value ? parseInt(maxEl.value) : null;
var hwEl = document.getElementById('folderHotwords');
folderData.default_hotwords = hwEl ? hwEl.value.trim() || null : null;
var ipEl = document.getElementById('folderInitialPrompt');
folderData.default_initial_prompt = ipEl ? ipEl.value.trim() || null : null;
var templateEl = document.getElementById('folderNamingTemplate');
folderData.naming_template_id = templateEl && templateEl.value ? parseInt(templateEl.value) : null;
var exportTemplateEl = document.getElementById('folderExportTemplate');
folderData.export_template_id = exportTemplateEl && exportTemplateEl.value ? parseInt(exportTemplateEl.value) : null;
// Group settings
const folderGroupIdEl = document.getElementById('folderGroupId');
if (folderGroupIdEl && folderGroupIdEl.value) {
folderData.group_id = parseInt(folderGroupIdEl.value);
var autoShareEl = document.getElementById('folderAutoShareOnApply');
folderData.auto_share_on_apply = autoShareEl ? autoShareEl.checked : true;
var shareLeadEl = document.getElementById('folderShareWithGroupLead');
folderData.share_with_group_lead = shareLeadEl ? shareLeadEl.checked : true;
}
// Retention settings
const folderProtect = document.getElementById('folderProtectFromDeletion');
const folderRetention = document.getElementById('folderRetentionDays');
if (folderProtect && folderProtect.checked) {
folderData.retention_days = -1;
} else if (folderRetention && folderRetention.value) {
folderData.retention_days = parseInt(folderRetention.value);
}
try {
const csrfToken = document.querySelector('meta[name=csrf-token]');
const csrfValue = csrfToken ? csrfToken.getAttribute('content') : '';
const url = isEditingFolder ? '/api/folders/' + folderId : '/api/folders';
const method = isEditingFolder ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfValue
},
body: JSON.stringify(folderData)
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to save folder');
}
const savedFolder = await response.json();
if (isEditingFolder) {
const index = folders.findIndex(function(f) { return f.id === parseInt(folderId); });
if (index !== -1) {
folders[index] = savedFolder;
}
showToast('Folder "' + savedFolder.name + '" updated successfully');
} else {
folders.push(savedFolder);
showToast('Folder "' + savedFolder.name + '" created successfully');
}
displayFolders();
closeFolderModal();
} catch (error) {
console.error('Error saving folder:', error);
showToast('Failed to save folder: ' + error.message, 'error');
}
});
}
// Check if auto-deletion is enabled for folder retention section
function checkFolderAutoDeletionEnabled() {
const isAutoDeletionEnabled = {{ 'true' if enable_auto_deletion else 'false' }};
const retentionSection = document.getElementById('folderRetentionSection');
if (retentionSection && isAutoDeletionEnabled) {
retentionSection.classList.remove('hidden');
}
}
checkFolderAutoDeletionEnabled();
// Initialize folder group selection visibility
function initFolderGroupSelection() {
const folderGroupSection = document.getElementById('folderGroupSelectionSection');
if (folderGroupSection && userAdminGroups && userAdminGroups.length > 0) {
folderGroupSection.classList.remove('hidden');
// Populate folder group dropdown
const folderGroupIdEl = document.getElementById('folderGroupId');
if (folderGroupIdEl) {
folderGroupIdEl.textContent = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = t('account.personalFolder');
folderGroupIdEl.appendChild(defaultOpt);
userAdminGroups.forEach(function(group) {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
folderGroupIdEl.appendChild(option);
});
}
}
}
initFolderGroupSelection();
// ========== END FOLDERS MANAGEMENT ==========
// AJAX form submission for Account Information
const accountInfoForm = document.getElementById('accountInfoForm');
if (accountInfoForm) {
accountInfoForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(accountInfoForm);
try {
const response = await fetch('/account', {
method: 'POST',
body: formData
});
if (response.ok) {
showToast('Account information updated successfully', 'success');
} else {
var text = await response.text();
showToast('Failed to update account information', 'error');
}
} catch (error) {
showToast('Network error: ' + error.message, 'error');
}
});
}
// AJAX form submission for Custom Prompts
const customPromptsForm = document.getElementById('customPromptsForm');
if (customPromptsForm) {
customPromptsForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(customPromptsForm);
try {
const response = await fetch('/account', {
method: 'POST',
body: formData
});
if (response.ok) {
showToast('Custom prompt saved successfully', 'success');
} else {
const text = await response.text();
showToast('Failed to save custom prompt', 'error');
}
} catch (error) {
showToast('Network error: ' + error.message, 'error');
}
});
}
// Check for stored tab preference (from language change reload)
const storedTab = localStorage.getItem('activeAccountTab');
if (storedTab) {
localStorage.removeItem('activeAccountTab'); // Clean up after use
}
// Check for URL hash and show appropriate tab
const urlHash = window.location.hash.substring(1); // Remove the '#' symbol
if (urlHash === 'prompts') {
showPromptsTab();
} else if (urlHash === 'tags') {
showTagsTab();
} else if (urlHash === 'shares') {
showSharesTab();
} else if (storedTab) {
// Restore previously active tab after language change
if (storedTab === 'prompts') {
showPromptsTab();
} else if (storedTab === 'tags') {
showTagsTab();
} else if (storedTab === 'shares') {
showSharesTab();
} else {
showAccountTab();
}
} else {
// Default to account tab
showAccountTab();
}
// Track original UI language to detect changes
let currentUILanguage = document.getElementById('ui_language').value;
const originalUILanguage = currentUILanguage;
// Add immediate language change listener to the UI language dropdown
const uiLanguageSelect = document.getElementById('ui_language');
if (uiLanguageSelect) {
uiLanguageSelect.addEventListener('change', async function(e) {
const newLanguage = e.target.value;
if (newLanguage !== currentUILanguage) {
await changeLanguageImmediately(newLanguage);
// Update the current language tracker
currentUILanguage = newLanguage;
}
});
}
// Add event listener to the account info form
document.getElementById('accountInfoForm').addEventListener('submit', function(e) {
const newUILanguage = document.getElementById('ui_language').value;
if (newUILanguage !== originalUILanguage) {
// Language has changed, store flag to reload main app
localStorage.setItem('ui_language_changed', 'true');
localStorage.setItem('preferredLanguage', newUILanguage);
}
});
// API Tokens Management Functions
let tokensData = [];
let tokensTabLoaded = false;
async function loadTokensTab() {
if (tokensTabLoaded) return;
const tokensLoading = document.getElementById('tokensLoading');
const tokensContainer = document.getElementById('tokensContainer');
try {
tokensLoading.classList.remove('hidden');
tokensContainer.classList.add('hidden');
const response = await fetch('/api/tokens');
if (!response.ok) {
throw new Error('Failed to load tokens');
}
const data = await response.json();
tokensData = data.tokens || [];
renderTokensList();
tokensTabLoaded = true;
tokensLoading.classList.add('hidden');
tokensContainer.classList.remove('hidden');
} catch (error) {
console.error('Error loading tokens:', error);
tokensLoading.innerHTML = `
<div class="text-center py-12">
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-4"></i>
<p class="text-red-600">Failed to load tokens: ${error.message}</p>
</div>
`;
}
}
function renderTokensList() {
const activeTokensSection = document.getElementById('activeTokensSection');
const activeTokensList = document.getElementById('activeTokensList');
const activeTokensCount = document.getElementById('activeTokensCount');
const noTokensMessage = document.getElementById('noTokensMessage');
// Filter active tokens
const activeTokens = tokensData.filter(token => !token.revoked && !isTokenExpired(token));
if (activeTokens.length > 0) {
activeTokensSection.classList.remove('hidden');
noTokensMessage.classList.add('hidden');
activeTokensCount.textContent = `(${activeTokens.length})`;
activeTokensList.innerHTML = activeTokens.map(token => {
const statusClass = getTokenStatusClass(token);
const status = getTokenStatus(token);
const createdDate = new Date(token.created_at).toLocaleDateString();
const lastUsed = token.last_used_at ? new Date(token.last_used_at).toLocaleDateString() : null;
return `
<div class="bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:border-[var(--border-accent)] transition-colors duration-200">
<div class="p-3">
<!-- Header with name, status badge, and delete button -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 flex-1 min-w-0">
<i class="fas fa-key text-[var(--text-accent)] text-sm flex-shrink-0"></i>
<h4 class="font-medium text-sm text-[var(--text-primary)] truncate">${escapeHtml(token.name)}</h4>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<span class="${statusClass} capitalize">${status}</span>
<button onclick="revokeToken(${token.id}, '${escapeHtml(token.name).replace(/'/g, "\\'")}' )"
class="p-1 text-[var(--text-danger)] hover:bg-[var(--bg-danger-light)] rounded transition-colors"
title="Revoke token">
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
<!-- Compact stats grid -->
<div class="grid grid-cols-2 gap-1 text-[10px] text-[var(--text-muted)]">
<div class="flex items-center gap-1">
<i class="fas fa-calendar-plus opacity-60"></i>
<span>${createdDate}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-clock opacity-60"></i>
<span>${lastUsed || 'Never used'}</span>
</div>
</div>
<!-- Expiration badge -->
<div class="mt-2">
${token.expires_at ? `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-800">
<i class="fas fa-hourglass-half"></i>
Expires ${new Date(token.expires_at).toLocaleDateString()}
</span>
` : `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
<i class="fas fa-infinity"></i>
No expiration
</span>
`}
</div>
</div>
</div>
`;
}).join('');
} else {
activeTokensSection.classList.add('hidden');
noTokensMessage.classList.remove('hidden');
}
}
function isTokenExpired(token) {
if (!token.expires_at) return false;
const expiryDate = new Date(token.expires_at);
return expiryDate < new Date();
}
function formatTokenDate(dateString) {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
function getTokenStatus(token) {
if (token.revoked) return 'revoked';
if (isTokenExpired(token)) return 'expired';
return 'active';
}
function getTokenStatusClass(token) {
const status = getTokenStatus(token);
const baseClasses = 'px-2 py-1 text-xs font-semibold rounded';
switch (status) {
case 'active':
return `${baseClasses} bg-green-100 text-green-800`;
case 'expired':
return `${baseClasses} bg-yellow-100 text-yellow-800`;
case 'revoked':
return `${baseClasses} bg-red-100 text-red-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
}
}
// Token modal elements - same pattern as tag modal
const createTokenModal = document.getElementById('createTokenModal');
const tokenSecretModal = document.getElementById('tokenSecretModal');
const createTokenForm = document.getElementById('createTokenForm');
const createTokenBtn = document.getElementById('createTokenBtn');
const createTokenBtnEmpty = document.getElementById('createTokenBtnEmpty');
// Close create token modal
function closeCreateTokenModal() {
createTokenModal.classList.add('hidden');
createTokenForm.reset();
}
// Close token secret modal
function closeTokenSecretModal() {
tokenSecretModal.classList.add('hidden');
}
// Open create token modal
function openCreateTokenModal() {
createTokenForm.reset();
createTokenModal.classList.remove('hidden');
}
// Show token secret modal
function showTokenSecretModal(tokenValue, tokenName, expiresAt) {
document.getElementById('newTokenValue').value = tokenValue;
document.getElementById('newTokenInfo').innerHTML = `
<div class="flex justify-between">
<span class="text-[var(--text-muted)]">Name:</span>
<span class="text-[var(--text-primary)] font-medium">${escapeHtml(tokenName)}</span>
</div>
<div class="flex justify-between">
<span class="text-[var(--text-muted)]">Expires:</span>
<span class="text-[var(--text-primary)]">${expiresAt ? formatTokenDate(expiresAt) : 'Never'}</span>
</div>
`;
tokenSecretModal.classList.remove('hidden');
}
// Create token button click handlers
if (createTokenBtn) {
createTokenBtn.addEventListener('click', openCreateTokenModal);
}
if (createTokenBtnEmpty) {
createTokenBtnEmpty.addEventListener('click', openCreateTokenModal);
}
// Close modal handlers
document.getElementById('closeCreateTokenModal')?.addEventListener('click', closeCreateTokenModal);
document.getElementById('cancelCreateTokenBtn')?.addEventListener('click', closeCreateTokenModal);
document.getElementById('closeTokenSecretModal')?.addEventListener('click', closeTokenSecretModal);
// Close modals when clicking outside (on the backdrop)
createTokenModal?.addEventListener('click', (event) => {
if (event.target === createTokenModal) {
closeCreateTokenModal();
}
});
tokenSecretModal?.addEventListener('click', (event) => {
if (event.target === tokenSecretModal) {
closeTokenSecretModal();
}
});
// Copy token button
document.getElementById('copyTokenBtn')?.addEventListener('click', async () => {
const tokenValue = document.getElementById('newTokenValue').value;
const copyBtn = document.getElementById('copyTokenBtn');
const copyIcon = document.getElementById('copyTokenIcon');
const copyText = document.getElementById('copyTokenText');
try {
await navigator.clipboard.writeText(tokenValue);
// Visual feedback - change to success state
copyIcon.classList.remove('fa-copy', 'text-[var(--text-muted)]');
copyIcon.classList.add('fa-check', 'text-green-500');
copyText.classList.remove('hidden', 'text-[var(--text-muted)]');
copyText.classList.add('text-green-500');
copyBtn.classList.add('bg-green-50', 'border-green-200');
// Reset after 2 seconds
setTimeout(() => {
copyIcon.classList.remove('fa-check', 'text-green-500');
copyIcon.classList.add('fa-copy', 'text-[var(--text-muted)]');
copyText.classList.add('hidden', 'text-[var(--text-muted)]');
copyText.classList.remove('text-green-500');
copyBtn.classList.remove('bg-green-50', 'border-green-200');
}, 2000);
showToast('Token copied to clipboard', 'success');
} catch (error) {
console.error('Error copying token:', error);
showToast('Failed to copy token to clipboard', 'error');
}
});
// Form submission handler
createTokenForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const tokenName = document.getElementById('tokenNameInput').value.trim();
const expiresInDays = parseInt(document.getElementById('tokenExpirationSelect').value);
if (!tokenName) {
showToast('Please enter a token name', 'error');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
name: tokenName,
expires_in_days: expiresInDays
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create token');
}
const data = await response.json();
// Add to local list
tokensData.unshift({
id: data.id,
name: data.name,
created_at: data.created_at,
last_used_at: data.last_used_at,
expires_at: data.expires_at,
revoked: data.revoked
});
renderTokensList();
// Close create modal and show secret modal
closeCreateTokenModal();
showTokenSecretModal(data.token, data.name, data.expires_at);
showToast('API token created successfully', 'success');
} catch (error) {
console.error('Error creating token:', error);
showToast('Failed to create token: ' + error.message, 'error');
}
});
// Make revokeToken globally accessible for inline onclick handlers
window.revokeToken = async function(tokenId, tokenName) {
if (!confirm(`Are you sure you want to revoke the token "${tokenName}"? This action cannot be undone and any applications using this token will lose access.`)) {
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/tokens/${tokenId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to revoke token');
}
// Remove from local list
tokensData = tokensData.filter(t => t.id !== tokenId);
renderTokensList();
showToast('Token revoked successfully', 'success');
} catch (error) {
console.error('Error revoking token:', error);
showToast('Failed to revoke token: ' + error.message, 'error');
}
};
// Template Management Functions
let templates = [];
let currentTemplate = null;
let isEditingTemplate = false;
async function loadTemplates() {
try {
const response = await fetch('/api/transcript-templates');
if (!response.ok) {
throw new Error('Failed to load templates');
}
templates = await response.json();
// Create default templates if none exist
if (templates.length === 0) {
document.getElementById('createDefaultTemplatesBtn').style.display = 'block';
} else {
document.getElementById('createDefaultTemplatesBtn').style.display = 'none';
}
renderTemplatesList();
} catch (error) {
console.error('Error loading templates:', error);
showToast('Failed to load templates', 'error');
}
}
function renderTemplatesList() {
const templatesList = document.getElementById('templatesList');
templatesList.innerHTML = '';
if (templates.length === 0) {
templatesList.innerHTML = '<p class="text-sm text-[var(--text-muted)] text-center py-4">No templates yet</p>';
return;
}
templates.forEach(template => {
const templateItem = document.createElement('div');
templateItem.className = 'p-3 bg-[var(--bg-primary)] rounded-md hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors border border-[var(--border-primary)]';
if (template.is_default) {
templateItem.className += ' ring-2 ring-[var(--ring-focus)]';
}
templateItem.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="text-sm font-medium text-[var(--text-primary)]">${template.name}</h4>
${template.description ? `<p class="text-xs text-[var(--text-muted)] mt-1">${template.description}</p>` : ''}
${template.is_default ? `<span class="text-xs text-[var(--text-accent)] mt-1"><i class="fas fa-star mr-1"></i>${t('transcriptTemplates.default')}</span>` : ''}
</div>
</div>
`;
templateItem.addEventListener('click', () => selectTemplate(template));
templatesList.appendChild(templateItem);
});
}
function selectTemplate(template) {
currentTemplate = template;
isEditingTemplate = true;
// Show editor, hide empty state
document.getElementById('templateEditor').classList.remove('hidden');
document.getElementById('templateEmptyState').classList.add('hidden');
// Populate form
document.getElementById('templateName').value = template.name;
document.getElementById('templateDescription').value = template.description || '';
document.getElementById('templateContent').value = template.template;
document.getElementById('templateIsDefault').checked = template.is_default;
// Show delete button
document.getElementById('deleteTemplateBtn').classList.remove('hidden');
}
function createNewTemplate() {
currentTemplate = null;
isEditingTemplate = false;
// Show editor, hide empty state
document.getElementById('templateEditor').classList.remove('hidden');
document.getElementById('templateEmptyState').classList.add('hidden');
// Clear form
document.getElementById('templateForm').reset();
// Hide delete button
document.getElementById('deleteTemplateBtn').classList.add('hidden');
}
document.getElementById('createTemplateBtn')?.addEventListener('click', createNewTemplate);
document.getElementById('cancelTemplateBtn')?.addEventListener('click', () => {
// Hide editor, show empty state
document.getElementById('templateEditor').classList.add('hidden');
document.getElementById('templateEmptyState').classList.remove('hidden');
currentTemplate = null;
isEditingTemplate = false;
});
document.getElementById('templateForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const templateData = {
name: document.getElementById('templateName').value,
description: document.getElementById('templateDescription').value,
template: document.getElementById('templateContent').value,
is_default: document.getElementById('templateIsDefault').checked
};
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const url = isEditingTemplate ? `/api/transcript-templates/${currentTemplate.id}` : '/api/transcript-templates';
const method = isEditingTemplate ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(templateData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save template');
}
const savedTemplate = await response.json();
if (isEditingTemplate) {
// Update existing template
const index = templates.findIndex(t => t.id === savedTemplate.id);
if (index !== -1) {
templates[index] = savedTemplate;
}
} else {
// Add new template
templates.push(savedTemplate);
}
// Update default status for other templates if needed
if (savedTemplate.is_default) {
templates.forEach(t => {
if (t.id !== savedTemplate.id) {
t.is_default = false;
}
});
}
renderTemplatesList();
showToast(isEditingTemplate ? 'Template updated successfully' : 'Template created successfully', 'success');
// Hide editor
document.getElementById('templateEditor').classList.add('hidden');
document.getElementById('templateEmptyState').classList.remove('hidden');
currentTemplate = null;
isEditingTemplate = false;
} catch (error) {
console.error('Error saving template:', error);
showToast(error.message || 'Failed to save template', 'error');
}
});
document.getElementById('deleteTemplateBtn')?.addEventListener('click', async () => {
if (!currentTemplate || !confirm('Are you sure you want to delete this template?')) {
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/transcript-templates/${currentTemplate.id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
throw new Error('Failed to delete template');
}
// Remove from local array
templates = templates.filter(t => t.id !== currentTemplate.id);
renderTemplatesList();
// Hide editor
document.getElementById('templateEditor').classList.add('hidden');
document.getElementById('templateEmptyState').classList.remove('hidden');
currentTemplate = null;
isEditingTemplate = false;
showToast('Template deleted successfully', 'success');
} catch (error) {
console.error('Error deleting template:', error);
showToast('Failed to delete template', 'error');
}
});
document.getElementById('createDefaultTemplatesBtn')?.addEventListener('click', async () => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/transcript-templates/create-defaults', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
throw new Error('Failed to create default templates');
}
const result = await response.json();
templates = result.templates;
renderTemplatesList();
showToast('Default templates created successfully', 'success');
} catch (error) {
console.error('Error creating default templates:', error);
showToast('Failed to create default templates', 'error');
}
});
// ============================================
// Templates Sub-tabs (Transcript / Export / Naming)
// ============================================
const subtabTranscript = document.getElementById('subtab-transcript');
const subtabExport = document.getElementById('subtab-export');
const subtabNaming = document.getElementById('subtab-naming');
const subcontentTranscript = document.getElementById('subcontent-transcript');
const subcontentExport = document.getElementById('subcontent-export');
const subcontentNaming = document.getElementById('subcontent-naming');
function switchTemplateSubtab(tab) {
// Reset all tabs
[subtabTranscript, subtabExport, subtabNaming].forEach(t => {
if (t) {
t.classList.remove('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
t.classList.add('border-transparent', 'text-[var(--text-muted)]');
}
});
// Hide all content
[subcontentTranscript, subcontentExport, subcontentNaming].forEach(c => {
if (c) c.classList.add('hidden');
});
if (tab === 'transcript') {
subtabTranscript?.classList.add('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
subtabTranscript?.classList.remove('border-transparent', 'text-[var(--text-muted)]');
subcontentTranscript?.classList.remove('hidden');
} else if (tab === 'export') {
subtabExport?.classList.add('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
subtabExport?.classList.remove('border-transparent', 'text-[var(--text-muted)]');
subcontentExport?.classList.remove('hidden');
loadExportTemplates();
} else if (tab === 'naming') {
subtabNaming?.classList.add('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
subtabNaming?.classList.remove('border-transparent', 'text-[var(--text-muted)]');
subcontentNaming?.classList.remove('hidden');
loadNamingTemplates();
}
}
subtabTranscript?.addEventListener('click', () => switchTemplateSubtab('transcript'));
subtabExport?.addEventListener('click', () => switchTemplateSubtab('export'));
subtabNaming?.addEventListener('click', () => switchTemplateSubtab('naming'));
// ============================================
// Copy Variable Button Handler
// ============================================
document.querySelectorAll('.copy-var-btn').forEach(btn => {
btn.addEventListener('click', async function(e) {
e.preventDefault();
const textToCopy = this.getAttribute('data-copy');
try {
await navigator.clipboard.writeText(textToCopy);
// Visual feedback - change icon temporarily
const icon = this.querySelector('i');
if (icon) {
icon.classList.remove('fa-copy');
icon.classList.add('fa-check');
icon.classList.remove('text-[var(--text-muted)]');
icon.classList.add('text-green-500');
setTimeout(() => {
icon.classList.remove('fa-check');
icon.classList.add('fa-copy');
icon.classList.remove('text-green-500');
icon.classList.add('text-[var(--text-muted)]');
}, 1000);
}
} catch (err) {
console.error('Failed to copy:', err);
}
});
});
// ============================================
// Export Templates Management
// ============================================
let exportTemplates = [];
let currentExportTemplate = null;
let isEditingExportTemplate = false;
let exportTemplatesLoaded = false;
async function loadExportTemplates() {
if (exportTemplatesLoaded) return;
try {
const response = await fetch('/api/export-templates');
if (!response.ok) {
throw new Error('Failed to load export templates');
}
exportTemplates = await response.json();
exportTemplatesLoaded = true;
// Show/hide create defaults button
if (exportTemplates.length === 0) {
document.getElementById('createDefaultExportTemplatesBtn').style.display = 'block';
} else {
document.getElementById('createDefaultExportTemplatesBtn').style.display = 'none';
}
renderExportTemplatesList();
} catch (error) {
console.error('Error loading export templates:', error);
showToast('Failed to load export templates', 'error');
}
}
function renderExportTemplatesList() {
const list = document.getElementById('exportTemplatesList');
if (!list) return;
list.textContent = '';
if (exportTemplates.length === 0) {
const emptyMsg = document.createElement('p');
emptyMsg.className = 'text-sm text-[var(--text-muted)] text-center py-4';
emptyMsg.textContent = 'No templates yet';
list.appendChild(emptyMsg);
return;
}
exportTemplates.forEach(template => {
const item = document.createElement('div');
item.className = 'p-3 bg-[var(--bg-primary)] rounded-md hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors border border-[var(--border-primary)]';
if (template.is_default) {
item.className += ' ring-2 ring-[var(--ring-focus)]';
}
const wrapper = document.createElement('div');
wrapper.className = 'flex items-start justify-between';
const inner = document.createElement('div');
inner.className = 'flex-1';
const nameEl = document.createElement('h4');
nameEl.className = 'text-sm font-medium text-[var(--text-primary)]';
nameEl.textContent = template.name;
inner.appendChild(nameEl);
if (template.description) {
const descEl = document.createElement('p');
descEl.className = 'text-xs text-[var(--text-muted)] mt-1';
descEl.textContent = template.description;
inner.appendChild(descEl);
}
if (template.is_default) {
const defaultEl = document.createElement('span');
defaultEl.className = 'text-xs text-[var(--text-accent)] mt-1';
const starIcon = document.createElement('i');
starIcon.className = 'fas fa-star mr-1';
defaultEl.appendChild(starIcon);
defaultEl.appendChild(document.createTextNode(t('exportTemplates.default')));
inner.appendChild(defaultEl);
}
wrapper.appendChild(inner);
item.appendChild(wrapper);
item.addEventListener('click', () => selectExportTemplate(template));
list.appendChild(item);
});
}
function selectExportTemplate(template) {
currentExportTemplate = template;
isEditingExportTemplate = true;
// Show editor, hide empty state
document.getElementById('exportTemplateEditor').classList.remove('hidden');
document.getElementById('exportTemplateEmptyState').classList.add('hidden');
// Populate form
document.getElementById('exportTemplateName').value = template.name;
document.getElementById('exportTemplateDescription').value = template.description || '';
document.getElementById('exportTemplateContent').value = template.template;
document.getElementById('exportTemplateIsDefault').checked = template.is_default;
// Show delete button
document.getElementById('deleteExportTemplateBtn').classList.remove('hidden');
}
function createNewExportTemplate() {
currentExportTemplate = null;
isEditingExportTemplate = false;
// Show editor, hide empty state
document.getElementById('exportTemplateEditor').classList.remove('hidden');
document.getElementById('exportTemplateEmptyState').classList.add('hidden');
// Clear form
document.getElementById('exportTemplateForm').reset();
// Hide delete button
document.getElementById('deleteExportTemplateBtn').classList.add('hidden');
}
document.getElementById('createExportTemplateBtn')?.addEventListener('click', createNewExportTemplate);
document.getElementById('cancelExportTemplateBtn')?.addEventListener('click', () => {
document.getElementById('exportTemplateEditor').classList.add('hidden');
document.getElementById('exportTemplateEmptyState').classList.remove('hidden');
currentExportTemplate = null;
isEditingExportTemplate = false;
});
document.getElementById('exportTemplateForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const templateData = {
name: document.getElementById('exportTemplateName').value,
description: document.getElementById('exportTemplateDescription').value,
template: document.getElementById('exportTemplateContent').value,
is_default: document.getElementById('exportTemplateIsDefault').checked
};
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const url = isEditingExportTemplate ? `/api/export-templates/${currentExportTemplate.id}` : '/api/export-templates';
const method = isEditingExportTemplate ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(templateData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save export template');
}
const savedTemplate = await response.json();
if (isEditingExportTemplate) {
const index = exportTemplates.findIndex(t => t.id === savedTemplate.id);
if (index !== -1) {
exportTemplates[index] = savedTemplate;
}
} else {
exportTemplates.push(savedTemplate);
}
// Update default status for other templates if needed
if (savedTemplate.is_default) {
exportTemplates.forEach(t => {
if (t.id !== savedTemplate.id) {
t.is_default = false;
}
});
}
renderExportTemplatesList();
showToast(isEditingExportTemplate ? 'Export template updated successfully' : 'Export template created successfully', 'success');
document.getElementById('exportTemplateEditor').classList.add('hidden');
document.getElementById('exportTemplateEmptyState').classList.remove('hidden');
currentExportTemplate = null;
isEditingExportTemplate = false;
} catch (error) {
console.error('Error saving export template:', error);
showToast(error.message || 'Failed to save export template', 'error');
}
});
document.getElementById('deleteExportTemplateBtn')?.addEventListener('click', async () => {
if (!currentExportTemplate || !confirm('Are you sure you want to delete this export template?')) {
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/export-templates/${currentExportTemplate.id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
throw new Error('Failed to delete export template');
}
exportTemplates = exportTemplates.filter(t => t.id !== currentExportTemplate.id);
renderExportTemplatesList();
document.getElementById('exportTemplateEditor').classList.add('hidden');
document.getElementById('exportTemplateEmptyState').classList.remove('hidden');
currentExportTemplate = null;
isEditingExportTemplate = false;
showToast('Export template deleted successfully', 'success');
} catch (error) {
console.error('Error deleting export template:', error);
showToast('Failed to delete export template', 'error');
}
});
document.getElementById('createDefaultExportTemplatesBtn')?.addEventListener('click', async () => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/export-templates/create-defaults', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
throw new Error('Failed to create default export template');
}
const result = await response.json();
exportTemplates = result.templates;
renderExportTemplatesList();
document.getElementById('createDefaultExportTemplatesBtn').style.display = 'none';
showToast('Default export template created successfully', 'success');
} catch (error) {
console.error('Error creating default export template:', error);
showToast('Failed to create default export template', 'error');
}
});
// ============================================
// Naming Templates Management
// ============================================
let namingTemplates = [];
let currentNamingTemplate = null;
let isEditingNamingTemplate = false;
let userDefaultNamingTemplateId = null;
async function loadNamingTemplates() {
try {
const [templatesRes, defaultRes] = await Promise.all([
fetch('/api/naming-templates'),
fetch('/api/naming-templates/default')
]);
if (!templatesRes.ok) {
throw new Error('Failed to load naming templates');
}
namingTemplates = await templatesRes.json();
if (defaultRes.ok) {
const defaultData = await defaultRes.json();
userDefaultNamingTemplateId = defaultData.default_naming_template_id;
}
// Show/hide create defaults button
if (namingTemplates.length === 0) {
document.getElementById('createDefaultNamingTemplatesBtn').style.display = 'block';
} else {
document.getElementById('createDefaultNamingTemplatesBtn').style.display = 'none';
}
renderNamingTemplatesList();
updateUserDefaultSelect();
} catch (error) {
console.error('Error loading naming templates:', error);
showToast('Failed to load naming templates', 'error');
}
}
function renderNamingTemplatesList() {
const list = document.getElementById('namingTemplatesList');
list.innerHTML = '';
if (namingTemplates.length === 0) {
list.innerHTML = '<p class="text-sm text-[var(--text-muted)] text-center py-4">No templates yet</p>';
return;
}
namingTemplates.forEach(template => {
const item = document.createElement('div');
item.className = 'p-3 bg-[var(--bg-primary)] rounded-md hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors border border-[var(--border-primary)]';
if (userDefaultNamingTemplateId === template.id) {
item.className += ' ring-2 ring-[var(--ring-focus)]';
}
const nameEl = document.createElement('h4');
nameEl.className = 'text-sm font-medium text-[var(--text-primary)]';
nameEl.textContent = template.name;
const templateEl = document.createElement('p');
templateEl.className = 'text-xs text-[var(--text-muted)] mt-1 font-mono';
templateEl.textContent = template.template;
const wrapper = document.createElement('div');
wrapper.className = 'flex items-start justify-between';
const inner = document.createElement('div');
inner.className = 'flex-1';
inner.appendChild(nameEl);
inner.appendChild(templateEl);
if (template.description) {
const descEl = document.createElement('p');
descEl.className = 'text-xs text-[var(--text-muted)] mt-1';
descEl.textContent = template.description;
inner.appendChild(descEl);
}
if (userDefaultNamingTemplateId === template.id) {
const defaultEl = document.createElement('span');
defaultEl.className = 'text-xs text-[var(--text-accent)] mt-1';
defaultEl.innerHTML = '<i class="fas fa-star mr-1"></i>Default';
inner.appendChild(defaultEl);
}
wrapper.appendChild(inner);
item.appendChild(wrapper);
item.addEventListener('click', () => selectNamingTemplate(template));
list.appendChild(item);
});
}
function updateUserDefaultSelect() {
const select = document.getElementById('userDefaultNamingTemplate');
select.innerHTML = '<option value="">No default (AI title only)</option>';
namingTemplates.forEach(template => {
const option = document.createElement('option');
option.value = template.id;
option.textContent = template.name;
if (userDefaultNamingTemplateId === template.id) {
option.selected = true;
}
select.appendChild(option);
});
}
document.getElementById('userDefaultNamingTemplate')?.addEventListener('change', async (e) => {
const templateId = e.target.value ? parseInt(e.target.value) : null;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/naming-templates/default', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ template_id: templateId })
});
if (!response.ok) {
throw new Error('Failed to update default template');
}
userDefaultNamingTemplateId = templateId;
renderNamingTemplatesList();
showToast('Default naming template updated', 'success');
} catch (error) {
console.error('Error updating default template:', error);
showToast('Failed to update default template', 'error');
}
});
function selectNamingTemplate(template) {
currentNamingTemplate = template;
isEditingNamingTemplate = true;
document.getElementById('namingTemplateEditor').classList.remove('hidden');
document.getElementById('namingTemplateEmptyState').classList.add('hidden');
document.getElementById('namingTemplateName').value = template.name;
document.getElementById('namingTemplateDescription').value = template.description || '';
document.getElementById('namingTemplateContent').value = template.template;
// Populate regex patterns
renderRegexPatterns(template.regex_patterns || {});
document.getElementById('deleteNamingTemplateBtn').classList.remove('hidden');
}
function createNewNamingTemplate() {
currentNamingTemplate = null;
isEditingNamingTemplate = false;
document.getElementById('namingTemplateEditor').classList.remove('hidden');
document.getElementById('namingTemplateEmptyState').classList.add('hidden');
document.getElementById('namingTemplateForm').reset();
renderRegexPatterns({});
document.getElementById('deleteNamingTemplateBtn').classList.add('hidden');
}
function renderRegexPatterns(patterns) {
const container = document.getElementById('regexPatternsContainer');
container.innerHTML = '';
Object.entries(patterns).forEach(([varName, pattern]) => {
addRegexPatternRow(varName, pattern);
});
}
function addRegexPatternRow(varName = '', pattern = '') {
const container = document.getElementById('regexPatternsContainer');
const row = document.createElement('div');
row.className = 'flex gap-2 items-center';
const varInput = document.createElement('input');
varInput.type = 'text';
varInput.placeholder = 'Variable name';
varInput.value = varName;
varInput.className = 'flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md regex-var-name';
const patternInput = document.createElement('input');
patternInput.type = 'text';
patternInput.placeholder = 'Regex pattern';
patternInput.value = pattern;
patternInput.className = 'flex-2 px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md regex-pattern font-mono';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'text-[var(--text-danger)] hover:text-[var(--text-danger-hover)] remove-regex-btn';
removeBtn.innerHTML = '<i class="fas fa-times"></i>';
removeBtn.addEventListener('click', () => row.remove());
row.appendChild(varInput);
row.appendChild(patternInput);
row.appendChild(removeBtn);
container.appendChild(row);
}
document.getElementById('addRegexPatternBtn')?.addEventListener('click', () => addRegexPatternRow());
document.getElementById('createNamingTemplateBtn')?.addEventListener('click', createNewNamingTemplate);
document.getElementById('cancelNamingTemplateBtn')?.addEventListener('click', () => {
document.getElementById('namingTemplateEditor').classList.add('hidden');
document.getElementById('namingTemplateEmptyState').classList.remove('hidden');
currentNamingTemplate = null;
isEditingNamingTemplate = false;
});
document.getElementById('namingTemplateForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
// Collect regex patterns
const regexPatterns = {};
document.querySelectorAll('#regexPatternsContainer > div').forEach(row => {
const varName = row.querySelector('.regex-var-name').value.trim();
const pattern = row.querySelector('.regex-pattern').value.trim();
if (varName && pattern) {
regexPatterns[varName] = pattern;
}
});
const templateData = {
name: document.getElementById('namingTemplateName').value,
description: document.getElementById('namingTemplateDescription').value,
template: document.getElementById('namingTemplateContent').value,
regex_patterns: regexPatterns
};
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const url = isEditingNamingTemplate ? `/api/naming-templates/${currentNamingTemplate.id}` : '/api/naming-templates';
const method = isEditingNamingTemplate ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(templateData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save naming template');
}
const savedTemplate = await response.json();
if (isEditingNamingTemplate) {
const index = namingTemplates.findIndex(t => t.id === savedTemplate.id);
if (index !== -1) {
namingTemplates[index] = savedTemplate;
}
} else {
namingTemplates.push(savedTemplate);
}
renderNamingTemplatesList();
updateUserDefaultSelect();
showToast(isEditingNamingTemplate ? 'Naming template updated' : 'Naming template created', 'success');
document.getElementById('namingTemplateEditor').classList.add('hidden');
document.getElementById('namingTemplateEmptyState').classList.remove('hidden');
currentNamingTemplate = null;
isEditingNamingTemplate = false;
} catch (error) {
console.error('Error saving naming template:', error);
showToast(error.message || 'Failed to save naming template', 'error');
}
});
document.getElementById('deleteNamingTemplateBtn')?.addEventListener('click', async () => {
if (!currentNamingTemplate || !confirm('Are you sure you want to delete this naming template?')) {
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/naming-templates/${currentNamingTemplate.id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete naming template');
}
namingTemplates = namingTemplates.filter(t => t.id !== currentNamingTemplate.id);
if (userDefaultNamingTemplateId === currentNamingTemplate.id) {
userDefaultNamingTemplateId = null;
}
renderNamingTemplatesList();
updateUserDefaultSelect();
document.getElementById('namingTemplateEditor').classList.add('hidden');
document.getElementById('namingTemplateEmptyState').classList.remove('hidden');
currentNamingTemplate = null;
isEditingNamingTemplate = false;
showToast('Naming template deleted', 'success');
} catch (error) {
console.error('Error deleting naming template:', error);
showToast(error.message || 'Failed to delete naming template', 'error');
}
});
document.getElementById('createDefaultNamingTemplatesBtn')?.addEventListener('click', async () => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/naming-templates/create-defaults', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
throw new Error('Failed to create default naming templates');
}
const result = await response.json();
namingTemplates = result.templates;
renderNamingTemplatesList();
updateUserDefaultSelect();
showToast('Default naming templates created', 'success');
} catch (error) {
console.error('Error creating default naming templates:', error);
showToast('Failed to create default naming templates', 'error');
}
});
document.getElementById('testNamingTemplateBtn')?.addEventListener('click', async () => {
const templateId = currentNamingTemplate?.id;
const testFilename = document.getElementById('testFilename').value || 'sample-file.mp3';
if (!templateId) {
showToast('Save the template first to test it', 'info');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/naming-templates/${templateId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ filename: testFilename, ai_title: 'Sample AI Title' })
});
if (!response.ok) {
throw new Error('Failed to test template');
}
const result = await response.json();
document.getElementById('testResult').classList.remove('hidden');
document.getElementById('testResultText').textContent = result.result;
} catch (error) {
console.error('Error testing template:', error);
showToast('Failed to test template', 'error');
}
});
function showToast(message, type = 'info') {
// Simple toast notification
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-4 py-2 rounded-md text-white z-50 ${
type === 'success' ? 'bg-green-600' :
type === 'error' ? 'bg-red-600' :
'bg-blue-600'
}`;
toast.style.cursor = 'pointer';
toast.textContent = message;
document.body.appendChild(toast);
// Function to dismiss the toast
const dismissToast = () => {
if (toast.parentNode) {
toast.remove();
}
};
// Add click handler to dismiss toast
toast.addEventListener('click', () => {
clearTimeout(timeoutId);
dismissToast();
});
// Auto-dismiss after 3 seconds
const timeoutId = setTimeout(dismissToast, 3000);
}
});
</script>
</body>
</html>