Compare commits

...
6 Commits
16 changed files with 974 additions and 48 deletions

No files matched your search

+30 -3
View File
@@ -950,6 +950,7 @@ body {
position: relative;
overflow: visible !important;
}
.queue-tooltip {
visibility: hidden;
opacity: 0;
@@ -970,6 +971,7 @@ body {
transition: opacity 0.05s ease, visibility 0.05s ease;
pointer-events: none;
}
.queue-cell:hover .queue-tooltip {
visibility: visible;
opacity: 1;
@@ -2477,10 +2479,12 @@ body {
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.6);
}
50% {
box-shadow: 0 0 30px rgba(239, 68, 68, 0.8), inset 0 0 30px rgba(239, 68, 68, 0.4);
border-color: rgba(239, 68, 68, 1);
}
100% {
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.6);
@@ -2502,11 +2506,13 @@ body {
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
border-color: rgba(16, 112, 202, 0.5);
}
50% {
box-shadow: 0 0 15px rgba(16, 112, 202, 0.8), inset 0 0 5px rgba(16, 112, 202, 0.3);
border-color: rgba(16, 112, 202, 0.9);
background: rgba(16, 112, 202, 0.15);
}
100% {
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
border-color: rgba(16, 112, 202, 0.5);
@@ -2548,11 +2554,13 @@ body {
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
border-color: rgba(239, 68, 68, 0.6);
}
50% {
box-shadow: 0 0 15px rgba(239, 68, 68, 0.8), inset 0 0 5px rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.9);
background: rgba(239, 68, 68, 0.15) !important;
}
100% {
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
border-color: rgba(239, 68, 68, 0.6);
@@ -2571,11 +2579,13 @@ body {
/* ---- Ticket Tab Bar System ---- */
.tabs-bar {
display: flex;
flex-wrap: wrap; /* Wraps to new line if too many tabs */
flex-wrap: wrap;
/* Wraps to new line if too many tabs */
align-items: stretch;
background: var(--bg-secondary);
padding: 0;
}
.tab-item {
display: inline-flex;
align-items: center;
@@ -2584,7 +2594,8 @@ body {
background: transparent;
border: none;
border-right: 1px solid var(--border-subtle);
border-radius: 0 !important; /* Square corners */
border-radius: 0 !important;
/* Square corners */
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
@@ -2593,15 +2604,19 @@ body {
transition: all var(--transition-fast);
margin: 0 !important;
}
.tab-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.tab-item.active {
background: var(--bg-primary); /* Blend with main content background */
background: var(--bg-primary);
/* Blend with main content background */
color: var(--accent-primary);
border-bottom: 2px solid var(--accent-primary);
}
.tab-item .tab-close {
background: none;
border: none;
@@ -2613,6 +2628,7 @@ body {
opacity: 0.7;
transition: all 0.15s;
}
.tab-item .tab-close:hover {
opacity: 1;
color: var(--error);
@@ -2622,14 +2638,17 @@ body {
#view-container.ticket-view-active {
padding: 0 !important;
}
#view-container.ticket-view-active .back-link {
margin: var(--space-md) var(--space-xl) var(--space-xs);
display: inline-flex;
}
#view-container.ticket-view-active .ticket-detail {
border-radius: 0 !important;
border: none !important;
}
#view-container.ticket-view-active .ticket-detail .card {
border-radius: 0 !important;
border-left: none !important;
@@ -2651,6 +2670,7 @@ body {
gap: 2px;
background: var(--bg-primary);
}
.multiselect-item {
padding: 6px 10px;
border-radius: var(--radius-sm);
@@ -2660,14 +2680,17 @@ body {
transition: background-color 0.15s, color 0.15s;
user-select: none;
}
.multiselect-item:hover {
background-color: var(--bg-hover, rgba(0, 0, 0, 0.05));
color: var(--text-primary);
}
.multiselect-item.selected {
background-color: var(--accent-primary);
color: white;
}
.theme-dark .multiselect-item:hover,
.theme-rosso .multiselect-item:hover,
.theme-naturale .multiselect-item:hover,
@@ -2676,6 +2699,7 @@ body {
.theme-fairytale .multiselect-item:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.line-config-row {
display: flex;
justify-content: space-between;
@@ -2686,14 +2710,17 @@ body {
border-radius: var(--radius-md);
transition: border-color var(--transition-fast);
}
.line-config-row:hover {
border-color: var(--border-light);
}
.line-config-row .line-name {
font-weight: 500;
font-size: 0.9rem;
color: var(--text-primary);
}
.line-config-row .line-actions {
display: flex;
gap: 6px;
+5 -5
View File
@@ -11,7 +11,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.snow.css" rel="stylesheet">
<link href="/vendor/quill.snow.css" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
@@ -258,10 +258,10 @@
<div class="toast-container" id="toast-container"></div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
<script src="/vendor/quill.js"></script>
<script src="/vendor/chart.js"></script>
<script src="/vendor/pdf.min.js"></script>
<script src="/vendor/pdf-lib.min.js"></script>
<script src="/js/components/toast.js"></script>
<script src="/js/components/filters.js"></script>
<script src="/js/views/dashboard.js"></script>
+24 -2
View File
@@ -64,8 +64,10 @@ const App = {
create_ticket_open: { ctrl: true, alt: true, shift: false, key: 'n', enabled: true },
create_ticket_submit: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
quick_edit_save: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
send_email_submit: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
pdf_save_edited: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
ricerca_spudorata_open: { ctrl: true, alt: false, shift: true, key: 'f', enabled: true },
ticket_list_open: { ctrl: true, alt: false, shift: true, key: 'Backspace', enabled: true },
};
return defaults[id] || { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true };
},
@@ -126,6 +128,21 @@ const App = {
}
},
async assignAndOpenTab(id, tn, title) {
const activeAgentId = parseInt(localStorage.getItem('activeAgentId') || '1', 10);
try {
await this.api(`/api/tickets/${id}`, {
method: 'PATCH',
body: JSON.stringify({ user_id: activeAgentId }),
});
Toast.success('Ticket assegnato all\'agente attivo e aperto in scheda!');
} catch (err) {
console.error('Error assigning ticket:', err);
Toast.error('Errore durante l\'assegnazione del ticket: ' + err.message);
}
this.openTab(id, tn, title);
},
addTabWithoutRedirect(id, tn, title) {
const exists = this.tabs.find(t => String(t.id) === String(id));
if (!exists) {
@@ -262,7 +279,7 @@ const App = {
toggleClearBtn();
}
// Global keyboard shortcuts (e.g. Crea Nuovo Ticket, Ricerca Spudorata)
// Global keyboard shortcuts (e.g. Crea Nuovo Ticket, Ricerca Spudorata, Torna a Elenco Ticket)
document.addEventListener('keydown', (e) => {
if (this.isShortcutTriggered(e, 'create_ticket_open')) {
if (window.location.hash !== '#/tickets/new') {
@@ -280,8 +297,13 @@ const App = {
searchInput.select();
}
}
} else if (this.isShortcutTriggered(e, 'ticket_list_open')) {
if (window.location.hash !== '#/tickets' && window.location.hash !== '') {
e.preventDefault();
window.location.hash = '#/tickets';
}
});
}
}, true);
// Check DB connection
this.checkConnection();
+5 -5
View File
@@ -215,7 +215,7 @@ const Filters = {
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<input type="text" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<input type="text" id="state-ms-search-input" name="state_search" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.states || []).map(s => {
const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
@@ -243,7 +243,7 @@ const Filters = {
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="queue-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; min-width: 480px; width: max-content; max-width: 85vw; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<input type="text" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<input type="text" id="queue-ms-search-input" name="queue_search" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.queues || []).map(q => {
const selectedList = String(this.state.queue_id || '').split(',').filter(Boolean);
@@ -271,7 +271,7 @@ const Filters = {
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="priority-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 200px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<input type="text" class="form-input ms-search" placeholder="Cerca priorità..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<input type="text" id="priority-ms-search-input" name="priority_search" class="form-input ms-search" placeholder="Cerca priorità..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.priorities || []).map(p => {
const selectedList = String(this.state.priority_id || '').split(',').filter(Boolean);
@@ -299,7 +299,7 @@ const Filters = {
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="owner-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 240px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<input type="text" class="form-input ms-search" placeholder="Cerca owner..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<input type="text" id="owner-ms-search-input" name="owner_search" class="form-input ms-search" placeholder="Cerca owner..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.users || []).map(u => {
const selectedList = String(this.state.user_id || '').split(',').filter(Boolean);
@@ -326,7 +326,7 @@ const Filters = {
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="customer-user-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<input type="text" id="customer-user-search-input" placeholder="Cerca utente..." style="width:100%; padding:6px 8px; font-size:0.8rem; background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); margin-bottom:6px; box-sizing:border-box; color:var(--text-primary); font-family:inherit;" autocomplete="off" />
<input type="text" id="customer-user-search-input" name="customer_user_search" placeholder="Cerca utente..." style="width:100%; padding:6px 8px; font-size:0.8rem; background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); margin-bottom:6px; box-sizing:border-box; color:var(--text-primary); font-family:inherit;" autocomplete="off" />
<div id="customer-user-items-container" style="display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
<!-- Populated dynamically -->
</div>
+16 -1
View File
@@ -559,7 +559,22 @@ const EmailCompose = (() => {
});
// Send
document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl, bccTagsCtrl));
const handleEmailSend = () => sendEmail(toTagsCtrl, ccTagsCtrl, bccTagsCtrl);
document.getElementById('ec-send').addEventListener('click', handleEmailSend);
// Keyboard shortcut handler for email compose modal
const handleEmailKeydown = (e) => {
if (!document.getElementById('email-compose-overlay')) {
document.removeEventListener('keydown', handleEmailKeydown, true);
return;
}
if (App.isShortcutTriggered(e, 'send_email_submit')) {
e.preventDefault();
e.stopPropagation();
handleEmailSend();
}
};
document.addEventListener('keydown', handleEmailKeydown, true);
}
function processFiles(files) {
+1 -1
View File
@@ -431,7 +431,7 @@ const RicercaSpudorataView = {
const limitSelectHtml = `
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary);">
<span>Righe:</span>
<select class="form-select spudorata-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
<select id="spudorata-per-page-select-${isTop ? 'top' : 'bottom'}" name="spudorata_per_page_${isTop ? 'top' : 'bottom'}" class="form-select spudorata-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
<option value="10" ${perPage === 10 ? 'selected' : ''}>10</option>
<option value="20" ${perPage === 20 ? 'selected' : ''}>20</option>
<option value="50" ${perPage === 50 ? 'selected' : ''}>50</option>
+14
View File
@@ -72,6 +72,13 @@ const ShortcutsView = {
context: 'Globale',
defaultCombo: { ctrl: true, alt: false, shift: true, key: 'f', enabled: true }
},
{
id: 'ticket_list_open',
name: 'Torna a Elenco Ticket',
description: 'Naviga direttamente alla pagina principale dell\'elenco ticket',
context: 'Globale',
defaultCombo: { ctrl: true, alt: false, shift: true, key: 'Backspace', enabled: true }
},
{
id: 'create_ticket_submit',
name: 'Invia Nuovo Ticket',
@@ -86,6 +93,13 @@ const ShortcutsView = {
context: 'Pagina Dettaglio Ticket',
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
},
{
id: 'send_email_submit',
name: 'Invia Email',
description: 'Invia l\'email quando la finestra di composizione email è aperta',
context: 'Modal Composizione Email',
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
},
{
id: 'pdf_save_edited',
name: 'Salva Immagine / Allegato / PDF Modificato',
+646 -28
View File
@@ -246,6 +246,13 @@ const TicketDetailView = {
<input type="color" id="img-color-picker" value="#ef4444" title="Colore primario" style="width:24px; height:24px; border:none; border-radius:50%; cursor:pointer; background:none;" />
<div id="img-highlight-controls" style="display: none; align-items: center; gap: 4px; padding-left: 4px; border-left: 1px solid rgba(255,255,255,0.2);" title="Intensità Opacità">
<label style="font-size:0.7rem; color:#cbd5e1; display:flex; align-items:center; gap:3px;">
Opacità: <input type="range" id="img-highlight-opacity" min="0.01" max="1.0" step="0.01" value="0.15" style="width:65px; cursor:pointer;" />
<span id="img-highlight-opacity-val" style="font-size:0.7rem; font-family:monospace; color:#38bdf8; min-width:28px;">15%</span>
</label>
</div>
<div id="img-shape-controls" style="display: none; align-items: center; gap: 4px; padding-left: 4px; border-left: 1px solid rgba(255,255,255,0.2);">
<select id="img-shape-type" style="height: 24px; background: #0f172a; color: #fff; border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; font-size: 0.74rem; padding: 0 4px; cursor: pointer;">
<option value="rectangle">🟦 Rettangolo</option>
@@ -321,6 +328,13 @@ const TicketDetailView = {
<!-- Main Color Picker -->
<input type="color" id="pdf-color-picker" value="#ef4444" title="Colore primario" style="width:26px; height:26px; padding:0; border:none; border-radius:50%; cursor:pointer; background:none;" />
<div id="pdf-highlight-controls" style="display: none; align-items: center; gap: 4px; padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.2);" title="Intensità Opacità">
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:4px;">
Opacità: <input type="range" id="pdf-highlight-opacity" min="0.01" max="1.0" step="0.01" value="0.15" style="width:70px; cursor:pointer;" />
<span id="pdf-highlight-opacity-val" style="font-size:0.72rem; font-family:monospace; color:#38bdf8; min-width:30px;">15%</span>
</label>
</div>
<!-- Shape controls container -->
<div id="pdf-shape-controls" style="display: none; align-items: center; gap: 6px; padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.2);">
<select id="pdf-shape-type" style="height: 26px; background: #0f172a; color: #fff; border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; font-size: 0.76rem; padding: 0 4px; cursor: pointer;">
@@ -365,7 +379,26 @@ const TicketDetailView = {
</div>
`;
} else if (current.type === 'text') {
previewHtml = `<iframe src="${current.src}" style="width: 84vw; height: 78vh; border: none; border-radius: 6px; background: #ffffff; box-shadow: 0 10px 40px rgba(0,0,0,0.6);"></iframe>`;
const attId = (current.src.match(/\/attachments\/(\d+)/) || [])[1];
previewHtml = `
<div id="text-viewer-container" style="position: relative; width: 84vw; height: 78vh; background: var(--bg-card); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border-subtle);">
<div id="text-toolbar" style="height: 48px; min-height: 48px; background: rgba(15, 23, 42, 0.95); border-bottom: 1px solid rgba(255, 255, 255, 0.15); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; color: #ffffff; font-size: 0.82rem; user-select: none; gap: 12px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-weight: 700; color: #ffffff;">📝 Documento di Testo (Modificabile)</span>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
${attId ? `
<button id="text-save-btn" class="btn btn-primary btn-sm" style="height:30px; padding:0 14px; font-size:0.8rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:var(--accent-primary); color:#ffffff; border:none; border-radius:6px; cursor:pointer;" title="Salva le modifiche apportate al file di testo">
💾 Salva Modifiche
</button>
` : ''}
</div>
</div>
<div style="flex: 1; position: relative; padding: 12px; background: #0f172a; display: flex;">
<textarea id="text-editor-textarea" style="flex: 1; width: 100%; height: 100%; background: #090d16; color: #e2e8f0; font-family: consolas, Monaco, monospace, sans-serif; font-size: 0.88rem; line-height: 1.5; border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; padding: 14px; resize: none; outline: none;" placeholder="Caricamento contenuto di testo..."></textarea>
</div>
</div>
`;
} else {
const icon = this.getFileIcon(current.filename);
previewHtml = `
@@ -380,13 +413,18 @@ const TicketDetailView = {
</div>
</div>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 0; line-height: 1.4;">
Per modificare o visualizzare questo file con l'applicazione predefinita del tuo sistema (Word, Excel, ecc.), usa il pulsante qui sotto.
Scegli se aprire il file con l'applicazione predefinita del tuo computer (Word, Excel, PowerPoint, ecc.) oppure scaricarne una copia locale.
</p>
<a href="${current.downloadUrl}" download="${App.escapeHtml(current.filename)}" target="_blank" class="btn btn-primary" style="padding: 10px 22px; font-size: 0.9rem; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px; height:18px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Apri nell'app / Scarica
<div style="display:flex; gap:10px; flex-wrap:wrap; justify-content:center;">
<button id="doc-open-system-btn" class="btn btn-primary" style="padding: 10px 18px; font-size: 0.88rem; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; border-radius: var(--radius-md); border:none; cursor:pointer;">
🚀 Apri con app di sistema
</button>
<a href="${current.downloadUrl}" download="${App.escapeHtml(current.filename)}" target="_blank" class="btn btn-ghost" style="padding: 10px 18px; font-size: 0.88rem; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; border-radius: var(--radius-md); border:1px solid var(--border-subtle);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px; height:16px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Scarica copia
</a>
</div>
</div>
`;
}
@@ -409,6 +447,54 @@ const TicketDetailView = {
this.initPdfJsViewer(current.src);
} else if (current.type === 'image') {
this.initImageEditor(current.src, current.filename);
} else if (current.type === 'text') {
const textarea = document.getElementById('text-editor-textarea');
const saveBtn = document.getElementById('text-save-btn');
const attId = (current.src.match(/\/attachments\/(\d+)/) || [])[1];
if (textarea) {
fetch(current.src)
.then(r => r.text())
.then(txt => { textarea.value = txt; })
.catch(err => { textarea.value = 'Errore nel caricamento del file: ' + err.message; });
}
if (saveBtn && attId && textarea) {
saveBtn.addEventListener('click', async () => {
try {
saveBtn.disabled = true;
saveBtn.innerHTML = '💾 Salvataggio...';
await App.api(`/api/tickets/attachments/${attId}`, {
method: 'PUT',
body: JSON.stringify({ content: textarea.value })
});
Toast.success('File di testo salvato con successo!');
} catch (err) {
Toast.error('Errore durante il salvataggio: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '💾 Salva Modifiche';
}
});
}
} else {
const openSysBtn = document.getElementById('doc-open-system-btn');
const attId = (current.src.match(/\/attachments\/(\d+)/) || [])[1];
if (openSysBtn && attId) {
openSysBtn.addEventListener('click', async () => {
try {
openSysBtn.disabled = true;
openSysBtn.innerHTML = '🚀 Apertura in corso...';
await App.api(`/api/tickets/attachments/${attId}/open`, { method: 'POST' });
Toast.success('File aperto con l\'applicazione di sistema!');
} catch (err) {
Toast.error('Impossibile aprire il file: ' + err.message);
} finally {
openSysBtn.disabled = false;
openSysBtn.innerHTML = '🚀 Apri con app di sistema';
}
});
}
}
overlay.focus();
@@ -650,6 +736,8 @@ const TicketDetailView = {
let currentTool = 'draw'; // 'draw' | 'text' | 'highlight' | 'shape' | 'signature'
let currentColor = '#ef4444';
let currentHighlightOpacity = 0.15;
let mousePos = null;
let activeSignature = null;
// Pre-fetch signatures to select default signature
@@ -668,10 +756,20 @@ const TicketDetailView = {
const toolShapeBtn = document.getElementById('pdf-tool-shape');
const toolSigBtn = document.getElementById('pdf-tool-signature');
const shapeControls = document.getElementById('pdf-shape-controls');
const highlightControls = document.getElementById('pdf-highlight-controls');
const highlightOpacityInput = document.getElementById('pdf-highlight-opacity');
const highlightOpacityVal = document.getElementById('pdf-highlight-opacity-val');
const colorPicker = document.getElementById('pdf-color-picker');
const toolClearBtn = document.getElementById('pdf-tool-clear');
const saveEditedBtn = document.getElementById('pdf-save-edited');
if (highlightOpacityInput && highlightOpacityVal) {
highlightOpacityInput.oninput = (e) => {
currentHighlightOpacity = parseFloat(e.target.value);
highlightOpacityVal.textContent = `${Math.round(currentHighlightOpacity * 100)}%`;
};
}
const setActiveTool = (tool, activeBtn) => {
currentTool = tool;
const allToolBtns = [toolSelectBtn, toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, toolSigBtn];
@@ -688,6 +786,9 @@ const TicketDetailView = {
if (shapeControls) {
shapeControls.style.display = tool === 'shape' ? 'flex' : 'none';
}
if (highlightControls) {
highlightControls.style.display = (tool === 'draw' || tool === 'highlight') ? 'flex' : 'none';
}
// Adjust pointer-events for text selection layer vs annotation canvas
for (let num = 1; num <= totalPages; num++) {
@@ -1131,13 +1232,13 @@ const TicketDetailView = {
drawCtx.strokeStyle = obj.color;
drawCtx.lineWidth = obj.size;
if (obj.brushType === 'highlight') {
if (obj.brushType === 'highlight' || obj.type === 'highlight') {
drawCtx.globalCompositeOperation = 'multiply';
drawCtx.globalAlpha = 0.55;
drawCtx.globalAlpha = obj.opacity !== undefined ? obj.opacity : 0.15;
drawCtx.lineCap = 'square';
} else {
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.globalAlpha = obj.opacity || 1.0;
drawCtx.globalAlpha = obj.opacity !== undefined ? obj.opacity : 1.0;
drawCtx.lineCap = 'round';
}
drawCtx.lineJoin = 'round';
@@ -1264,6 +1365,27 @@ const TicketDetailView = {
drawCtx.restore();
}
});
// Draw brush size circle indicator under mouse cursor when drawing/highlighting
if (mousePos && (currentTool === 'draw' || currentTool === 'highlight')) {
const currentSize = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const radius = Math.max(2, currentSize / 2);
drawCtx.save();
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.beginPath();
drawCtx.arc(mousePos.x, mousePos.y, radius, 0, Math.PI * 2);
drawCtx.strokeStyle = 'rgba(0, 0, 0, 0.7)';
drawCtx.lineWidth = 1.5;
drawCtx.stroke();
drawCtx.beginPath();
drawCtx.arc(mousePos.x, mousePos.y, radius, 0, Math.PI * 2);
drawCtx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
drawCtx.lineWidth = 1;
drawCtx.stroke();
drawCtx.restore();
}
};
const getObjectHandles = (obj) => {
@@ -1396,8 +1518,16 @@ const TicketDetailView = {
<div style="margin-bottom: 10px; display:flex; gap:8px; align-items:center;">
<div style="flex:1;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:2px;">Dimensione tratto</label>
<input type="range" id="ctx-brush-size" min="1" max="40" value="${currentBrushSize}" style="width:100%; cursor:pointer;" />
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:2px;">
<label style="font-size:0.75rem; color:#94a3b8;">Dimensione tratto</label>
<span id="ctx-brush-val-badge" style="font-size:0.72rem; font-family:monospace; color:#38bdf8; font-weight:700;">${currentBrushSize}px</span>
</div>
<div style="display:flex; align-items:center; gap:6px;">
<input type="range" id="ctx-brush-size" min="1" max="40" value="${currentBrushSize}" style="flex:1; cursor:pointer;" />
<div id="ctx-brush-preview-box" title="Anteprima dimensione reale" style="width:26px; height:26px; border-radius:4px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.2); display:flex; align-items:center; justify-content:center; flex-shrink:0;">
<div id="ctx-brush-preview-circle" style="width:${Math.max(2, currentBrushSize)}px; height:${Math.max(2, currentBrushSize)}px; max-width:22px; max-height:22px; border-radius:50%; background:${currentColor}; transition:all 0.1s ease;"></div>
</div>
</div>
</div>
<div>
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:2px;">Colore</label>
@@ -1496,6 +1626,13 @@ const TicketDetailView = {
} else if (type === 'shape') {
setActiveTool('shape', toolShapeBtn);
}
const valBadge = menuOverlay.querySelector('#ctx-brush-val-badge');
if (valBadge) valBadge.textContent = `${currentBrushSize}px`;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) {
pCircle.style.width = `${Math.max(2, currentBrushSize)}px`;
pCircle.style.height = `${Math.max(2, currentBrushSize)}px`;
}
if (sizeInput) sizeInput.value = currentBrushSize;
};
});
@@ -1507,6 +1644,15 @@ const TicketDetailView = {
const val = parseInt(eVal.target.value, 10);
currentBrushSize = val;
const valBadge = menuOverlay.querySelector('#ctx-brush-val-badge');
if (valBadge) valBadge.textContent = `${val}px`;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) {
pCircle.style.width = `${Math.max(2, val)}px`;
pCircle.style.height = `${Math.max(2, val)}px`;
}
// Update shape border width input in toolbar if shape tool is active
const shapeBorderWidthEl = document.getElementById('pdf-shape-border-width');
if (shapeBorderWidthEl) {
@@ -1526,6 +1672,8 @@ const TicketDetailView = {
ctxColorPicker.onchange = (eVal) => {
eVal.stopPropagation();
currentColor = eVal.target.value;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) pCircle.style.background = currentColor;
if (colorPicker) colorPicker.value = currentColor;
};
}
@@ -1611,6 +1759,10 @@ const TicketDetailView = {
drawCanvas.onmousedown = (e) => {
if (e.button !== 0) return; // Only primary click
const existingPopup = document.getElementById('pdf-context-menu-popup') || document.getElementById('img-context-menu-popup');
if (existingPopup) existingPopup.remove();
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
@@ -1847,7 +1999,9 @@ const TicketDetailView = {
// Drawing mode (pencil / brush / highlight)
isDrawingStroke = true;
currentStrokePoints = [{ x, y }];
const opacity = (currentTool === 'highlight' || currentBrushType === 'highlight') ? 0.15 : 1.0;
const opacity = (currentTool === 'highlight' || currentBrushType === 'highlight')
? currentHighlightOpacity
: (currentTool === 'draw' ? currentHighlightOpacity : 1.0);
const size = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const newStrokeObj = {
@@ -1872,6 +2026,7 @@ const TicketDetailView = {
const rect = drawCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
mousePos = { x, y };
if (isResizingObj && selectedObject) {
e.preventDefault();
@@ -1990,7 +2145,14 @@ const TicketDetailView = {
}
}
const hoverObj = hitTestObject(x, y);
drawCanvas.style.cursor = hoverObj ? 'move' : (currentTool === 'select' ? 'default' : 'crosshair');
if (hoverObj) {
drawCanvas.style.cursor = 'move';
} else if (currentTool === 'select') {
drawCanvas.style.cursor = 'default';
} else {
// Custom subtle dot cursor so the outer stroke-size circle is the main visual indicator
drawCanvas.style.cursor = 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'8\' viewBox=\'0 0 8 8\'><circle cx=\'4\' cy=\'4\' r=\'1.5\' fill=\'%23ffffff\' stroke=\'%23000000\' stroke-width=\'0.8\'/></svg>") 4 4, crosshair';
}
// Live preview ghost for active signature under cursor
if (currentTool === 'signature' && activeSignature && !isDrawingStroke && !isDraggingObj && !isResizingObj) {
@@ -2019,14 +2181,14 @@ const TicketDetailView = {
return;
}
if (!isDrawingStroke) return;
if (isDrawingStroke) {
e.preventDefault();
e.stopPropagation();
currentStrokePoints.push({ x, y });
// Update stroke bounding box
if (isDrawingStroke && pageObjects.length > 0) {
if (pageObjects.length > 0) {
const activeStrokeObj = pageObjects[pageObjects.length - 1];
if (activeStrokeObj && activeStrokeObj.type === 'stroke') {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -2042,6 +2204,7 @@ const TicketDetailView = {
activeStrokeObj.height = maxY - minY;
}
}
}
redrawPageObjects();
};
@@ -2086,6 +2249,7 @@ const TicketDetailView = {
drawCanvas.onmouseup = stopDrawing;
drawCanvas.onmouseleave = (e) => {
mousePos = null;
stopDrawing(e);
redrawPageObjects();
};
@@ -2174,6 +2338,7 @@ const TicketDetailView = {
let currentTool = 'draw';
let currentColor = '#ef4444';
let currentHighlightOpacity = 0.15;
let currentBrushType = 'pencil';
let currentBrushSize = 3;
let activeSignature = null;
@@ -2225,6 +2390,8 @@ const TicketDetailView = {
baseCtx.restore();
};
let mousePos = null;
const redrawPageObjects = () => {
drawCtx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
pageObjects.forEach(obj => {
@@ -2236,13 +2403,13 @@ const TicketDetailView = {
obj.points.forEach(p => drawCtx.lineTo(p.x, p.y));
drawCtx.strokeStyle = obj.color;
drawCtx.lineWidth = obj.size;
if (obj.brushType === 'highlight') {
if (obj.brushType === 'highlight' || obj.type === 'highlight') {
drawCtx.globalCompositeOperation = 'multiply';
drawCtx.globalAlpha = 0.55;
drawCtx.globalAlpha = obj.opacity !== undefined ? obj.opacity : 0.15;
drawCtx.lineCap = 'square';
} else {
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.globalAlpha = obj.opacity || 1.0;
drawCtx.globalAlpha = obj.opacity !== undefined ? obj.opacity : 1.0;
drawCtx.lineCap = 'round';
}
drawCtx.lineJoin = 'round';
@@ -2365,6 +2532,27 @@ const TicketDetailView = {
drawCtx.restore();
}
});
// Draw brush size circle indicator under mouse cursor when drawing/highlighting
if (mousePos && (currentTool === 'draw' || currentTool === 'highlight')) {
const currentSize = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const radius = Math.max(2, currentSize / 2);
drawCtx.save();
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.beginPath();
drawCtx.arc(mousePos.x, mousePos.y, radius, 0, Math.PI * 2);
drawCtx.strokeStyle = 'rgba(0, 0, 0, 0.7)';
drawCtx.lineWidth = 1.5;
drawCtx.stroke();
drawCtx.beginPath();
drawCtx.arc(mousePos.x, mousePos.y, radius, 0, Math.PI * 2);
drawCtx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
drawCtx.lineWidth = 1;
drawCtx.stroke();
drawCtx.restore();
}
};
const getObjectHandles = (obj) => {
@@ -2461,10 +2649,20 @@ const TicketDetailView = {
const toolShapeBtn = document.getElementById('img-tool-shape');
const toolSigBtn = document.getElementById('img-tool-signature');
const shapeControls = document.getElementById('img-shape-controls');
const highlightControls = document.getElementById('img-highlight-controls');
const highlightOpacityInput = document.getElementById('img-highlight-opacity');
const highlightOpacityVal = document.getElementById('img-highlight-opacity-val');
const colorPicker = document.getElementById('img-color-picker');
const toolClearBtn = document.getElementById('img-tool-clear');
const saveEditedBtn = document.getElementById('img-save-edited');
if (highlightOpacityInput && highlightOpacityVal) {
highlightOpacityInput.oninput = (e) => {
currentHighlightOpacity = parseFloat(e.target.value);
highlightOpacityVal.textContent = `${Math.round(currentHighlightOpacity * 100)}%`;
};
}
const setActiveTool = (tool, activeBtn) => {
currentTool = tool;
const allToolBtns = [toolSelectBtn, toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, toolSigBtn];
@@ -2481,6 +2679,9 @@ const TicketDetailView = {
if (shapeControls) {
shapeControls.style.display = tool === 'shape' ? 'flex' : 'none';
}
if (highlightControls) {
highlightControls.style.display = (tool === 'draw' || tool === 'highlight') ? 'flex' : 'none';
}
drawCanvas.style.pointerEvents = tool === 'select' ? 'none' : 'auto';
};
@@ -2522,6 +2723,10 @@ const TicketDetailView = {
// Mouse Events on drawCanvas
drawCanvas.onmousedown = (e) => {
if (e.button !== 0) return;
const existingPopup = document.getElementById('img-context-menu-popup') || document.getElementById('pdf-context-menu-popup');
if (existingPopup) existingPopup.remove();
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
@@ -2625,7 +2830,9 @@ const TicketDetailView = {
isDrawingStroke = true;
currentStrokePoints = [{ x, y }];
const opacity = (currentTool === 'highlight' || currentBrushType === 'highlight') ? 0.15 : 1.0;
const opacity = (currentTool === 'highlight' || currentBrushType === 'highlight')
? currentHighlightOpacity
: (currentTool === 'draw' ? currentHighlightOpacity : 1.0);
const size = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const newStrokeObj = {
@@ -2643,6 +2850,7 @@ const TicketDetailView = {
const rect = drawCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
mousePos = { x, y };
if (isResizingObj && selectedObject) {
e.preventDefault();
@@ -2681,9 +2889,29 @@ const TicketDetailView = {
return;
}
if (!isDrawingStroke) return;
if (isDrawingStroke) {
e.preventDefault();
currentStrokePoints.push({ x, y });
}
if (selectedObject) {
const hIdx = hitTestHandle(selectedObject, x, y);
if (hIdx !== -1) {
drawCanvas.style.cursor = 'nwse-resize';
redrawPageObjects();
return;
}
}
const hoverObj = hitTestObject(x, y);
if (hoverObj) {
drawCanvas.style.cursor = 'move';
} else if (currentTool === 'select') {
drawCanvas.style.cursor = 'default';
} else {
// Custom subtle dot cursor so the outer stroke-size circle is the main visual indicator
drawCanvas.style.cursor = 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'8\' viewBox=\'0 0 8 8\'><circle cx=\'4\' cy=\'4\' r=\'1.5\' fill=\'%23ffffff\' stroke=\'%23000000\' stroke-width=\'0.8\'/></svg>") 4 4, crosshair';
}
redrawPageObjects();
};
@@ -2713,7 +2941,300 @@ const TicketDetailView = {
};
drawCanvas.onmouseup = stopDrawing;
drawCanvas.onmouseleave = stopDrawing;
drawCanvas.onmouseleave = () => {
mousePos = null;
stopDrawing();
};
// Ctrl + Right Click Context Menu Handler for Image Editor
drawCanvas.oncontextmenu = async (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Fetch signatures and shapes from SQLite
let signatures = [];
let favoriteShapes = [];
try {
const [sigRes, shapeRes] = await Promise.all([
App.api('/api/config/user-signatures'),
App.api('/api/config/user-shapes')
]);
signatures = sigRes || [];
favoriteShapes = (shapeRes || []).filter(s => s.is_favorite);
} catch (_) { }
let menuOverlay = document.getElementById('img-context-menu-popup');
if (menuOverlay) menuOverlay.remove();
menuOverlay = document.createElement('div');
menuOverlay.id = 'img-context-menu-popup';
menuOverlay.style.cssText = `
position: fixed;
left: ${e.clientX}px;
top: ${e.clientY}px;
z-index: 20000;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
color: #ffffff;
font-size: 0.8rem;
min-width: 260px;
user-select: none;
`;
menuOverlay.innerHTML = `
<div style="font-weight: 700; margin-bottom: 8px; color: var(--accent-primary); border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between;">
<span>⚙️ Strumenti Rapidi Immagine</span>
<span style="cursor:pointer; opacity:0.7;" id="ctx-close">✕</span>
</div>
<div style="margin-bottom: 10px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:4px;">Tipo di Strumento</label>
<div style="display:flex; gap:4px; flex-wrap:wrap;">
<button class="ctx-btn ${currentTool === 'draw' && currentBrushType === 'pencil' ? 'active' : ''}" data-type="pencil" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'draw' && currentBrushType === 'pencil' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">✏️ Matita</button>
<button class="ctx-btn ${currentTool === 'draw' && currentBrushType === 'brush' ? 'active' : ''}" data-type="brush" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'draw' && currentBrushType === 'brush' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">🖌️ Pennello</button>
<button class="ctx-btn ${currentTool === 'highlight' ? 'active' : ''}" data-type="highlight" style="flex:1; min-width:65px; padding:4px; font-size:0.75rem; background:${currentTool === 'highlight' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">🖍️ Evidenzia</button>
<button class="ctx-btn ${currentTool === 'shape' ? 'active' : ''}" data-type="shape" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'shape' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">📐 Forme</button>
</div>
</div>
<div style="margin-bottom: 10px; display:flex; gap:8px; align-items:center;">
<div style="flex:1;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:2px;">
<label style="font-size:0.75rem; color:#94a3b8;">Dimensione tratto</label>
<span id="ctx-brush-val-badge" style="font-size:0.72rem; font-family:monospace; color:#38bdf8; font-weight:700;">${currentBrushSize}px</span>
</div>
<div style="display:flex; align-items:center; gap:6px;">
<input type="range" id="ctx-brush-size" min="1" max="40" value="${currentBrushSize}" style="flex:1; cursor:pointer;" />
<div id="ctx-brush-preview-box" title="Anteprima dimensione reale" style="width:26px; height:26px; border-radius:4px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.2); display:flex; align-items:center; justify-content:center; flex-shrink:0;">
<div id="ctx-brush-preview-circle" style="width:${Math.max(2, currentBrushSize)}px; height:${Math.max(2, currentBrushSize)}px; max-width:22px; max-height:22px; border-radius:50%; background:${currentColor}; transition:all 0.1s ease;"></div>
</div>
</div>
</div>
<div>
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:2px;">Colore</label>
<input type="color" id="ctx-color" value="${currentColor}" style="width:26px; height:26px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</div>
</div>
<!-- Favorite Shapes Section -->
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px; margin-bottom: 8px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:6px;">📐 Forme Preferite Salvate</label>
${favoriteShapes.length > 0 ? `
<div style="max-height: 100px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
${favoriteShapes.map(sh => `
<button class="ctx-shape-btn" data-id="${sh.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<span style="display:inline-block; width:16px; height:12px; border-radius:${sh.shape_type === 'circle' ? '50%' : '2px'}; background:${sh.fill_color === 'transparent' ? 'transparent' : sh.fill_color}; border:${Math.min(sh.border_width, 3)}px solid ${sh.border_color}; box-sizing:border-box;"></span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(sh.name)}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic;">Nessuna forma preferita salvata.</div>
`}
</div>
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:6px;">✍️ Inserisci Firma / Immagine Utente</label>
${signatures.length > 0 ? `
<div style="max-height: 120px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
${signatures.map(s => `
<button class="ctx-sig-btn" data-id="${s.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<img src="${s.image_data}" style="width:28px; height:20px; object-fit:contain; background:#fff; border-radius:2px; padding:1px;" />
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(s.name)}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic;">Nessuna firma salvata in SQLite.</div>
`}
</div>
`;
document.body.appendChild(menuOverlay);
// Clamp positioning to prevent overflowing window boundaries
const menuRect = menuOverlay.getBoundingClientRect();
const winW = window.innerWidth;
const winH = window.innerHeight;
let finalLeft = e.clientX;
let finalTop = e.clientY;
if (finalLeft + menuRect.width > winW - 12) {
finalLeft = winW - menuRect.width - 12;
}
if (finalLeft < 12) {
finalLeft = 12;
}
if (finalTop + menuRect.height > winH - 12) {
finalTop = winH - menuRect.height - 12;
}
if (finalTop < 12) {
finalTop = 12;
}
menuOverlay.style.left = `${finalLeft}px`;
menuOverlay.style.top = `${finalTop}px`;
// Menu Event Listeners
const closeCtx = () => { if (menuOverlay) menuOverlay.remove(); };
menuOverlay.querySelector('#ctx-close').onclick = closeCtx;
menuOverlay.querySelectorAll('.ctx-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const type = btn.dataset.type;
menuOverlay.querySelectorAll('.ctx-btn').forEach(b => {
b.style.background = 'rgba(255,255,255,0.1)';
b.classList.remove('active');
});
btn.style.background = 'var(--accent-primary)';
btn.classList.add('active');
if (type === 'pencil') {
currentBrushType = 'pencil';
currentBrushSize = 3;
setActiveTool('draw', toolDrawBtn);
} else if (type === 'brush') {
currentBrushType = 'brush';
currentBrushSize = 8;
setActiveTool('draw', toolDrawBtn);
} else if (type === 'highlight') {
currentBrushType = 'highlight';
currentBrushSize = 20;
setActiveTool('highlight', toolHighlightBtn);
} else if (type === 'shape') {
setActiveTool('shape', toolShapeBtn);
}
const valBadge = menuOverlay.querySelector('#ctx-brush-val-badge');
if (valBadge) valBadge.textContent = `${currentBrushSize}px`;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) {
pCircle.style.width = `${Math.max(2, currentBrushSize)}px`;
pCircle.style.height = `${Math.max(2, currentBrushSize)}px`;
}
if (sizeInput) sizeInput.value = currentBrushSize;
};
});
const sizeInput = menuOverlay.querySelector('#ctx-brush-size');
if (sizeInput) {
sizeInput.oninput = (eVal) => {
eVal.stopPropagation();
const val = parseInt(eVal.target.value, 10);
currentBrushSize = val;
const valBadge = menuOverlay.querySelector('#ctx-brush-val-badge');
if (valBadge) valBadge.textContent = `${val}px`;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) {
pCircle.style.width = `${Math.max(2, val)}px`;
pCircle.style.height = `${Math.max(2, val)}px`;
}
const shapeBorderWidthEl = document.getElementById('img-shape-border-width');
if (shapeBorderWidthEl) {
shapeBorderWidthEl.value = val;
}
if (selectedObject && selectedObject.type === 'shape') {
selectedObject.borderWidth = val;
redrawPageObjects();
}
};
}
const ctxColorPicker = menuOverlay.querySelector('#ctx-color');
if (ctxColorPicker) {
ctxColorPicker.onchange = (eVal) => {
eVal.stopPropagation();
currentColor = eVal.target.value;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) pCircle.style.background = currentColor;
if (colorPicker) colorPicker.value = currentColor;
};
}
menuOverlay.querySelectorAll('.ctx-shape-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const shId = btn.dataset.id;
const sh = favoriteShapes.find(s => s.id == shId);
if (sh) {
const defaultW = 130;
const defaultH = 85;
const newObj = {
id: Date.now(),
type: 'shape',
shapeType: sh.shape_type,
x: clickX - defaultW / 2,
y: clickY - defaultH / 2,
width: defaultW,
height: defaultH,
fillColor: sh.fill_color,
borderColor: sh.border_color,
borderWidth: sh.border_width
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Forma preferita "${sh.name}" inserita!`);
closeCtx();
}
};
});
menuOverlay.querySelectorAll('.ctx-sig-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const sId = btn.dataset.id;
const s = signatures.find(sig => sig.id == sId);
if (s) {
const img = new Image();
img.onload = () => {
const targetWidth = Math.min(220, img.width || 180);
const targetHeight = (img.height / img.width) * targetWidth || 80;
const newObj = {
id: Date.now(),
type: 'image',
img: img,
x: clickX - targetWidth / 2,
y: clickY - targetHeight / 2,
width: targetWidth,
height: targetHeight
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Firma "${s.name}" apposta!`);
closeCtx();
};
img.src = s.image_data;
}
};
});
const handleOutsideClick = (eClick) => {
if (menuOverlay && !menuOverlay.contains(eClick.target)) {
closeCtx();
document.removeEventListener('mousedown', handleOutsideClick);
}
};
setTimeout(() => {
document.addEventListener('mousedown', handleOutsideClick);
}, 50);
}
};
},
getOutputFilename(originalName) {
@@ -2906,6 +3427,11 @@ const TicketDetailView = {
const hasEmailDraft = App.getDraft(id, 'email');
const emailBtnText = hasEmailDraft ? 'Continua mail' : 'Invia Email';
const ticketRealAttachments = (attachments || []).filter(att => {
const fname = (att.filename || '').toLowerCase();
return !fname.startsWith('file-1') && !fname.startsWith('file-2') && !fname.startsWith('file-3');
});
container.innerHTML = `
<a class="back-link" onclick="window.location.hash = App.lastListView || '#/tickets'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
@@ -2924,9 +3450,18 @@ const TicketDetailView = {
-
</div>
<h2 class="ticket-detail-title" style="display:inline-flex; align-items:center; gap:6px; flex-wrap:wrap;">
<span class="copy-ticket-btn" data-tn="${App.escapeHtml(ticket.title || '')}" style="cursor: pointer; font-size: 0.85rem;" title="Copia titolo ticket">📋</span>
<span class="copy-full-ticket-title" data-full="${ticket.tn} - ${App.escapeHtml(ticket.title || '(senza titolo)')}" style="cursor: pointer;" title="Clicca per copiare: ${ticket.tn} - ${App.escapeHtml(ticket.title || '')}">
${App.escapeHtml(ticket.title || '(senza titolo)')}
</span>
<button class="open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.openTab(${ticket.id}, '${ticket.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
<button class="assign-open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.assignAndOpenTab(${ticket.id}, '${ticket.tn}', this.dataset.title); event.stopPropagation();" title="Assegna a me ed apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 8px; background:var(--accent-primary); color:#fff; border:none; border-radius:var(--radius-full); font-size:11px; font-weight:600; font-family:inherit; margin-left:6px; cursor:pointer; gap:3px;">
👤+ Assegna & Scheda
</button>
${ticketRealAttachments.length > 0 ? `
<button class="header-open-attachments-btn" style="display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 8px; background:var(--bg-tertiary); color:var(--accent-primary); border:1px solid var(--border-subtle); border-radius:var(--radius-full); font-size:11px; font-weight:600; font-family:inherit; margin-left:6px; cursor:pointer; gap:4px;" title="Visualizza allegati ticket (${ticketRealAttachments.length})">
📎 ${ticketRealAttachments.length}
</button>
` : ''}
</h2>
<div class="ticket-meta-badges">
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
@@ -3212,7 +3747,7 @@ const TicketDetailView = {
ℹ️
</button>
<div class="article-info-popover" style="position: absolute; top: calc(100% + 4px); right: 0; left: auto; width: 340px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px; font-size: 0.78rem; box-shadow: var(--shadow-lg); z-index: 120; display: none; text-align: left;">
<div class="article-info-popover" style="position: absolute; top: calc(100% + 4px); right: 0; left: auto; width: 340px; max-height: 360px; overflow-y: auto; overscroll-behavior: contain; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px; font-size: 0.78rem; box-shadow: var(--shadow-lg); z-index: 120; display: none; text-align: left; user-select: text; -webkit-user-select: text;">
<div style="font-weight: 700; color: var(--text-secondary); margin-bottom: 8px; border-bottom: 1px solid var(--border-subtle); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem;">
<span style="display: flex; align-items: center; gap: 4px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:var(--accent-primary);"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
@@ -3259,7 +3794,7 @@ const TicketDetailView = {
</div>
${extractedLinks.length > 0 ? `
<div class="article-links-minilist" style="display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; padding-right: 2px;">
<div class="article-links-minilist" style="display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; overscroll-behavior: contain; padding-right: 4px;">
${extractedLinks.map(link => `
<a href="${App.escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" title="${App.escapeHtml(link.url)}" style="color: var(--accent-primary); text-decoration: none; word-break: break-all; font-size: 0.75rem; display: flex; align-items: flex-start; gap: 4px; line-height: 1.3; background: var(--bg-tertiary); padding: 4px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);">
<span style="flex-shrink:0; font-size: 0.7rem;">🌐</span>
@@ -3573,7 +4108,12 @@ const TicketDetailView = {
queueSearchInput.addEventListener('input', () => {
clearTimeout(queueDebounce);
const q = queueSearchInput.value.trim();
const origQueueName = (this.originalValues['queue_name'] || '').trim();
if (q !== origQueueName) {
if (queueIdInput) queueIdInput.value = '';
} else {
if (queueIdInput) queueIdInput.value = String(this.originalValues['queue_id'] || '');
}
checkChanges();
queueDebounce = setTimeout(async () => {
@@ -3695,9 +4235,14 @@ const TicketDetailView = {
saveBtn.addEventListener('click', async () => {
const queueSearch = queueSearchInput ? queueSearchInput.value.trim() : '';
if (queueSearchInput && !queueIdInput.value) {
const origName = (this.originalValues['queue_name'] || '').trim();
if (queueSearch === origName && this.originalValues['queue_id']) {
queueIdInput.value = String(this.originalValues['queue_id']);
} else {
Toast.warning('Seleziona una coda valida dall\'elenco.');
return;
}
}
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
if (customerSearch && customerUserIdInput && !customerUserIdInput.value) {
@@ -3751,14 +4296,16 @@ const TicketDetailView = {
// 2. Perform article note creation if note fields are populated
if (hasNoteContent) {
if (!noteBody && noteSubject) {
if (!noteSubject && !noteBody) {
noteSubject = 'consuntivazione';
noteBody = '<p>consuntivazione</p>';
} else if (!noteBody && noteSubject) {
noteBody = `<p>${App.escapeHtml(noteSubject)}</p>`;
}
if (noteBody && !noteSubject) {
} else if (noteBody && !noteSubject) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = noteBody;
const plainText = tempDiv.textContent || tempDiv.innerText || '';
noteSubject = plainText.trim().substring(0, 50) || 'Nota interna';
noteSubject = plainText.trim().substring(0, 50) || 'consuntivazione';
}
const resNote = await App.api(`/api/tickets/${this.ticketId}/articles`, {
@@ -4123,11 +4670,50 @@ const TicketDetailView = {
}
});
// Article Info Popover (ℹ️) listeners & Copy email address buttons
document.querySelectorAll('.article-info-popover-wrapper').forEach(wrapper => {
const btnInfo = wrapper.querySelector('.btn-article-info');
const popover = wrapper.querySelector('.article-info-popover');
const card = wrapper.closest('.article-card');
const iframe = card ? card.querySelector('iframe') : null;
if (btnInfo && popover) {
btnInfo.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = popover.style.display === 'block';
// Close all popovers and restore pointer-events on all iframes
document.querySelectorAll('.article-info-popover, .time-editor-popover').forEach(p => p.style.display = 'none');
document.querySelectorAll('.article-card iframe').forEach(f => f.style.pointerEvents = 'auto');
if (!isOpen) {
popover.style.display = 'block';
if (iframe) iframe.style.pointerEvents = 'none';
}
});
popover.addEventListener('click', (e) => e.stopPropagation());
popover.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
}
});
document.querySelectorAll('.btn-copy-email-addr').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const text = btn.dataset.copyText;
if (text) {
navigator.clipboard.writeText(text);
Toast.success('Indirizzo copiato negli appunti!');
}
});
});
// Close popovers clicking outside
document.addEventListener('click', () => {
document.querySelectorAll('.time-editor-popover').forEach(p => {
document.querySelectorAll('.time-editor-popover, .article-info-popover').forEach(p => {
p.style.display = 'none';
});
document.querySelectorAll('.article-card iframe').forEach(f => f.style.pointerEvents = 'auto');
});
// Delete Article Event Listeners
document.querySelectorAll('.btn-delete-article').forEach(btn => {
@@ -4259,6 +4845,30 @@ const TicketDetailView = {
});
});
document.querySelectorAll('.copy-full-ticket-title').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const fullText = el.dataset.full;
if (fullText) {
navigator.clipboard.writeText(fullText).then(() => {
Toast.success('TN e Oggetto copiati negli appunti!');
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
const headerAttBtn = document.querySelector('.header-open-attachments-btn');
if (headerAttBtn) {
headerAttBtn.addEventListener('click', (e) => {
e.stopPropagation();
const firstBadge = document.querySelector('.attachment-badge');
const firstSrc = firstBadge ? firstBadge.href : '';
this.openImageLightbox(firstSrc);
});
}
// Keydown listener for Ticket Detail shortcut (Save Quick Edit / Send Note)
const handleTicketDetailShortcut = (e) => {
if (!document.getElementById('qe-save')) {
@@ -4275,5 +4885,13 @@ const TicketDetailView = {
}
};
document.addEventListener('keydown', handleTicketDetailShortcut, true);
// Set focus on State dropdown (#qe-state) on page load so Tab navigation starts from State
setTimeout(() => {
const qeStateEl = document.getElementById('qe-state');
if (qeStateEl) {
qeStateEl.focus();
}
}, 50);
},
};
+1 -1
View File
@@ -371,7 +371,7 @@ const TicketListView = {
const limitSelectHtml = `
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary); margin-right:var(--space-md);">
<span>Righe:</span>
<select class="form-select ticket-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
<select id="ticket-per-page-select-${isTop ? 'top' : 'bottom'}" name="per_page_${isTop ? 'top' : 'bottom'}" class="form-select ticket-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
<option value="10" ${per_page === 10 ? 'selected' : ''}>10</option>
<option value="20" ${per_page === 20 ? 'selected' : ''}>20</option>
<option value="50" ${per_page === 50 ? 'selected' : ''}>50</option>
+14
View File
@@ -0,0 +1,14 @@
/*!
* Chart.js v4.5.1
* https://www.chartjs.org
* (c) 2025 Chart.js Contributors
* Released under the MIT License
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?x(n,a,s):e[t]=g(a)}function x(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function b(t,e){return x(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?b(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function J(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Z(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return Z(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...eLine truncated
/*!
* @kurkle/color v0.3.2
* https://github.com/kurkle/color#readme
* (c) 2023 Jukka Kurkela
* Released under the MIT License
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
//# sourceMappingURL=chart.umd.min.js.map
+16
View File
@@ -0,0 +1,16 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).PDFLib={})}(this,(function(t){"use strict";
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */var e=function(t,n){return(e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,n)};function n(t,n){function r(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(r.prototype=n.prototype,new r)}var r=function(){return(r=Object.assign||function(t){for(var e,n=1,r=arguments.length;n<r;n++)for(var i in e=arguments[n])Object.prototype.hasOwnProperty.call(e,i)&&(t[i]=e[i]);return t}).apply(this,arguments)};function i(t,e,n,r){return new(n||(n=Promise))((function(i,o){function a(t){try{l(r.next(t))}catch(t){o(t)}}function s(t){try{l(r.throw(t))}catch(t){o(t)}}function l(t){var e;t.done?i(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(a,s)}l((r=r.apply(t,e||[])).next())}))}function o(t,e){var n,r,i,o,a={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:s(0),throw:s(1),return:s(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function s(o){return function(s){return function(o){if(n)throw new TypeError("Generator is already executing.");for(;a;)try{if(n=1,r&&(i=2&o[0]?r.return:o[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,o[1])).done)return i;switch(r=0,i&&(o=[2&o[0],i.value]),o[0]){case 0:case 1:i=o;break;case 4:return a.label++,{value:o[1],done:!1};case 5:a.label++,r=o[1],o=[0];continue;case 7:o=a.ops.pop(),a.trys.pop();continue;default:if(!(i=a.trys,(i=i.length>0&&i[i.length-1])||6!==o[0]&&2!==o[0])){a=0;continue}if(3===o[0]&&(!i||o[1]>i[0]&&o[1]<i[3])){a.label=o[1];break}if(6===o[0]&&a.label<i[1]){a.label=i[1],i=o;break}if(i&&a.label<i[2]){a.label=i[2],a.ops.push(o);break}i[2]&&a.ops.pop(),a.trys.pop();continue}o=e.call(t,a)}catch(t){o=[6,t],r=0}finally{n=i=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,s])}}}function a(){for(var t=0,e=0,n=arguments.length;e<n;e++)t+=arguments[e].length;var r=Array(t),i=0;for(e=0;e<n;e++)for(var o=arguments[e],a=0,s=o.length;a<s;a++,i++)r[i]=o[a];return r}for(var s="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",l=new Uint8Array(256),h=0;h<s.length;h++)l[s.charCodeAt(h)]=h;var u,c=function(t){for(var e="",n=t.length,r=0;r<n;r+=3)e+=s[t[r]>>2],e+=s[(3&t[r])<<4|t[r+1]>>4],e+=s[(15&t[r+1])<<2|t[r+2]>>6],e+=s[63&t[r+2]];return n%3==2?e=e.substring(0,e.length-1)+"=":n%3==1&&(e=e.substring(0,e.length-2)+"=="),e},d=function(t){var e,n,r,i,o,a=.75*t.length,s=t.length,h=0;"="===t[t.length-1]&&(a--,"="===t[t.length-2]&&a--);var u=new Uint8Array(a);for(e=0;e<s;e+=4)n=l[t.charCodeAt(e)],r=l[t.charCodeAt(e+1)],i=l[t.charCodeAt(e+2)],o=l[t.charCodeAt(e+3)],u[h++]=n<<2|r>>4,u[h++]=(15&r)<<4|i>>2,u[h++]=(3&i)<<6|63&o;return u},f=/^(data)?:?([\w\/\+]+)?;?(charset=[\w-]+|base64)?.*,/i,p=function(t){var e=t.trim(),n=e.substring(0,100).match(f);if(!n)return d(e);var r=n[0],i=e.substring(r.length);return d(i)},g=function(t){return t.charCodeAt(0)},v=function(t){return t.codePointAt(0)},y=function(t,e){return x(t.toString(16),e,"0").toUpperCase()},m=function(t){return y(t,2)},b=function(t){return String.fromCharCode(t)},w=function(t){return b(parseInt(t,16))},x=function(t,e,n){for(var r="",i=0,o=e-t.length;i<o;i++)r+=n;return r+t},k=function(t,e,n){for(var r=t.length,i=0;i<r;i++)e[n++]=t.charCodeAt(i);return r},F=function(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")},S=function(t){return t.replace(/\t|\u0085|\u2028|\u2029/g," ").replace(/[\b\v]/g,"")},C=["\\n","\\f","\\r","\\u000B"],A=function(t){return/^[\n\f\r\u000B]$/.test(t)},T=function(t){return t.split(/[\n\f\r\u000B]/)},P=function(t){return t.replace(/[\n\f\r\u000B]/g," ")},z=function(t,e){var n,r=t.charCodeAt(e),i=e+1,o=1;return r>=55296&&r<=56319&&t.length>i&&(n=t.charCodeAt(i))>=56320&&n<=57343&&(o=2),[t.slice(e,e+o),o]},R=function(t){for(var e=[],n=0,r=t.length;n<r;){var i=z(t,n),o=i[0],a=i[1];e.push(o),n+=a}return e},O=function(t,e,n,r){for(var i=function(t){for(var e=C.join("|"),n=["$"],r=0,i=t.length;r<i;r++){var o=t[r];if(A(o))throw new TypeError("`wordBreak` must not include "+e);n.push(""===o?".":F(o))}var a=n.join("|");return new RegExp("("+e+")|((.*?)("+a+"))","gm")}(e),o=S(t).match(i),a="",s=0,l=[],h=function(){""!==a&&l.push(a),a="",s=0},u=0,c=o.length;u<c;u++){var d=o[u];if(A(d))h();else{var f=r(d);s+f>n&&h(),a+=d,s+=f}}return h(),l},D=/^D:(\d\d\d\d)(\d\d)?(\d\d)?(\d\d)?(\d\d)?(\d\d)?([+\-Z])?(\d\d)?'?(\d\d)?'?$/,B=function(t){var e=t.match(D);if(e){var n=e[1],r=e[2],i=void 0===r?"01":r,o=e[3],a=void 0===o?"01":o,s=e[4],l=void 0===s?"00":s,h=e[5],u=void 0===h?"00":h,c=e[6],d=void 0===c?"00":c,f=e[7],p=void 0===f?"Z":f,g=e[8],v=void 0===g?"00":g,y=e[9];return new Date(n+"-"+i+"-"+a+"T"+l+":"+u+":"+d+("Z"===p?"Z":""+p+v+":"+(void 0===y?"00":y)))}},N=function(t,e){for(var n,r,i=0;i<t.length;){var o=t.substring(i).match(e);if(!o)return{match:r,pos:i};r=o,i+=(null!==(n=o.index)&&void 0Line truncated
//# sourceMappingURL=pdf-lib.min.js.map
+22
View File
@@ -0,0 +1,22 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2023 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/
!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=t.pdfjsLib=e():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf",[],(()=>t.pdfjsLib=e())):"object"==typeof exports?exports["pdfjs-dist/build/pdf"]=t.pdfjsLib=e():t["pdfjs-dist/build/pdf"]=t.pdfjsLib=e()}(globalThis,(()=>(()=>{"use strict";var __webpack_modules__=[,(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.VerbosityLevel=e.Util=e.UnknownErrorException=e.UnexpectedResponseException=e.TextRenderingMode=e.RenderingIntentFlag=e.PromiseCapability=e.PermissionFlag=e.PasswordResponses=e.PasswordException=e.PageActionEventType=e.OPS=e.MissingPDFException=e.MAX_IMAGE_SIZE_TO_CACHE=e.LINE_FACTOR=e.LINE_DESCENT_FACTOR=e.InvalidPDFException=e.ImageKind=e.IDENTITY_MATRIX=e.FormatError=e.FeatureTest=e.FONT_IDENTITY_MATRIX=e.DocumentActionEventType=e.CMapCompressionType=e.BaseException=e.BASELINE_FACTOR=e.AnnotationType=e.AnnotationReplyType=e.AnnotationPrefix=e.AnnotationMode=e.AnnotationFlag=e.AnnotationFieldFlag=e.AnnotationEditorType=e.AnnotationEditorPrefix=e.AnnotationEditorParamsType=e.AnnotationBorderStyleType=e.AnnotationActionEventType=e.AbortException=void 0;e.assert=function assert(t,e){t||unreachable(e)};e.bytesToString=bytesToString;e.createValidAbsoluteUrl=function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=stringToUTF8String(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(s))return s}catch{}return null};e.getModificationDate=function getModificationDate(t=new Date){return[t.getUTCFullYear().toString(),(t.getUTCMonth()+1).toString().padStart(2,"0"),t.getUTCDate().toString().padStart(2,"0"),t.getUTCHours().toString().padStart(2,"0"),t.getUTCMinutes().toString().padStart(2,"0"),t.getUTCSeconds().toString().padStart(2,"0")].join("")};e.getUuid=function getUuid(){if("undefined"!=typeof crypto&&"function"==typeof crypto?.randomUUID)return crypto.randomUUID();const t=new Uint8Array(32);if("undefined"!=typeof crypto&&"function"==typeof crypto?.getRandomValues)crypto.getRandomValues(t);else for(let e=0;e<32;e++)t[e]=Math.floor(255*Math.random());return bytesToString(t)};e.getVerbosityLevel=function getVerbosityLevel(){return n};e.info=function info(t){n>=s.INFOS&&console.log(`Info: ${t}`)};e.isArrayBuffer=function isArrayBuffer(t){return"object"==typeof t&&void 0!==t?.byteLength};e.isArrayEqual=function isArrayEqual(t,e){if(t.length!==e.length)return!1;for(let i=0,s=t.length;i<s;i++)if(t[i]!==e[i])return!1;return!0};e.isNodeJS=void 0;e.normalizeUnicode=function normalizeUnicode(t){if(!l){l=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;h=new Map([["ſt","ſt"]])}return t.replaceAll(l,((t,e,i)=>e?e.normalize("NFKC"):h.get(i)))};e.objectFromMap=function objectFromMap(t){const e=Object.create(null);for(const[i,s]of t)e[i]=s;return e};e.objectSize=function objectSize(t){return Object.keys(t).length};e.setVerbosityLevel=function setVerbosityLevel(t){Number.isInteger(t)&&(n=t)};e.shadow=shadow;e.string32=function string32(t){return String.fromCharCode(t>>24&255,t>>16&255,t>>8&255,255&t)};e.stringToBytes=stringToBytes;e.stringToPDFString=function stringToPDFString(t){if(t[0]>="ï"){let e;"þ"===t[0]&&"ÿ"===t[1]?e="utf-16be":"ÿ"===t[0]&&"þ"===t[1]?e="utf-16le":"ï"===t[0]&&"»"===t[1]&&"¿"===t[2]&&(e="utf-8");if(e)try{const i=new TextDecoder(e,{fatal:!0}),s=stringToBytes(t);return i.decode(s)}catch(t){warn(`stringToPDFString: "${t}".`)}}const e=[];for(let i=0,s=t.length;i<s;i++){const s=o[t.charCodeAt(i)];e.push(s?String.fromCharCode(s):t.charAt(i))}return e.join("")};e.stringToUTF8String=stringToUTF8String;e.unreachable=unreachable;e.utf8StringToString=function utf8StringToString(t){return unescape(encodeURIComponent(t))};e.warn=warn;const i=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type);e.isNodeJS=i;e.IDENTITY_MATRIX=[1,0,0,1,0,0];e.FONT_IDENTITY_MATRIX=[.001,0,0,.001,0,0];e.MAX_IMAGE_SIZE_TO_CACHE=1e7;e.LINE_FACTOR=1.35;e.LINE_DESCENT_FACTOR=.35;e.BASELINE_FACTOR=.25925925925925924;e.RenderingIntentFlag={ANY:1,DISPLAY:2,PRINT:4,SAVE:8,ANNOTATIONS_FORMS:16,ANNOTATIONS_STORAGE:32,ANNOTATIONS_DISABLE:64,OPLIST:256};e.AnnotationMode={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3};e.AnnotationEditorPrefix="pdfjs_internal_editor_";e.AnnotationEditorType={DISABLE:-1,NONE:0,FREETEXT:3,STLine truncated
+3
View File
@@ -0,0 +1,3 @@
/*! For license information please see quill.js.LICENSE.txt */
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Quill=e():t.Quill=e()}(self,(function(){return function(){var t={9698:function(t,e,n){"use strict";n.d(e,{Ay:function(){return c},Ji:function(){return d},mG:function(){return h},zo:function(){return u}});var r=n(6003),i=n(5232),s=n.n(i),o=n(3036),l=n(4850),a=n(5508);class c extends r.BlockBlot{cache={};delta(){return null==this.cache.delta&&(this.cache.delta=h(this)),this.cache.delta}deleteAt(t,e){super.deleteAt(t,e),this.cache={}}formatAt(t,e,n,i){e<=0||(this.scroll.query(n,r.Scope.BLOCK)?t+e===this.length()&&this.format(n,i):super.formatAt(t,Math.min(e,this.length()-t-1),n,i),this.cache={})}insertAt(t,e,n){if(null!=n)return super.insertAt(t,e,n),void(this.cache={});if(0===e.length)return;const r=e.split("\n"),i=r.shift();i.length>0&&(t<this.length()-1||null==this.children.tail?super.insertAt(Math.min(t,this.length()-1),i):this.children.tail.insertAt(this.children.tail.length(),i),this.cache={});let s=this;r.reduce(((t,e)=>(s=s.split(t,!0),s.insertAt(0,e),e.length)),t+i.length)}insertBefore(t,e){const{head:n}=this.children;super.insertBefore(t,e),n instanceof o.A&&n.remove(),this.cache={}}length(){return null==this.cache.length&&(this.cache.length=super.length()+1),this.cache.length}moveChildren(t,e){super.moveChildren(t,e),this.cache={}}optimize(t){super.optimize(t),this.cache={}}path(t){return super.path(t,!0)}removeChild(t){super.removeChild(t),this.cache={}}split(t){let e=arguments.length>1&&void 0!==arguments[1]&&arguments[1];if(e&&(0===t||t>=this.length()-1)){const e=this.clone();return 0===t?(this.parent.insertBefore(e,this),this):(this.parent.insertBefore(e,this.next),e)}const n=super.split(t,e);return this.cache={},n}}c.blotName="block",c.tagName="P",c.defaultChild=o.A,c.allowedChildren=[o.A,l.A,r.EmbedBlot,a.A];class u extends r.EmbedBlot{attach(){super.attach(),this.attributes=new r.AttributorStore(this.domNode)}delta(){return(new(s())).insert(this.value(),{...this.formats(),...this.attributes.values()})}format(t,e){const n=this.scroll.query(t,r.Scope.BLOCK_ATTRIBUTE);null!=n&&this.attributes.attribute(n,e)}formatAt(t,e,n,r){this.format(n,r)}insertAt(t,e,n){if(null!=n)return void super.insertAt(t,e,n);const r=e.split("\n"),i=r.pop(),s=r.map((t=>{const e=this.scroll.create(c.blotName);return e.insertAt(0,t),e})),o=this.split(t);s.forEach((t=>{this.parent.insertBefore(t,o)})),i&&this.parent.insertBefore(this.scroll.create("text",i),o)}}function h(t){let e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return t.descendants(r.LeafBlot).reduce(((t,n)=>0===n.length()?t:t.insert(n.value(),d(n,{},e))),new(s())).insert("\n",d(t))}function d(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];return null==t?e:("formats"in t&&"function"==typeof t.formats&&(e={...e,...t.formats()},n&&delete e["code-token"]),null==t.parent||"scroll"===t.parent.statics.blotName||t.parent.statics.scope!==t.statics.scope?e:d(t.parent,e,n))}u.scope=r.Scope.BLOCK_BLOT},3036:function(t,e,n){"use strict";var r=n(6003);class i extends r.EmbedBlot{static value(){}optimize(){(this.prev||this.next)&&this.remove()}length(){return 0}value(){return""}}i.blotName="break",i.tagName="BR",e.A=i},580:function(t,e,n){"use strict";var r=n(6003);class i extends r.ContainerBlot{}e.A=i},4541:function(t,e,n){"use strict";var r=n(6003),i=n(5508);class s extends r.EmbedBlot{static blotName="cursor";static className="ql-cursor";static tagName="span";static CONTENTS="\ufeff";static value(){}constructor(t,e,n){super(t,e),this.selection=n,this.textNode=document.createTextNode(s.CONTENTS),this.domNode.appendChild(this.textNode),this.savedLength=0}detach(){null!=this.parent&&this.parent.removeChild(this)}format(t,e){if(0!==this.savedLength)return void super.format(t,e);let n=this,i=0;for(;null!=n&&n.statics.scope!==r.Scope.BLOCK_BLOT;)i+=n.offset(n.parent),n=n.parent;null!=n&&(this.savedLength=s.CONTENTS.length,n.optimize(),n.formatAt(i,s.CONTENTS.length,t,e),this.savedLength=0)}index(t,e){return t===this.textNode?0:super.index(t,e)}length(){return this.savedLength}position(){return[this.textNode,this.textNode.data.length]}remove(){super.remove(),this.parent=null}restore(){if(this.selection.composing||null==this.parent)return null;const t=this.selection.getNativeRange();for(;null!=this.domNode.lastChild&&this.domNode.lastChild!==this.textNode;)this.domNode.parentNode.insertBefore(this.domNode.lastChild,this.domNode);const e=this.prev instanceof i.A?this.prev:null,n=e?e.length():0,r=this.next instanceof i.A?this.next:null,o=r?r.text:"",{textNode:l}=this,a=l.data.split(s.CONTENTS).join("");let c;if(l.data=s.CONTENTS,e)c=e,(a||r)&&(e.insertAt(e.length(),a+o),r&&r.remove());else if(r)c=r,r.insertAt(0,a);else{const t=document.createTextNode(a);c=this.scroll.create(t),this.parent.insertBefore(c,this)}if(this.reLine truncated
//# sourceMappingURL=quill.js.map
+10
View File
@@ -0,0 +1,10 @@
/*!
* Quill Editor v2.0.2
* https://quilljs.com
* Copyright (c) 2017-2024, Slab
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/
.ql-container{box-sizing:border-box;font-family:Helvetica,Arial,sans-serif;font-size:13px;height:100%;margin:0;position:relative}.ql-container.ql-disabled .ql-tooltip{visibility:hidden}.ql-container:not(.ql-disabled) li[data-list=checked] > .ql-ui,.ql-container:not(.ql-disabled) li[data-list=unchecked] > .ql-ui{cursor:pointer}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-clipboard p{margin:0;padding:0}.ql-editor{box-sizing:border-box;counter-reset:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;line-height:1.42;height:100%;outline:none;overflow-y:auto;padding:12px 15px;tab-size:4;-moz-tab-size:4;text-align:left;white-space:pre-wrap;word-wrap:break-word}.ql-editor > *{cursor:text}.ql-editor p,.ql-editor ol,.ql-editor pre,.ql-editor blockquote,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{margin:0;padding:0}@supports (counter-set:none){.ql-editor p,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{counter-set:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor p,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{counter-reset:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor table{border-collapse:collapse}.ql-editor td{border:1px solid #000;padding:2px 5px}.ql-editor ol{padding-left:1.5em}.ql-editor li{list-style-type:none;padding-left:1.5em;position:relative}.ql-editor li > .ql-ui:before{display:inline-block;margin-left:-1.5em;margin-right:.3em;text-align:right;white-space:nowrap;width:1.2em}.ql-editor li[data-list=checked] > .ql-ui,.ql-editor li[data-list=unchecked] > .ql-ui{color:#777}.ql-editor li[data-list=bullet] > .ql-ui:before{content:'\2022'}.ql-editor li[data-list=checked] > .ql-ui:before{content:'\2611'}.ql-editor li[data-list=unchecked] > .ql-ui:before{content:'\2610'}@supports (counter-set:none){.ql-editor li[data-list]{counter-set:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list]{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered]{counter-increment:list-0}.ql-editor li[data-list=ordered] > .ql-ui:before{content:counter(list-0, decimal) '. '}.ql-editor li[data-list=ordered].ql-indent-1{counter-increment:list-1}.ql-editor li[data-list=ordered].ql-indent-1 > .ql-ui:before{content:counter(list-1, lower-alpha) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-1{counter-set:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-1{counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-2{counter-increment:list-2}.ql-editor li[data-list=ordered].ql-indent-2 > .ql-ui:before{content:counter(list-2, lower-roman) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-2{counter-set:list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-2{counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-3{counter-increment:list-3}.ql-editor li[data-list=ordered].ql-indent-3 > .ql-ui:before{content:counter(list-3, decimal) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-3{counter-set:list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-3{counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-4{counter-increment:list-4}.ql-editor li[data-list=ordered].ql-indent-4 > .ql-ui:before{content:counter(list-4, lower-alpha) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-4{counter-set:list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-4{counter-reset:list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-5{counter-increment:list-5}.ql-editor li[data-list=ordered].ql-indent-5 > .ql-ui:before{content:counter(list-5, lower-roman) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-5{counter-set:list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-5{counter-reset:list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-6{counter-increment:list-6}.ql-editor li[data-list=ordered].ql-indent-6 > .ql-ui:before{content:counter(list-6, decimal) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-6{counter-set:list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-6{counter-reset:list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-7{counter-increment:list-7}.ql-editor li[data-list=ordered].ql-indent-7 > .ql-ui:before{content:coLine truncated
/*# sourceMappingURL=quill.snow.css.map*/
+115 -2
View File
@@ -916,8 +916,8 @@ router.patch('/:id', async (req, res) => {
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
if (updates.title !== undefined) ticketFields.Title = updates.title;
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') ticketFields.CustomerUser = updates.customer_user_id;
// If ticketFields is empty but we have a note or timeUnit to log, supply a dummy field (StateID) so Ticket is not empty
if (Object.keys(ticketFields).length === 0 && (hasNote || (!isNaN(timeUnit) && timeUnit > 0))) {
@@ -986,6 +986,7 @@ router.patch('/:id', async (req, res) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const localNow = getLocalTimestamp();
// If state is changing, check if the target state is a closed state type to auto-unlock
if (updates.ticket_state_id && updates.ticket_state_id !== current.ticket_state_id) {
@@ -1957,6 +1958,118 @@ router.get('/attachments/:id', async (req, res) => {
}
});
// PUT /api/tickets/attachments/:id — Update text attachment content
router.put('/attachments/:id', async (req, res) => {
try {
const { id } = req.params;
const { content } = req.body;
if (typeof content !== 'string') {
return res.status(400).json({ error: 'Contenuto non valido' });
}
const checkRes = await pool.query(
`SELECT filename, content_type FROM article_data_mime_attachment WHERE id = $1`,
[id]
);
if (checkRes.rows.length === 0) {
return res.status(404).json({ error: 'Allegato non trovato' });
}
const contentBuffer = Buffer.from(content, 'utf-8');
await pool.query(
`UPDATE article_data_mime_attachment SET content = $1, content_size = $2 WHERE id = $3`,
[contentBuffer, contentBuffer.length, id]
);
res.json({ message: 'Allegato di testo aggiornato con successo' });
} catch (err) {
console.error('Errore aggiornamento allegato:', err);
res.status(500).json({ error: err.message });
}
});
// POST /api/tickets/attachments/:id/open — Open attachment using OS default app (Word, Excel, etc.)
router.post('/attachments/:id/open', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT filename, content_type, content FROM article_data_mime_attachment WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Allegato non trovato' });
}
const attachment = result.rows[0];
let contentBuffer;
if (attachment.content) {
if (Buffer.isBuffer(attachment.content)) {
const isRawBinary = attachment.content.length > 4 && (
(attachment.content[0] === 0x25 && attachment.content[1] === 0x50 && attachment.content[2] === 0x44 && attachment.content[3] === 0x46) ||
(attachment.content[0] === 0xFF && attachment.content[1] === 0xD8) ||
(attachment.content[0] === 0x89 && attachment.content[1] === 0x50) ||
(attachment.content[0] === 0x50 && attachment.content[1] === 0x4B)
);
if (isRawBinary) {
contentBuffer = attachment.content;
} else {
const str = attachment.content.toString('utf-8');
const cleaned = str.replace(/\s+/g, '');
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
try { contentBuffer = Buffer.from(cleaned, 'base64'); } catch (_) { contentBuffer = attachment.content; }
} else {
contentBuffer = attachment.content;
}
}
} else if (typeof attachment.content === 'string') {
const cleaned = attachment.content.replace(/\s+/g, '');
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
try { contentBuffer = Buffer.from(cleaned, 'base64'); } catch (_) { contentBuffer = Buffer.from(attachment.content, 'utf-8'); }
} else {
contentBuffer = Buffer.from(attachment.content, 'utf-8');
}
} else {
contentBuffer = Buffer.from(attachment.content);
}
} else {
contentBuffer = Buffer.alloc(0);
}
const fs = require('fs');
const path = require('path');
const os = require('os');
const { exec } = require('child_process');
const tempDir = path.join(os.tmpdir(), 'otrs_turbo_attachments');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const safeName = (attachment.filename || `allegato_${id}`).replace(/[^a-zA-Z0-9._-]/g, '_');
const filePath = path.join(tempDir, `${id}_${safeName}`);
fs.writeFileSync(filePath, contentBuffer);
let command = `"" "${filePath}"`;
if (process.platform === 'win32') {
command = `start "" "${filePath}"`;
} else if (process.platform === 'darwin') {
command = `open "${filePath}"`;
} else {
command = `xdg-open "${filePath}"`;
}
exec(command, (err) => {
if (err) {
console.error('Error opening file via OS default app:', err);
return res.status(500).json({ error: 'Impossibile aprire il file con l\'applicazione di sistema: ' + err.message });
}
res.json({ message: 'File aperto con l\'applicazione predefinita di sistema', filePath });
});
} catch (err) {
console.error('Errore apertura allegato:', err);
res.status(500).json({ error: err.message });
}
});
// POST /api/tickets/merge — Merge tickets (Issue #7)
router.post('/merge', async (req, res) => {
const { targetId, sourceIds } = req.body;
+52
View File
@@ -0,0 +1,52 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const vendorDir = path.join(__dirname, '..', 'public', 'vendor');
if (!fs.existsSync(vendorDir)) {
fs.mkdirSync(vendorDir, { recursive: true });
}
const files = [
{ url: 'https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.snow.css', dest: 'quill.snow.css' },
{ url: 'https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js', dest: 'quill.js' },
{ url: 'https://cdn.jsdelivr.net/npm/chart.js', dest: 'chart.js' },
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js', dest: 'pdf.min.js' },
{ url: 'https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js', dest: 'pdf-lib.min.js' }
];
function download(url, destPath) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return download(res.headers.location, destPath).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`Failed to download ${url}: status ${res.statusCode}`));
}
const file = fs.createWriteStream(destPath);
res.pipe(file);
file.on('finish', () => {
file.close(() => {
console.log(`Downloaded: ${path.basename(destPath)} (${fs.statSync(destPath).size} bytes)`);
resolve();
});
});
}).on('error', (err) => {
reject(err);
});
});
}
async function main() {
for (const f of files) {
const destPath = path.join(vendorDir, f.dest);
try {
await download(f.url, destPath);
} catch (err) {
console.error(`Error downloading ${f.dest}:`, err.message);
}
}
}
main();