Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
61
static/js/modules/utils/api.js
Normal file
61
static/js/modules/utils/api.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* API utility functions with CSRF token handling
|
||||
*/
|
||||
|
||||
export const createApiClient = (csrfToken) => {
|
||||
const getHeaders = (contentType = 'application/json') => {
|
||||
const headers = {
|
||||
'X-CSRFToken': csrfToken.value
|
||||
};
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
return {
|
||||
get: async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
post: async (url, data = {}) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
postFormData: async (url, formData) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken.value
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
delete: async (url) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
put: async (url, data = {}) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response;
|
||||
}
|
||||
};
|
||||
};
|
||||
50
static/js/modules/utils/colors.js
Normal file
50
static/js/modules/utils/colors.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Color utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate the relative luminance of a color
|
||||
* Based on WCAG contrast ratio formula
|
||||
* @param {string} hexColor - Hex color code (e.g., "#RRGGBB" or "#RGB")
|
||||
* @returns {number} Luminance value between 0 and 1
|
||||
*/
|
||||
function calculateLuminance(hexColor) {
|
||||
// Remove # if present
|
||||
const hex = hexColor.replace('#', '');
|
||||
|
||||
// Convert 3-digit hex to 6-digit
|
||||
const fullHex = hex.length === 3
|
||||
? hex.split('').map(char => char + char).join('')
|
||||
: hex;
|
||||
|
||||
// Parse RGB values
|
||||
const r = parseInt(fullHex.substr(0, 2), 16) / 255;
|
||||
const g = parseInt(fullHex.substr(2, 2), 16) / 255;
|
||||
const b = parseInt(fullHex.substr(4, 2), 16) / 255;
|
||||
|
||||
// Calculate relative luminance using simplified formula
|
||||
// (More accurate would use gamma correction, but this is sufficient)
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate text color (black or white) for a given background color
|
||||
* Ensures readable contrast based on background luminance
|
||||
* @param {string} bgColor - Background color in hex format
|
||||
* @returns {string} Either 'white' or 'black'
|
||||
*/
|
||||
export function getContrastTextColor(bgColor) {
|
||||
if (!bgColor) {
|
||||
return 'white'; // Default to white for undefined colors
|
||||
}
|
||||
|
||||
try {
|
||||
const luminance = calculateLuminance(bgColor);
|
||||
// Threshold of 0.65: only very light backgrounds get black text
|
||||
// This ensures medium/dark colors like greens, blues still get white text
|
||||
return luminance > 0.65 ? 'black' : 'white';
|
||||
} catch (e) {
|
||||
console.warn('Failed to calculate contrast color for:', bgColor, e);
|
||||
return 'white'; // Fallback to white
|
||||
}
|
||||
}
|
||||
67
static/js/modules/utils/dates.js
Normal file
67
static/js/modules/utils/dates.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Date comparison utility functions
|
||||
*/
|
||||
|
||||
export const isSameDay = (date1, date2) => {
|
||||
return date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate();
|
||||
};
|
||||
|
||||
export const isToday = (date) => {
|
||||
const today = new Date();
|
||||
return isSameDay(date, today);
|
||||
};
|
||||
|
||||
export const isYesterday = (date) => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return isSameDay(date, yesterday);
|
||||
};
|
||||
|
||||
export const isThisWeek = (date) => {
|
||||
const now = new Date();
|
||||
const startOfWeek = new Date(now);
|
||||
const day = now.getDay();
|
||||
const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
|
||||
startOfWeek.setDate(diff);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
return date >= startOfWeek && date <= endOfWeek;
|
||||
};
|
||||
|
||||
export const isLastWeek = (date) => {
|
||||
const now = new Date();
|
||||
const startOfLastWeek = new Date(now);
|
||||
const day = now.getDay();
|
||||
const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
|
||||
startOfLastWeek.setDate(diff);
|
||||
startOfLastWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfLastWeek = new Date(startOfLastWeek);
|
||||
endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
|
||||
endOfLastWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
return date >= startOfLastWeek && date <= endOfLastWeek;
|
||||
};
|
||||
|
||||
export const isThisMonth = (date) => {
|
||||
const now = new Date();
|
||||
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
|
||||
};
|
||||
|
||||
export const isLastMonth = (date) => {
|
||||
const now = new Date();
|
||||
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
return date.getFullYear() === lastMonth.getFullYear() && date.getMonth() === lastMonth.getMonth();
|
||||
};
|
||||
|
||||
export const getDateForSorting = (recording, sortBy) => {
|
||||
const dateStr = sortBy === 'meeting_date' ? recording.meeting_date : recording.created_at;
|
||||
if (!dateStr) return null;
|
||||
return new Date(dateStr);
|
||||
};
|
||||
271
static/js/modules/utils/errorDisplay.js
Normal file
271
static/js/modules/utils/errorDisplay.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Error Display Utility
|
||||
*
|
||||
* Parses and displays user-friendly error messages from the backend.
|
||||
* Handles both JSON-formatted errors (ERROR_JSON:...) and plain text errors.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a stored error message from the backend.
|
||||
* @param {string} text - The stored transcription/error text
|
||||
* @returns {Object|null} - Parsed error object or null if not an error
|
||||
*/
|
||||
export function parseStoredError(text) {
|
||||
if (!text) return null;
|
||||
|
||||
// Check for JSON-formatted error
|
||||
if (text.startsWith('ERROR_JSON:')) {
|
||||
try {
|
||||
const jsonStr = text.substring(11); // Remove 'ERROR_JSON:' prefix
|
||||
const data = JSON.parse(jsonStr);
|
||||
return {
|
||||
title: data.t || 'Error',
|
||||
message: data.m || 'An error occurred',
|
||||
guidance: data.g || '',
|
||||
icon: data.i || 'fa-exclamation-circle',
|
||||
type: data.y || 'unknown',
|
||||
isKnown: data.k || false,
|
||||
technical: data.d || '',
|
||||
isFormattedError: true
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to parse error JSON:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for legacy error format (starts with common error prefixes)
|
||||
const errorPrefixes = [
|
||||
'Transcription failed:',
|
||||
'Processing failed:',
|
||||
'ASR processing failed:',
|
||||
'Audio extraction failed:',
|
||||
'Error:'
|
||||
];
|
||||
|
||||
for (const prefix of errorPrefixes) {
|
||||
if (text.startsWith(prefix)) {
|
||||
// Parse the error using pattern matching
|
||||
return parseUnformattedError(text);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an unformatted error message and try to make it user-friendly.
|
||||
* @param {string} text - The raw error text
|
||||
* @returns {Object} - Parsed error object
|
||||
*/
|
||||
function parseUnformattedError(text) {
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// Known error patterns
|
||||
const patterns = [
|
||||
{
|
||||
patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'],
|
||||
title: 'File Too Large',
|
||||
message: 'The audio file exceeds the maximum size allowed by the transcription service.',
|
||||
guidance: 'Try enabling audio chunking in your settings, or compress the audio file before uploading.',
|
||||
icon: 'fa-file-audio',
|
||||
type: 'size_limit'
|
||||
},
|
||||
{
|
||||
patterns: ['timed out', 'timeout', 'deadline exceeded'],
|
||||
title: 'Processing Timeout',
|
||||
message: 'The transcription took too long to complete.',
|
||||
guidance: 'This can happen with very long recordings. Try splitting the audio into smaller parts.',
|
||||
icon: 'fa-clock',
|
||||
type: 'timeout'
|
||||
},
|
||||
{
|
||||
patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'],
|
||||
title: 'Authentication Error',
|
||||
message: 'The transcription service rejected the API credentials.',
|
||||
guidance: 'Please check that the API key is correct and has not expired.',
|
||||
icon: 'fa-key',
|
||||
type: 'auth'
|
||||
},
|
||||
{
|
||||
patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'],
|
||||
title: 'Rate Limit Exceeded',
|
||||
message: 'Too many requests were sent to the transcription service.',
|
||||
guidance: 'Please wait a few minutes and try reprocessing.',
|
||||
icon: 'fa-hourglass-half',
|
||||
type: 'rate_limit'
|
||||
},
|
||||
{
|
||||
patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'],
|
||||
title: 'Connection Error',
|
||||
message: 'Could not connect to the transcription service.',
|
||||
guidance: 'Check your internet connection and ensure the service is available.',
|
||||
icon: 'fa-wifi',
|
||||
type: 'connection'
|
||||
},
|
||||
{
|
||||
patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'],
|
||||
title: 'Service Unavailable',
|
||||
message: 'The transcription service is temporarily unavailable.',
|
||||
guidance: 'This is usually temporary. Please try again in a few minutes.',
|
||||
icon: 'fa-server',
|
||||
type: 'service_error'
|
||||
},
|
||||
{
|
||||
patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt', 'not valid audio'],
|
||||
title: 'Invalid Audio Format',
|
||||
message: 'The audio file format is not supported or the file may be corrupted.',
|
||||
guidance: 'Try converting the audio to MP3 or WAV format before uploading.',
|
||||
icon: 'fa-file-audio',
|
||||
type: 'format'
|
||||
},
|
||||
{
|
||||
patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'],
|
||||
title: 'Audio Extraction Failed',
|
||||
message: 'Could not extract audio from the uploaded file.',
|
||||
guidance: 'Try converting the file to a standard audio format (MP3, WAV) before uploading.',
|
||||
icon: 'fa-file-video',
|
||||
type: 'extraction'
|
||||
}
|
||||
];
|
||||
|
||||
// Check patterns
|
||||
for (const pattern of patterns) {
|
||||
for (const p of pattern.patterns) {
|
||||
if (lowerText.includes(p)) {
|
||||
return {
|
||||
title: pattern.title,
|
||||
message: pattern.message,
|
||||
guidance: pattern.guidance,
|
||||
icon: pattern.icon,
|
||||
type: pattern.type,
|
||||
isKnown: true,
|
||||
technical: text,
|
||||
isFormattedError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown error - clean it up as best we can
|
||||
let cleanMessage = text;
|
||||
for (const prefix of ['Transcription failed:', 'Processing failed:', 'Error:', 'ASR processing failed:']) {
|
||||
if (cleanMessage.startsWith(prefix)) {
|
||||
cleanMessage = cleanMessage.substring(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if (cleanMessage.length > 200) {
|
||||
cleanMessage = cleanMessage.substring(0, 200) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Processing Error',
|
||||
message: cleanMessage,
|
||||
guidance: 'If this error persists, try reprocessing the recording.',
|
||||
icon: 'fa-exclamation-circle',
|
||||
type: 'unknown',
|
||||
isKnown: false,
|
||||
technical: text,
|
||||
isFormattedError: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transcription text is actually an error message.
|
||||
* @param {string} text - The transcription text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isErrorMessage(text) {
|
||||
if (!text) return false;
|
||||
|
||||
if (text.startsWith('ERROR_JSON:')) return true;
|
||||
|
||||
const errorPrefixes = [
|
||||
'Transcription failed:',
|
||||
'Processing failed:',
|
||||
'ASR processing failed:',
|
||||
'Audio extraction failed:'
|
||||
];
|
||||
|
||||
return errorPrefixes.some(prefix => text.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for displaying an error nicely.
|
||||
* @param {Object} error - Parsed error object from parseStoredError
|
||||
* @param {boolean} showTechnical - Whether to show technical details
|
||||
* @returns {string} - HTML string
|
||||
*/
|
||||
export function generateErrorHTML(error, showTechnical = false) {
|
||||
if (!error) return '';
|
||||
|
||||
const typeColors = {
|
||||
size_limit: 'amber',
|
||||
timeout: 'orange',
|
||||
auth: 'red',
|
||||
rate_limit: 'yellow',
|
||||
connection: 'blue',
|
||||
service_error: 'purple',
|
||||
format: 'pink',
|
||||
extraction: 'indigo',
|
||||
billing: 'red',
|
||||
model: 'gray',
|
||||
unknown: 'gray'
|
||||
};
|
||||
|
||||
const color = typeColors[error.type] || 'gray';
|
||||
|
||||
let html = `
|
||||
<div class="error-display bg-${color}-500/10 border border-${color}-500/30 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-${color}-500/20 flex items-center justify-center">
|
||||
<i class="fas ${error.icon} text-${color}-500"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-${color}-600 dark:text-${color}-400 mb-1">
|
||||
${escapeHtml(error.title)}
|
||||
</h3>
|
||||
<p class="text-[var(--text-primary)] mb-2">
|
||||
${escapeHtml(error.message)}
|
||||
</p>
|
||||
${error.guidance ? `
|
||||
<div class="flex items-start gap-2 text-sm text-[var(--text-secondary)] bg-[var(--bg-tertiary)]/50 rounded p-2">
|
||||
<i class="fas fa-lightbulb text-yellow-500 mt-0.5"></i>
|
||||
<span>${escapeHtml(error.guidance)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${showTechnical && error.technical ? `
|
||||
<details class="mt-3 text-xs">
|
||||
<summary class="cursor-pointer text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre class="mt-2 p-2 bg-[var(--bg-tertiary)] rounded overflow-x-auto text-[var(--text-muted)]">${escapeHtml(error.technical)}</pre>
|
||||
</details>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Export for use in Vue components
|
||||
export default {
|
||||
parseStoredError,
|
||||
isErrorMessage,
|
||||
generateErrorHTML
|
||||
};
|
||||
139
static/js/modules/utils/formatters.js
Normal file
139
static/js/modules/utils/formatters.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Formatting utility functions
|
||||
*/
|
||||
|
||||
export const formatFileSize = (bytes) => {
|
||||
if (bytes == null || bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes < 0) bytes = 0;
|
||||
const i = bytes === 0 ? 0 : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
|
||||
const size = i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
|
||||
return size + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const formatDisplayDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
let date = new Date(dateString);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
date = new Date(dateString + 'T00:00:00');
|
||||
} else {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
} catch (e) {
|
||||
console.error("Error formatting date:", e);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatShortDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
let date = new Date(dateString);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
date = new Date(dateString + 'T00:00:00');
|
||||
} else {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const isCurrentYear = date.getFullYear() === now.getFullYear();
|
||||
|
||||
if (isCurrentYear) {
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} else {
|
||||
return date.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error formatting short date:", e);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatStatus = (status, t) => {
|
||||
if (!status || status === 'COMPLETED') return '';
|
||||
const statusMap = {
|
||||
'PENDING': t('status.queued'),
|
||||
'QUEUED': t('status.queued'),
|
||||
'PROCESSING': t('status.processing'),
|
||||
'TRANSCRIBING': t('status.transcribing'),
|
||||
'SUMMARIZING': t('status.summarizing'),
|
||||
'FAILED': t('status.failed'),
|
||||
'UPLOADING': t('status.uploading')
|
||||
};
|
||||
return statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
export const getStatusClass = (status) => {
|
||||
switch(status) {
|
||||
case 'PENDING': return 'status-pending';
|
||||
case 'QUEUED': return 'status-pending';
|
||||
case 'PROCESSING': return 'status-processing';
|
||||
case 'SUMMARIZING': return 'status-summarizing';
|
||||
case 'COMPLETED': return '';
|
||||
case 'FAILED': return 'status-failed';
|
||||
default: return 'status-pending';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatTime = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const formatDuration = (totalSeconds) => {
|
||||
if (totalSeconds == null || totalSeconds < 0) return 'N/A';
|
||||
|
||||
if (totalSeconds < 1) {
|
||||
return `${totalSeconds.toFixed(2)} seconds`;
|
||||
}
|
||||
|
||||
totalSeconds = Math.round(totalSeconds);
|
||||
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds} sec`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
let parts = [];
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours} hr`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes} min`);
|
||||
}
|
||||
if (hours === 0 && seconds > 0) {
|
||||
parts.push(`${seconds} sec`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
export const formatProcessingDuration = (seconds) => {
|
||||
if (!seconds && seconds !== 0) return null;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
};
|
||||
9
static/js/modules/utils/index.js
Normal file
9
static/js/modules/utils/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Utils module exports
|
||||
*/
|
||||
|
||||
export * from './formatters.js';
|
||||
export * from './dates.js';
|
||||
export { createApiClient } from './api.js';
|
||||
export { showToast } from './toast.js';
|
||||
export { getContrastTextColor } from './colors.js';
|
||||
91
static/js/modules/utils/toast.js
Normal file
91
static/js/modules/utils/toast.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Toast notification utility
|
||||
*/
|
||||
|
||||
export const showToast = (message, iconClass = 'fa-info-circle', duration = 3000, type = 'info') => {
|
||||
const container = document.getElementById('toastContainer');
|
||||
if (!container) {
|
||||
console.warn('Toast container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine colors and styles based on type
|
||||
let bgColor, textColor, iconColor, borderColor;
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
bgColor = '#10b981'; // green-500
|
||||
textColor = '#ffffff';
|
||||
iconColor = '#ffffff';
|
||||
borderColor = '#059669'; // green-600
|
||||
break;
|
||||
case 'error':
|
||||
bgColor = '#ef4444'; // red-500
|
||||
textColor = '#ffffff';
|
||||
iconColor = '#ffffff';
|
||||
borderColor = '#dc2626'; // red-600
|
||||
break;
|
||||
case 'warning':
|
||||
bgColor = '#f59e0b'; // amber-500
|
||||
textColor = '#ffffff';
|
||||
iconColor = '#ffffff';
|
||||
borderColor = '#d97706'; // amber-600
|
||||
break;
|
||||
case 'info':
|
||||
default:
|
||||
bgColor = '#3b82f6'; // blue-500
|
||||
textColor = '#ffffff';
|
||||
iconColor = '#ffffff';
|
||||
borderColor = '#2563eb'; // blue-600
|
||||
break;
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-message px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 opacity-0 min-w-[300px]';
|
||||
toast.style.backgroundColor = bgColor;
|
||||
toast.style.color = textColor;
|
||||
toast.style.border = `1px solid ${borderColor}`;
|
||||
|
||||
// Handle icon class - support both old format (just icon name) and new format (full class)
|
||||
let fullIconClass = iconClass;
|
||||
if (!iconClass.includes(' ')) {
|
||||
// Old format: just the icon name like 'fa-check-circle'
|
||||
fullIconClass = `fas ${iconClass}`;
|
||||
}
|
||||
|
||||
toast.innerHTML = `
|
||||
<i class="${fullIconClass}" style="color: ${iconColor}"></i>
|
||||
<span class="flex-1">${message}</span>
|
||||
`;
|
||||
|
||||
// Make toast clickable to dismiss
|
||||
toast.style.cursor = 'pointer';
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger fly-in animation
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.remove('opacity-0');
|
||||
toast.classList.add('opacity-100', 'toast-show');
|
||||
});
|
||||
|
||||
// Function to dismiss the toast
|
||||
const dismissToast = () => {
|
||||
toast.classList.remove('opacity-100', 'toast-show');
|
||||
toast.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Add click handler to dismiss toast
|
||||
toast.addEventListener('click', () => {
|
||||
clearTimeout(timeoutId);
|
||||
dismissToast();
|
||||
});
|
||||
|
||||
// Auto-dismiss after duration
|
||||
const timeoutId = setTimeout(dismissToast, duration);
|
||||
};
|
||||
Reference in New Issue
Block a user