7208 lines
417 KiB
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>© {{ now.year }} InnovA AI · 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>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
|
</footer>
|
|
</div>
|
|
|
|
<!-- 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">×</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">×</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">×</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">×</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">×</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 & 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 & 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">×</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 & 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 & 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">×</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>
|