@autocode-cli/autocode 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Dashboard HTML generation - Full feature parity with legacy autocode.sh
3
3
  */
4
+ import { createRequire } from 'module';
4
5
  import { getColumns } from '../core/column.js';
5
- import { listTickets } from '../core/ticket.js';
6
+ import { listTickets, getTicket } from '../core/ticket.js';
6
7
  import { getWorkflowSummary } from '../core/workflow.js';
7
8
  import { getConfig } from '../utils/config.js';
9
+ const require = createRequire(import.meta.url);
10
+ const pkg = require('../../package.json');
8
11
  /**
9
12
  * Generate the full dashboard HTML
10
13
  */
@@ -115,6 +118,19 @@ export function generateDashboard() {
115
118
  <span>+</span> <span data-i18n="modal.addCriteria">Add criteria</span>
116
119
  </button>
117
120
  </div>
121
+ <div class="attachments-section" id="attachments-section" style="display:none">
122
+ <div class="attachments-header">
123
+ <h3 data-i18n="modal.attachments">Attachments</h3>
124
+ <span class="attachments-count" id="attachments-count">0</span>
125
+ </div>
126
+ <div class="attachments-list" id="attachments-list"></div>
127
+ <div class="attachments-upload">
128
+ <input type="file" id="file-input" multiple style="display:none" onchange="uploadFiles(this.files)">
129
+ <button type="button" class="btn-add" onclick="document.getElementById('file-input').click()">
130
+ <span>📎</span> <span data-i18n="modal.addAttachment">Add file</span>
131
+ </button>
132
+ </div>
133
+ </div>
118
134
  <div class="comments-section" id="comments-section" style="display:none">
119
135
  <div class="comments-header">
120
136
  <h3 data-i18n="modal.comments">Comments</h3>
@@ -175,7 +191,7 @@ export function generateDashboard() {
175
191
  </div>
176
192
 
177
193
  <footer>
178
- <span>AutoCode v2.0 | <span id="time">${timestamp}</span></span>
194
+ <span>AutoCode v${pkg.version} | <span id="time">${timestamp}</span></span>
179
195
  </footer>
180
196
 
181
197
  <script>
@@ -627,12 +643,7 @@ function getStyles() {
627
643
  display: flex;
628
644
  flex-direction: column;
629
645
  gap: 10px;
630
- max-height: 300px;
631
- overflow-y: auto;
632
- padding-right: 4px;
633
646
  }
634
- .comments-list::-webkit-scrollbar { width: 6px; }
635
- .comments-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
636
647
  .no-comments {
637
648
  color: var(--muted);
638
649
  font-size: 13px;
@@ -651,15 +662,37 @@ function getStyles() {
651
662
  }
652
663
  .comment:hover {
653
664
  border-left-color: var(--blue);
654
- transform: translateX(2px);
655
665
  }
656
666
  .comment-meta {
657
667
  display: flex;
658
668
  align-items: center;
659
- justify-content: space-between;
660
- margin-bottom: 8px;
661
- padding-bottom: 8px;
662
- border-bottom: 1px solid var(--border);
669
+ flex-wrap: wrap;
670
+ gap: 8px;
671
+ cursor: pointer;
672
+ user-select: none;
673
+ }
674
+ .comment-meta::before {
675
+ content: '▶';
676
+ font-size: 10px;
677
+ color: var(--muted);
678
+ transition: transform 0.2s ease;
679
+ }
680
+ .comment.expanded .comment-meta::before {
681
+ transform: rotate(90deg);
682
+ }
683
+ .comment-text {
684
+ max-height: 0;
685
+ overflow: hidden;
686
+ transition: max-height 0.3s ease, margin-top 0.3s ease, padding-top 0.3s ease;
687
+ margin-top: 0;
688
+ padding-top: 0;
689
+ border-top: none;
690
+ }
691
+ .comment.expanded .comment-text {
692
+ max-height: 500px;
693
+ margin-top: 8px;
694
+ padding-top: 8px;
695
+ border-top: 1px solid var(--border);
663
696
  }
664
697
  .comment-column {
665
698
  font-size: 10px;
@@ -671,6 +704,22 @@ function getStyles() {
671
704
  background: rgba(77,171,247,0.15);
672
705
  border-radius: 4px;
673
706
  }
707
+ .comment-source {
708
+ font-size: 10px;
709
+ padding: 3px 8px;
710
+ border-radius: 4px;
711
+ text-transform: uppercase;
712
+ font-weight: 600;
713
+ letter-spacing: 0.5px;
714
+ }
715
+ .comment-source.user {
716
+ background: #3b82f6;
717
+ color: white;
718
+ }
719
+ .comment-source.claude {
720
+ background: #8b5cf6;
721
+ color: white;
722
+ }
674
723
  .comment-date {
675
724
  font-size: 11px;
676
725
  color: var(--muted);
@@ -746,6 +795,72 @@ function getStyles() {
746
795
  font-size: 13px;
747
796
  }
748
797
 
798
+ /* Attachments Section */
799
+ .attachments-section {
800
+ border-top: 1px solid var(--border);
801
+ padding-top: 16px;
802
+ margin-top: 8px;
803
+ }
804
+ .attachments-header {
805
+ display: flex;
806
+ align-items: center;
807
+ gap: 8px;
808
+ margin-bottom: 12px;
809
+ }
810
+ .attachments-header h3 { font-size: 14px; font-weight: 600; }
811
+ .attachments-count {
812
+ background: var(--blue);
813
+ color: #fff;
814
+ padding: 2px 8px;
815
+ border-radius: 10px;
816
+ font-size: 11px;
817
+ font-weight: 600;
818
+ }
819
+ .attachments-list {
820
+ display: flex;
821
+ flex-wrap: wrap;
822
+ gap: 8px;
823
+ margin-bottom: 12px;
824
+ }
825
+ .attachment-item {
826
+ background: var(--bg);
827
+ border: 1px solid var(--border);
828
+ border-radius: 6px;
829
+ padding: 8px 12px;
830
+ display: flex;
831
+ align-items: center;
832
+ gap: 8px;
833
+ font-size: 13px;
834
+ }
835
+ .attachment-item:hover { border-color: var(--accent); }
836
+ .attachment-icon { font-size: 16px; }
837
+ .attachment-name {
838
+ max-width: 150px;
839
+ overflow: hidden;
840
+ text-overflow: ellipsis;
841
+ white-space: nowrap;
842
+ }
843
+ .attachment-delete {
844
+ background: none;
845
+ border: none;
846
+ color: var(--red);
847
+ cursor: pointer;
848
+ padding: 2px;
849
+ font-size: 14px;
850
+ opacity: 0.7;
851
+ }
852
+ .attachment-delete:hover { opacity: 1; }
853
+ .attachments-upload { margin-top: 8px; }
854
+ .no-attachments {
855
+ color: var(--muted);
856
+ font-size: 13px;
857
+ text-align: center;
858
+ padding: 16px;
859
+ background: var(--bg);
860
+ border-radius: 8px;
861
+ border: 1px dashed var(--border);
862
+ }
863
+
749
864
  .modal-actions {
750
865
  display: flex;
751
866
  gap: 12px;
@@ -960,6 +1075,9 @@ function getScript() {
960
1075
  'modal.selectLabel': 'Select a label...',
961
1076
  'modal.acceptanceCriteria': 'Acceptance criteria',
962
1077
  'modal.addCriteria': 'Add criteria',
1078
+ 'modal.attachments': 'Attachments',
1079
+ 'modal.addAttachment': 'Add file',
1080
+ 'modal.noAttachments': 'No attachments',
963
1081
  'modal.comments': 'Comments',
964
1082
  'modal.noComments': 'No comments',
965
1083
  'modal.addCommentPlaceholder': 'Add a comment...',
@@ -1046,6 +1164,9 @@ function getScript() {
1046
1164
  'modal.selectLabel': 'Sélectionner un label...',
1047
1165
  'modal.acceptanceCriteria': 'Critères d\\'acceptation',
1048
1166
  'modal.addCriteria': 'Ajouter un critère',
1167
+ 'modal.attachments': 'Pièces jointes',
1168
+ 'modal.addAttachment': 'Ajouter un fichier',
1169
+ 'modal.noAttachments': 'Aucune pièce jointe',
1049
1170
  'modal.comments': 'Commentaires',
1050
1171
  'modal.noComments': 'Aucun commentaire',
1051
1172
  'modal.addCommentPlaceholder': 'Ajouter un commentaire...',
@@ -1106,7 +1227,7 @@ function getScript() {
1106
1227
  }
1107
1228
  };
1108
1229
 
1109
- let currentLang = localStorage.getItem('autocode-ui-lang') || 'en';
1230
+ let currentLang = localStorage.getItem('autocode-lang') || 'fr';
1110
1231
 
1111
1232
  function t(key) {
1112
1233
  return translations[currentLang][key] || translations['en'][key] || key;
@@ -1114,8 +1235,7 @@ function getScript() {
1114
1235
 
1115
1236
  function switchLanguage(lang) {
1116
1237
  currentLang = lang;
1117
- currentActionLang = lang; // Sync ACTION file language
1118
- localStorage.setItem('autocode-ui-lang', lang);
1238
+ currentActionLang = lang;
1119
1239
  localStorage.setItem('autocode-lang', lang);
1120
1240
  document.documentElement.lang = lang;
1121
1241
 
@@ -1163,7 +1283,7 @@ function getScript() {
1163
1283
  let draggedTicket = null;
1164
1284
  let draggedFromColumn = null;
1165
1285
  let currentActionSlug = null;
1166
- let currentActionLang = localStorage.getItem('autocode-lang') || 'en';
1286
+ let currentActionLang = localStorage.getItem('autocode-lang') || 'fr';
1167
1287
  let originalActionContent = '';
1168
1288
  let claudeProcessingTickets = new Set(); // Tickets currently being processed by Claude
1169
1289
 
@@ -1290,6 +1410,7 @@ function getScript() {
1290
1410
  const nextBtn = document.getElementById('btn-next');
1291
1411
  const archiveBtn = document.getElementById('btn-archive');
1292
1412
  const commentsSection = document.getElementById('comments-section');
1413
+ const attachmentsSection = document.getElementById('attachments-section');
1293
1414
 
1294
1415
  if (key) {
1295
1416
  modalTitle.textContent = t('modal.editTicket') + ' ' + key;
@@ -1297,6 +1418,7 @@ function getScript() {
1297
1418
  nextBtn.style.display = 'inline-block';
1298
1419
  archiveBtn.style.display = 'inline-block';
1299
1420
  commentsSection.style.display = 'block';
1421
+ attachmentsSection.style.display = 'block';
1300
1422
  loadTicketForEdit(key);
1301
1423
  } else {
1302
1424
  modalTitle.textContent = t('modal.newTicket');
@@ -1304,8 +1426,10 @@ function getScript() {
1304
1426
  nextBtn.style.display = 'none';
1305
1427
  archiveBtn.style.display = 'none';
1306
1428
  commentsSection.style.display = 'none';
1429
+ attachmentsSection.style.display = 'none';
1307
1430
  resetForm();
1308
1431
  resetComments();
1432
+ resetAttachments();
1309
1433
  }
1310
1434
  }
1311
1435
 
@@ -1353,6 +1477,9 @@ function getScript() {
1353
1477
 
1354
1478
  renderComments(ticket.comments || []);
1355
1479
 
1480
+ // Fetch attachments
1481
+ loadAttachments(key);
1482
+
1356
1483
  // Fetch Claude log if exists
1357
1484
  fetchLog(key);
1358
1485
  } catch (e) {
@@ -1381,7 +1508,7 @@ function getScript() {
1381
1508
  if (editingKey) {
1382
1509
  btn.textContent = t('btn.updating');
1383
1510
  await fetch('/api/tickets/' + editingKey, {
1384
- method: 'POST',
1511
+ method: 'PATCH',
1385
1512
  headers: { 'Content-Type': 'application/json' },
1386
1513
  body: JSON.stringify({ title, description, priority, semver, labels: selectedLabels, acceptance_criteria: criteria })
1387
1514
  });
@@ -1411,7 +1538,11 @@ function getScript() {
1411
1538
  btn.disabled = true;
1412
1539
  btn.textContent = t('btn.moving');
1413
1540
  try {
1414
- await fetch('/api/tickets/' + editingKey + '/next', { method: 'POST' });
1541
+ await fetch('/api/tickets/' + editingKey + '/next', {
1542
+ method: 'POST',
1543
+ headers: { 'Content-Type': 'application/json' },
1544
+ body: JSON.stringify({ lang: currentLang })
1545
+ });
1415
1546
  showNotification('info', t('notify.ticketAdvanced'), editingKey);
1416
1547
  closeModal();
1417
1548
  loadTicketsFromAPI();
@@ -1434,7 +1565,7 @@ function getScript() {
1434
1565
  await fetch('/api/tickets/' + editingKey + '/move', {
1435
1566
  method: 'POST',
1436
1567
  headers: { 'Content-Type': 'application/json' },
1437
- body: JSON.stringify({ column: lastColumn.name, force: true })
1568
+ body: JSON.stringify({ column: lastColumn.name, force: true, lang: currentLang })
1438
1569
  });
1439
1570
  showNotification('info', t('notify.ticketArchived'), editingKey);
1440
1571
  closeModal();
@@ -1513,16 +1644,31 @@ function getScript() {
1513
1644
  }
1514
1645
 
1515
1646
  const sorted = [...currentComments].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1516
- list.innerHTML = sorted.map(comment => {
1647
+ list.innerHTML = sorted.map((comment, index) => {
1517
1648
  const date = new Date(comment.created_at);
1518
1649
  const dateStr = date.toLocaleDateString('en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
1519
- return '<div class="comment">' +
1520
- '<div class="comment-meta"><span class="comment-column">' + (comment.column || 'N/A') + '</span>' +
1650
+ const source = comment.source || 'user';
1651
+ const sourceBadge = source === 'claude'
1652
+ ? '<span class="comment-source claude">Claude</span>'
1653
+ : '<span class="comment-source user">User</span>';
1654
+ return '<div class="comment" id="comment-' + index + '">' +
1655
+ '<div class="comment-meta" onclick="toggleComment(' + index + ')">' + sourceBadge + '<span class="comment-column">' + (comment.column || 'N/A') + '</span>' +
1521
1656
  '<span class="comment-date">' + dateStr + '</span></div>' +
1522
1657
  '<div class="comment-text">' + renderMarkdown(comment.text) + '</div></div>';
1523
1658
  }).join('');
1524
1659
  }
1525
1660
 
1661
+ function toggleComment(index) {
1662
+ const comments = document.querySelectorAll('.comment');
1663
+ comments.forEach((comment, i) => {
1664
+ if (i === index) {
1665
+ comment.classList.toggle('expanded');
1666
+ } else {
1667
+ comment.classList.remove('expanded');
1668
+ }
1669
+ });
1670
+ }
1671
+
1526
1672
  function renderMarkdown(text) {
1527
1673
  if (!text) return '';
1528
1674
  let html = escapeHtml(text);
@@ -1557,7 +1703,9 @@ function getScript() {
1557
1703
  });
1558
1704
  const result = await res.json();
1559
1705
  textarea.value = '';
1560
- if (result.comments) renderComments(result.comments);
1706
+ if (result.success && result.data && result.data.comments) {
1707
+ renderComments(result.data.comments);
1708
+ }
1561
1709
  showNotification('success', t('notify.commentAdded'), '');
1562
1710
  } catch (e) {
1563
1711
  showNotification('error', t('notify.error'), e.message);
@@ -1567,6 +1715,112 @@ function getScript() {
1567
1715
  }
1568
1716
  }
1569
1717
 
1718
+ // ========================================
1719
+ // ATTACHMENTS
1720
+ // ========================================
1721
+ let currentAttachments = [];
1722
+
1723
+ function resetAttachments() {
1724
+ currentAttachments = [];
1725
+ document.getElementById('attachments-list').innerHTML = '<div class="no-attachments">' + t('modal.noAttachments') + '</div>';
1726
+ document.getElementById('attachments-count').textContent = '0';
1727
+ document.getElementById('file-input').value = '';
1728
+ }
1729
+
1730
+ async function loadAttachments(key) {
1731
+ try {
1732
+ const res = await fetch('/api/tickets/' + key + '/attachments');
1733
+ const json = await res.json();
1734
+ if (json.success) {
1735
+ currentAttachments = json.data || [];
1736
+ renderAttachments();
1737
+ }
1738
+ } catch (e) {
1739
+ console.error('Error loading attachments:', e);
1740
+ }
1741
+ }
1742
+
1743
+ function renderAttachments() {
1744
+ const list = document.getElementById('attachments-list');
1745
+ const count = document.getElementById('attachments-count');
1746
+ count.textContent = currentAttachments.length;
1747
+
1748
+ if (currentAttachments.length === 0) {
1749
+ list.innerHTML = '<div class="no-attachments">' + t('modal.noAttachments') + '</div>';
1750
+ return;
1751
+ }
1752
+
1753
+ list.innerHTML = currentAttachments.map(filename => {
1754
+ const ext = filename.split('.').pop().toLowerCase();
1755
+ const icon = getFileIcon(ext);
1756
+ return '<div class="attachment-item">' +
1757
+ '<span class="attachment-icon">' + icon + '</span>' +
1758
+ '<span class="attachment-name" title="' + escapeHtml(filename) + '">' + escapeHtml(filename) + '</span>' +
1759
+ '<button class="attachment-delete" onclick="deleteAttachment(\\'' + escapeHtml(filename) + '\\')" title="Delete">&times;</button>' +
1760
+ '</div>';
1761
+ }).join('');
1762
+ }
1763
+
1764
+ function getFileIcon(ext) {
1765
+ const icons = {
1766
+ pdf: '📄', doc: '📝', docx: '📝', txt: '📝',
1767
+ png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
1768
+ mp4: '🎬', mov: '🎬', avi: '🎬',
1769
+ mp3: '🎵', wav: '🎵',
1770
+ zip: '📦', rar: '📦', tar: '📦', gz: '📦',
1771
+ js: '📜', ts: '📜', py: '📜', json: '📜', md: '📜',
1772
+ };
1773
+ return icons[ext] || '📎';
1774
+ }
1775
+
1776
+ async function uploadFiles(files) {
1777
+ if (!editingKey || files.length === 0) return;
1778
+
1779
+ const formData = new FormData();
1780
+ for (const file of files) {
1781
+ formData.append('files', file, file.name);
1782
+ }
1783
+
1784
+ try {
1785
+ const res = await fetch('/api/tickets/' + editingKey + '/attachments', {
1786
+ method: 'POST',
1787
+ body: formData
1788
+ });
1789
+ const json = await res.json();
1790
+ if (json.success) {
1791
+ showNotification('success', 'Files uploaded', json.data.join(', '));
1792
+ loadAttachments(editingKey);
1793
+ } else {
1794
+ showNotification('error', 'Upload failed', json.error);
1795
+ }
1796
+ } catch (e) {
1797
+ showNotification('error', 'Upload error', e.message);
1798
+ }
1799
+
1800
+ // Reset file input
1801
+ document.getElementById('file-input').value = '';
1802
+ }
1803
+
1804
+ async function deleteAttachment(filename) {
1805
+ if (!editingKey) return;
1806
+ if (!confirm('Delete ' + filename + '?')) return;
1807
+
1808
+ try {
1809
+ const res = await fetch('/api/tickets/' + editingKey + '/attachments/' + encodeURIComponent(filename), {
1810
+ method: 'DELETE'
1811
+ });
1812
+ const json = await res.json();
1813
+ if (json.success) {
1814
+ showNotification('info', 'File deleted', filename);
1815
+ loadAttachments(editingKey);
1816
+ } else {
1817
+ showNotification('error', 'Delete failed', json.error);
1818
+ }
1819
+ } catch (e) {
1820
+ showNotification('error', 'Delete error', e.message);
1821
+ }
1822
+ }
1823
+
1570
1824
  // ========================================
1571
1825
  // DRAG & DROP
1572
1826
  // ========================================
@@ -1620,7 +1874,7 @@ function getScript() {
1620
1874
  await fetch('/api/tickets/' + key + '/move', {
1621
1875
  method: 'POST',
1622
1876
  headers: { 'Content-Type': 'application/json' },
1623
- body: JSON.stringify({ column: targetColumnName, force: true })
1877
+ body: JSON.stringify({ column: targetColumnName, force: true, lang: currentLang })
1624
1878
  });
1625
1879
  showNotification('info', key + ' ' + t('notify.ticketMoved'), t('notify.moveTo') + ' "' + targetColumnName + '"');
1626
1880
  loadTicketsFromAPI();
@@ -1662,7 +1916,7 @@ function getScript() {
1662
1916
  await fetch('/api/tickets/' + key + '/move', {
1663
1917
  method: 'POST',
1664
1918
  headers: { 'Content-Type': 'application/json' },
1665
- body: JSON.stringify({ column: lastColumn.name, force: true })
1919
+ body: JSON.stringify({ column: lastColumn.name, force: true, lang: currentLang })
1666
1920
  });
1667
1921
  showNotification('info', t('notify.ticketArchived'), key);
1668
1922
  loadTicketsFromAPI();
@@ -1947,7 +2201,7 @@ function getScript() {
1947
2201
  }
1948
2202
 
1949
2203
  function onTicketClick(key) {
1950
- openModal(key);
2204
+ window.location.href = '/ticket/' + key;
1951
2205
  }
1952
2206
 
1953
2207
  // ========================================
@@ -2119,9 +2373,9 @@ export function generateColumnEditPage(slug, lang) {
2119
2373
  <header class="header">
2120
2374
  <a href="/" class="back-btn">← Dashboard</a>
2121
2375
  <h1 class="title">${escapeHtml(columnName)} <span>/ ACTION.md</span></h1>
2122
- <div class="lang-selector">
2123
- <button class="lang-btn ${lang === 'en' ? 'active' : ''}" data-lang="en">EN</button>
2124
- <button class="lang-btn ${lang === 'fr' ? 'active' : ''}" data-lang="fr">FR</button>
2376
+ <div class="lang-selector" id="lang-selector">
2377
+ <button class="lang-btn" data-lang="en">EN</button>
2378
+ <button class="lang-btn" data-lang="fr">FR</button>
2125
2379
  </div>
2126
2380
  <div class="actions">
2127
2381
  <button class="btn btn-save" id="saveBtn" disabled>Save</button>
@@ -2137,8 +2391,9 @@ export function generateColumnEditPage(slug, lang) {
2137
2391
  <div class="notification" id="notification"></div>
2138
2392
 
2139
2393
  <script>
2394
+ const STORAGE_KEY = 'autocode-lang';
2140
2395
  const slug = '${slug}';
2141
- let currentLang = '${lang}';
2396
+ let currentLang = localStorage.getItem(STORAGE_KEY) || 'fr';
2142
2397
  let originalContent = '';
2143
2398
  let hasChanges = false;
2144
2399
 
@@ -2148,6 +2403,14 @@ export function generateColumnEditPage(slug, lang) {
2148
2403
  const pathEl = document.getElementById('path');
2149
2404
  const notification = document.getElementById('notification');
2150
2405
 
2406
+ // Update UI to reflect current language
2407
+ function updateLangUI() {
2408
+ document.querySelectorAll('.lang-btn').forEach(btn => {
2409
+ btn.classList.toggle('active', btn.dataset.lang === currentLang);
2410
+ });
2411
+ pathEl.textContent = slug + '/ACTION.' + currentLang + '.md';
2412
+ }
2413
+
2151
2414
  // Load content
2152
2415
  async function loadContent() {
2153
2416
  try {
@@ -2237,8 +2500,8 @@ export function generateColumnEditPage(slug, lang) {
2237
2500
  return;
2238
2501
  }
2239
2502
  currentLang = newLang;
2240
- document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
2241
- btn.classList.add('active');
2503
+ localStorage.setItem(STORAGE_KEY, newLang);
2504
+ updateLangUI();
2242
2505
  loadContent();
2243
2506
  }
2244
2507
  });
@@ -2253,9 +2516,1197 @@ export function generateColumnEditPage(slug, lang) {
2253
2516
  });
2254
2517
 
2255
2518
  // Init
2519
+ updateLangUI();
2256
2520
  loadContent();
2257
2521
  </script>
2258
2522
  </body>
2259
2523
  </html>`;
2260
2524
  }
2525
+ /**
2526
+ * Generate ticket view page
2527
+ */
2528
+ export function generateTicketViewPage(ticketKey, lang) {
2529
+ const config = getConfig();
2530
+ const ticket = getTicket(config.root, ticketKey);
2531
+ const columns = getColumns();
2532
+ // 404 page if ticket not found
2533
+ if (!ticket) {
2534
+ return `<!DOCTYPE html>
2535
+ <html lang="${lang}">
2536
+ <head>
2537
+ <meta charset="UTF-8">
2538
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2539
+ <title>Ticket Not Found - AutoCode</title>
2540
+ <style>
2541
+ :root {
2542
+ --bg: #0a0a0f;
2543
+ --text: #f1f5f9;
2544
+ --accent: #6366f1;
2545
+ }
2546
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2547
+ body {
2548
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2549
+ background: var(--bg);
2550
+ color: var(--text);
2551
+ min-height: 100vh;
2552
+ display: flex;
2553
+ align-items: center;
2554
+ justify-content: center;
2555
+ }
2556
+ .not-found {
2557
+ text-align: center;
2558
+ padding: 48px;
2559
+ }
2560
+ h1 { font-size: 2rem; margin-bottom: 16px; }
2561
+ p { color: #94a3b8; margin-bottom: 24px; }
2562
+ a {
2563
+ display: inline-block;
2564
+ background: var(--accent);
2565
+ color: white;
2566
+ padding: 12px 24px;
2567
+ border-radius: 8px;
2568
+ text-decoration: none;
2569
+ font-weight: 500;
2570
+ }
2571
+ a:hover { opacity: 0.9; }
2572
+ </style>
2573
+ </head>
2574
+ <body>
2575
+ <div class="not-found">
2576
+ <h1>Ticket Not Found</h1>
2577
+ <p>Ticket ${escapeHtml(ticketKey)} does not exist or has been archived.</p>
2578
+ <a href="/">← Back to Dashboard</a>
2579
+ </div>
2580
+ </body>
2581
+ </html>`;
2582
+ }
2583
+ const currentColumn = columns.find(c => c.slug === ticket.column_slug);
2584
+ const ticketData = JSON.stringify(ticket);
2585
+ const columnsData = JSON.stringify(columns);
2586
+ return `<!DOCTYPE html>
2587
+ <html lang="${lang}">
2588
+ <head>
2589
+ <meta charset="UTF-8">
2590
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2591
+ <title>${escapeHtml(ticket.title)} - ${ticketKey} - AutoCode</title>
2592
+ <style>
2593
+ :root {
2594
+ --bg: #0a0a0f;
2595
+ --bg-secondary: #12121a;
2596
+ --bg-tertiary: #1a1a24;
2597
+ --text: #f1f5f9;
2598
+ --muted: #94a3b8;
2599
+ --border: #2a2a3a;
2600
+ --accent: #6366f1;
2601
+ --blue: #4dabf7;
2602
+ --green: #4ade80;
2603
+ --yellow: #facc15;
2604
+ --orange: #fb923c;
2605
+ --red: #f87171;
2606
+ --purple: #a78bfa;
2607
+ }
2608
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2609
+ body {
2610
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2611
+ background: var(--bg);
2612
+ color: var(--text);
2613
+ min-height: 100vh;
2614
+ }
2615
+ .header {
2616
+ display: flex;
2617
+ align-items: center;
2618
+ gap: 24px;
2619
+ padding: 16px 24px;
2620
+ background: var(--bg-secondary);
2621
+ border-bottom: 1px solid var(--border);
2622
+ position: sticky;
2623
+ top: 0;
2624
+ z-index: 100;
2625
+ }
2626
+ .back-btn {
2627
+ color: var(--muted);
2628
+ text-decoration: none;
2629
+ font-size: 14px;
2630
+ display: flex;
2631
+ align-items: center;
2632
+ gap: 8px;
2633
+ }
2634
+ .back-btn:hover { color: var(--text); }
2635
+ .ticket-header-info {
2636
+ flex: 1;
2637
+ display: flex;
2638
+ align-items: center;
2639
+ gap: 16px;
2640
+ }
2641
+ .ticket-key {
2642
+ font-family: 'SF Mono', Monaco, monospace;
2643
+ font-size: 12px;
2644
+ color: var(--accent);
2645
+ background: rgba(99, 102, 241, 0.15);
2646
+ padding: 4px 10px;
2647
+ border-radius: 4px;
2648
+ font-weight: 600;
2649
+ }
2650
+ .ticket-title {
2651
+ font-size: 18px;
2652
+ font-weight: 600;
2653
+ }
2654
+ .lang-selector {
2655
+ display: flex;
2656
+ gap: 4px;
2657
+ }
2658
+ .lang-btn {
2659
+ background: transparent;
2660
+ border: 1px solid var(--border);
2661
+ color: var(--muted);
2662
+ padding: 6px 12px;
2663
+ border-radius: 4px;
2664
+ cursor: pointer;
2665
+ font-size: 12px;
2666
+ font-weight: 500;
2667
+ }
2668
+ .lang-btn:hover { border-color: var(--accent); color: var(--text); }
2669
+ .lang-btn.active { background: var(--accent); border-color: var(--accent); color: white; }
2670
+ .ticket-title-input {
2671
+ flex: 1;
2672
+ background: transparent;
2673
+ border: 1px solid transparent;
2674
+ color: var(--text);
2675
+ font-size: 18px;
2676
+ font-weight: 600;
2677
+ padding: 4px 8px;
2678
+ border-radius: 4px;
2679
+ font-family: inherit;
2680
+ }
2681
+ .ticket-title-input:hover { border-color: var(--border); }
2682
+ .ticket-title-input:focus {
2683
+ outline: none;
2684
+ border-color: var(--accent);
2685
+ background: var(--bg-tertiary);
2686
+ }
2687
+ .section-title {
2688
+ display: flex;
2689
+ align-items: center;
2690
+ justify-content: space-between;
2691
+ }
2692
+ .btn-edit-toggle {
2693
+ background: transparent;
2694
+ border: none;
2695
+ cursor: pointer;
2696
+ font-size: 14px;
2697
+ opacity: 0.5;
2698
+ transition: opacity 0.2s;
2699
+ }
2700
+ .btn-edit-toggle:hover { opacity: 1; }
2701
+ .btn-edit-toggle.active { opacity: 1; }
2702
+ .description-view {
2703
+ line-height: 1.7;
2704
+ color: var(--text);
2705
+ }
2706
+ .description-view p { margin-bottom: 12px; }
2707
+ .description-view h1, .description-view h2, .description-view h3, .description-view h4 {
2708
+ margin: 16px 0 8px;
2709
+ font-weight: 600;
2710
+ }
2711
+ .description-view code {
2712
+ background: var(--bg-tertiary);
2713
+ padding: 2px 6px;
2714
+ border-radius: 4px;
2715
+ font-family: 'SF Mono', Monaco, monospace;
2716
+ font-size: 13px;
2717
+ }
2718
+ .description-view pre {
2719
+ background: var(--bg-tertiary);
2720
+ padding: 12px;
2721
+ border-radius: 6px;
2722
+ overflow-x: auto;
2723
+ }
2724
+ .description-view ul, .description-view ol {
2725
+ margin: 8px 0;
2726
+ padding-left: 24px;
2727
+ }
2728
+ .description-view a { color: var(--accent); }
2729
+ .description-edit {
2730
+ width: 100%;
2731
+ min-height: 200px;
2732
+ padding: 12px;
2733
+ background: var(--bg-tertiary);
2734
+ border: 1px solid var(--border);
2735
+ border-radius: 8px;
2736
+ color: var(--text);
2737
+ font-family: 'SF Mono', Monaco, monospace;
2738
+ font-size: 13px;
2739
+ line-height: 1.6;
2740
+ resize: vertical;
2741
+ }
2742
+ .description-edit:focus {
2743
+ outline: none;
2744
+ border-color: var(--accent);
2745
+ }
2746
+ .main-content {
2747
+ display: flex;
2748
+ flex-direction: column;
2749
+ gap: 24px;
2750
+ padding: 24px 48px;
2751
+ }
2752
+ .ticket-details { display: flex; flex-direction: column; gap: 24px; }
2753
+ .ticket-bottom {
2754
+ display: flex;
2755
+ flex-direction: column;
2756
+ gap: 24px;
2757
+ }
2758
+ .section {
2759
+ background: var(--bg-secondary);
2760
+ border: 1px solid var(--border);
2761
+ border-radius: 12px;
2762
+ padding: 20px;
2763
+ }
2764
+ .section-title {
2765
+ font-size: 12px;
2766
+ font-weight: 600;
2767
+ text-transform: uppercase;
2768
+ letter-spacing: 0.5px;
2769
+ color: var(--muted);
2770
+ margin-bottom: 16px;
2771
+ }
2772
+ .ticket-meta {
2773
+ display: flex;
2774
+ flex-wrap: wrap;
2775
+ gap: 12px;
2776
+ }
2777
+ .meta-badge {
2778
+ font-size: 11px;
2779
+ font-weight: 600;
2780
+ text-transform: uppercase;
2781
+ letter-spacing: 0.5px;
2782
+ padding: 5px 12px;
2783
+ border-radius: 6px;
2784
+ }
2785
+ .priority-P0 { background: rgba(248, 113, 113, 0.2); color: var(--red); }
2786
+ .priority-P1 { background: rgba(251, 146, 60, 0.2); color: var(--orange); }
2787
+ .priority-P2 { background: rgba(250, 204, 21, 0.2); color: var(--yellow); }
2788
+ .priority-P3 { background: rgba(148, 163, 184, 0.2); color: var(--muted); }
2789
+ .column-badge { background: rgba(77, 171, 247, 0.15); color: var(--blue); }
2790
+ .semver-badge { background: rgba(167, 139, 250, 0.15); color: var(--purple); }
2791
+ .labels-list {
2792
+ display: flex;
2793
+ flex-wrap: wrap;
2794
+ gap: 8px;
2795
+ }
2796
+ .label-tag {
2797
+ font-size: 11px;
2798
+ padding: 4px 10px;
2799
+ border-radius: 12px;
2800
+ background: var(--bg-tertiary);
2801
+ color: var(--text);
2802
+ border: 1px solid var(--border);
2803
+ }
2804
+ .description-content {
2805
+ line-height: 1.7;
2806
+ color: var(--text);
2807
+ }
2808
+ .description-content p { margin-bottom: 12px; }
2809
+ .description-content code {
2810
+ background: var(--bg-tertiary);
2811
+ padding: 2px 6px;
2812
+ border-radius: 4px;
2813
+ font-family: 'SF Mono', Monaco, monospace;
2814
+ font-size: 13px;
2815
+ }
2816
+ .criteria-list { list-style: none; }
2817
+ .criteria-item {
2818
+ padding: 12px 16px;
2819
+ background: var(--bg-tertiary);
2820
+ border-radius: 8px;
2821
+ margin-bottom: 8px;
2822
+ display: flex;
2823
+ align-items: flex-start;
2824
+ gap: 12px;
2825
+ }
2826
+ .criteria-item::before {
2827
+ content: '☐';
2828
+ color: var(--muted);
2829
+ }
2830
+ .history-list { list-style: none; }
2831
+ .history-item {
2832
+ padding: 12px 0;
2833
+ border-bottom: 1px solid var(--border);
2834
+ display: flex;
2835
+ align-items: center;
2836
+ gap: 12px;
2837
+ font-size: 13px;
2838
+ }
2839
+ .history-item:last-child { border-bottom: none; }
2840
+ .history-action {
2841
+ font-weight: 600;
2842
+ text-transform: capitalize;
2843
+ }
2844
+ .history-from, .history-to {
2845
+ padding: 2px 8px;
2846
+ background: var(--bg-tertiary);
2847
+ border-radius: 4px;
2848
+ font-size: 11px;
2849
+ }
2850
+ .history-date { color: var(--muted); margin-left: auto; font-size: 12px; }
2851
+ .btn-prompt {
2852
+ background: none;
2853
+ border: none;
2854
+ cursor: pointer;
2855
+ font-size: 14px;
2856
+ padding: 2px 6px;
2857
+ opacity: 0.6;
2858
+ transition: opacity 0.2s;
2859
+ }
2860
+ .btn-prompt:hover { opacity: 1; }
2861
+ .prompt-modal {
2862
+ display: none;
2863
+ position: fixed;
2864
+ top: 0;
2865
+ left: 0;
2866
+ right: 0;
2867
+ bottom: 0;
2868
+ background: rgba(0, 0, 0, 0.7);
2869
+ z-index: 1000;
2870
+ align-items: center;
2871
+ justify-content: center;
2872
+ }
2873
+ .prompt-modal.visible { display: flex; }
2874
+ .prompt-modal-content {
2875
+ background: var(--bg-primary);
2876
+ border-radius: 12px;
2877
+ max-width: 900px;
2878
+ max-height: 80vh;
2879
+ width: 90%;
2880
+ display: flex;
2881
+ flex-direction: column;
2882
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
2883
+ }
2884
+ .prompt-modal-header {
2885
+ display: flex;
2886
+ justify-content: space-between;
2887
+ align-items: center;
2888
+ padding: 16px 20px;
2889
+ border-bottom: 1px solid var(--border);
2890
+ }
2891
+ .prompt-modal-header h3 { margin: 0; }
2892
+ .prompt-modal-close {
2893
+ background: none;
2894
+ border: none;
2895
+ font-size: 24px;
2896
+ cursor: pointer;
2897
+ color: var(--muted);
2898
+ }
2899
+ .prompt-modal-close:hover { color: var(--text); }
2900
+ .prompt-modal-body {
2901
+ padding: 20px;
2902
+ overflow-y: auto;
2903
+ flex: 1;
2904
+ }
2905
+ .prompt-modal-body pre {
2906
+ white-space: pre-wrap;
2907
+ word-wrap: break-word;
2908
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
2909
+ font-size: 13px;
2910
+ line-height: 1.5;
2911
+ margin: 0;
2912
+ background: var(--bg-secondary);
2913
+ padding: 16px;
2914
+ border-radius: 8px;
2915
+ }
2916
+ .actions-bar {
2917
+ display: flex;
2918
+ gap: 12px;
2919
+ padding: 16px 0;
2920
+ border-top: 1px solid var(--border);
2921
+ margin-top: 8px;
2922
+ }
2923
+ .btn {
2924
+ padding: 12px 20px;
2925
+ border-radius: 8px;
2926
+ font-weight: 500;
2927
+ font-size: 14px;
2928
+ cursor: pointer;
2929
+ border: none;
2930
+ text-align: center;
2931
+ text-decoration: none;
2932
+ }
2933
+ .btn-primary { background: var(--accent); color: white; }
2934
+ .btn-primary:hover { opacity: 0.9; }
2935
+ .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
2936
+ .btn-secondary { background: var(--bg-tertiary); color: var(--text); border: 1px solid var(--border); }
2937
+ .btn-secondary:hover { border-color: var(--accent); }
2938
+ .btn-danger { background: rgba(248, 113, 113, 0.15); color: var(--red); border: 1px solid transparent; }
2939
+ .btn-danger:hover { border-color: var(--red); }
2940
+ .comments-list {
2941
+ display: flex;
2942
+ flex-direction: column;
2943
+ gap: 12px;
2944
+ margin-bottom: 16px;
2945
+ }
2946
+ .comment {
2947
+ padding: 16px;
2948
+ background: var(--bg-tertiary);
2949
+ border-radius: 8px;
2950
+ border-left: 3px solid var(--border);
2951
+ transition: all 0.2s ease;
2952
+ }
2953
+ .comment:hover {
2954
+ border-left-color: var(--blue);
2955
+ }
2956
+ .comment-meta {
2957
+ display: flex;
2958
+ align-items: center;
2959
+ flex-wrap: wrap;
2960
+ gap: 8px;
2961
+ cursor: pointer;
2962
+ user-select: none;
2963
+ }
2964
+ .comment-meta::before {
2965
+ content: '▶';
2966
+ font-size: 10px;
2967
+ color: var(--muted);
2968
+ transition: transform 0.2s ease;
2969
+ }
2970
+ .comment.expanded .comment-meta::before {
2971
+ transform: rotate(90deg);
2972
+ }
2973
+ .comment-source {
2974
+ font-size: 10px;
2975
+ padding: 3px 8px;
2976
+ border-radius: 4px;
2977
+ text-transform: uppercase;
2978
+ font-weight: 600;
2979
+ letter-spacing: 0.5px;
2980
+ }
2981
+ .comment-source.user { background: #3b82f6; color: white; }
2982
+ .comment-source.claude { background: #8b5cf6; color: white; }
2983
+ .comment-column {
2984
+ font-size: 10px;
2985
+ font-weight: 600;
2986
+ text-transform: uppercase;
2987
+ letter-spacing: 0.5px;
2988
+ color: var(--blue);
2989
+ padding: 3px 8px;
2990
+ background: rgba(77,171,247,0.15);
2991
+ border-radius: 4px;
2992
+ }
2993
+ .comment-date {
2994
+ font-size: 11px;
2995
+ color: var(--muted);
2996
+ }
2997
+ .comment-text {
2998
+ font-size: 14px;
2999
+ line-height: 1.6;
3000
+ color: var(--text);
3001
+ max-height: 0;
3002
+ overflow: hidden;
3003
+ transition: max-height 0.3s ease, margin-top 0.3s ease, padding-top 0.3s ease;
3004
+ margin-top: 0;
3005
+ padding-top: 0;
3006
+ }
3007
+ .comment.expanded .comment-text {
3008
+ max-height: 500px;
3009
+ margin-top: 12px;
3010
+ padding-top: 12px;
3011
+ border-top: 1px solid var(--border);
3012
+ }
3013
+ .comment-text code {
3014
+ background: var(--bg);
3015
+ padding: 2px 6px;
3016
+ border-radius: 4px;
3017
+ font-family: 'SF Mono', Monaco, monospace;
3018
+ font-size: 12px;
3019
+ }
3020
+ .add-comment {
3021
+ display: flex;
3022
+ flex-direction: column;
3023
+ gap: 8px;
3024
+ }
3025
+ .add-comment textarea {
3026
+ width: 100%;
3027
+ min-height: 80px;
3028
+ padding: 12px;
3029
+ background: var(--bg-tertiary);
3030
+ border: 1px solid var(--border);
3031
+ border-radius: 8px;
3032
+ color: var(--text);
3033
+ font-family: inherit;
3034
+ font-size: 14px;
3035
+ resize: vertical;
3036
+ }
3037
+ .add-comment textarea:focus {
3038
+ outline: none;
3039
+ border-color: var(--accent);
3040
+ }
3041
+ .btn-comment {
3042
+ align-self: flex-end;
3043
+ padding: 8px 16px;
3044
+ background: var(--accent);
3045
+ color: white;
3046
+ border: none;
3047
+ border-radius: 6px;
3048
+ cursor: pointer;
3049
+ font-size: 13px;
3050
+ font-weight: 500;
3051
+ }
3052
+ .btn-comment:hover { opacity: 0.9; }
3053
+ .btn-comment:disabled { opacity: 0.5; cursor: not-allowed; }
3054
+ .no-comments {
3055
+ text-align: center;
3056
+ color: var(--muted);
3057
+ padding: 24px;
3058
+ font-size: 14px;
3059
+ }
3060
+ .notification {
3061
+ position: fixed;
3062
+ bottom: 24px;
3063
+ right: 24px;
3064
+ padding: 12px 20px;
3065
+ background: var(--green);
3066
+ color: #000;
3067
+ border-radius: 8px;
3068
+ font-weight: 500;
3069
+ transform: translateY(100px);
3070
+ opacity: 0;
3071
+ transition: all 0.3s ease;
3072
+ z-index: 1000;
3073
+ }
3074
+ .notification.show { transform: translateY(0); opacity: 1; }
3075
+ .notification.error { background: var(--red); color: white; }
3076
+
3077
+ /* Claude Terminal */
3078
+ .claude-section .section-title {
3079
+ display: flex;
3080
+ align-items: center;
3081
+ justify-content: space-between;
3082
+ }
3083
+ .claude-status {
3084
+ font-size: 11px;
3085
+ padding: 3px 10px;
3086
+ border-radius: 12px;
3087
+ background: var(--bg-tertiary);
3088
+ color: var(--muted);
3089
+ }
3090
+ .claude-status.processing {
3091
+ color: var(--yellow);
3092
+ animation: pulse 1s infinite;
3093
+ }
3094
+ .claude-status.success { color: var(--green); }
3095
+ .claude-status.error { color: var(--red); }
3096
+ .claude-log {
3097
+ background: #0d1117;
3098
+ border: 1px solid var(--border);
3099
+ border-radius: 8px;
3100
+ padding: 16px;
3101
+ max-height: 400px;
3102
+ overflow-y: auto;
3103
+ font-family: 'SF Mono', Monaco, 'Consolas', monospace;
3104
+ font-size: 12px;
3105
+ line-height: 1.6;
3106
+ white-space: pre-wrap;
3107
+ word-break: break-word;
3108
+ color: var(--text);
3109
+ margin: 0;
3110
+ margin-top: 12px;
3111
+ }
3112
+ @keyframes pulse {
3113
+ 0%, 100% { opacity: 1; }
3114
+ 50% { opacity: 0.5; }
3115
+ }
3116
+ </style>
3117
+ </head>
3118
+ <body>
3119
+ <header class="header">
3120
+ <a href="/" class="back-btn">← Dashboard</a>
3121
+ <div class="ticket-header-info">
3122
+ <span class="ticket-key">${escapeHtml(ticketKey)}</span>
3123
+ <input type="text" class="ticket-title-input" id="ticket-title" value="${escapeHtml(ticket.title)}" />
3124
+ </div>
3125
+ <div class="lang-selector" id="lang-selector">
3126
+ <button class="lang-btn" data-lang="en">EN</button>
3127
+ <button class="lang-btn" data-lang="fr">FR</button>
3128
+ </div>
3129
+ </header>
3130
+
3131
+ <main class="main-content">
3132
+ <div class="ticket-details">
3133
+ <!-- Meta info -->
3134
+ <div class="section">
3135
+ <div class="section-title" data-i18n="ticketView.meta">Meta</div>
3136
+ <div class="ticket-meta">
3137
+ <span class="meta-badge priority-${ticket.priority}">${ticket.priority}</span>
3138
+ <span class="meta-badge column-badge">${escapeHtml(currentColumn?.name || ticket.column_slug)}</span>
3139
+ <span class="meta-badge semver-badge">${ticket.semver}</span>
3140
+ </div>
3141
+ </div>
3142
+
3143
+ <!-- Labels -->
3144
+ ${ticket.labels && ticket.labels.length > 0 ? `
3145
+ <div class="section">
3146
+ <div class="section-title" data-i18n="ticketView.labels">Labels</div>
3147
+ <div class="labels-list">
3148
+ ${ticket.labels.map(label => `<span class="label-tag">${escapeHtml(label)}</span>`).join('')}
3149
+ </div>
3150
+ </div>
3151
+ ` : ''}
3152
+
3153
+ <!-- Description -->
3154
+ <div class="section">
3155
+ <div class="section-title">
3156
+ <span data-i18n="ticketView.description">Description</span>
3157
+ <button class="btn-edit-toggle" id="btn-edit-description" onclick="toggleDescriptionEdit()">✏️</button>
3158
+ </div>
3159
+ <div class="description-view" id="description-view"></div>
3160
+ <textarea class="description-edit" id="description-edit" style="display:none" placeholder="Description (Markdown)">${escapeHtml(ticket.description || '')}</textarea>
3161
+ </div>
3162
+
3163
+ <!-- Acceptance Criteria -->
3164
+ ${ticket.acceptance_criteria && ticket.acceptance_criteria.length > 0 ? `
3165
+ <div class="section">
3166
+ <div class="section-title" data-i18n="ticketView.criteria">Acceptance Criteria</div>
3167
+ <ul class="criteria-list">
3168
+ ${ticket.acceptance_criteria.map(c => `<li class="criteria-item">${escapeHtml(c)}</li>`).join('')}
3169
+ </ul>
3170
+ </div>
3171
+ ` : ''}
3172
+
3173
+ <!-- History -->
3174
+ ${ticket.history && ticket.history.length > 0 ? `
3175
+ <div class="section">
3176
+ <div class="section-title" data-i18n="ticketView.history">History</div>
3177
+ <ul class="history-list">
3178
+ ${ticket.history.map(h => {
3179
+ const date = new Date(h.at);
3180
+ const dateStr = date.toLocaleDateString('en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
3181
+ const showPromptBtn = h.to && h.action !== 'created' ? `<button class="btn-prompt" onclick="showPrompt('${escapeHtml(h.to)}')" title="View prompt">📜</button>` : '';
3182
+ const showLogBtn = h.to && h.action !== 'created' ? `<button class="btn-prompt" onclick="showLog('${escapeHtml(h.to)}')" title="View terminal log">🖥️</button>` : '';
3183
+ return `<li class="history-item">
3184
+ <span class="history-action">${h.action}</span>
3185
+ ${h.from ? `<span class="history-from">${escapeHtml(h.from)}</span> →` : ''}
3186
+ <span class="history-to">${escapeHtml(h.to)}</span>
3187
+ ${showPromptBtn}
3188
+ ${showLogBtn}
3189
+ <span class="history-date">${dateStr}</span>
3190
+ </li>`;
3191
+ }).join('')}
3192
+ </ul>
3193
+ </div>
3194
+ ` : ''}
3195
+
3196
+ <!-- Actions bar -->
3197
+ <div class="actions-bar">
3198
+ <button class="btn btn-primary" id="btn-save" onclick="saveTicket()" data-i18n="ticketView.save">Save</button>
3199
+ <button class="btn btn-secondary" onclick="advanceTicket()" data-i18n="ticketView.moveNext">Move to next column</button>
3200
+ <button class="btn btn-danger" onclick="archiveTicket()" data-i18n="ticketView.archive">Archive</button>
3201
+ </div>
3202
+ </div>
3203
+
3204
+ <!-- Bottom section: Comments & Claude Terminal -->
3205
+ <div class="ticket-bottom">
3206
+ <!-- Comments -->
3207
+ <div class="section comments-section">
3208
+ <div class="section-title"><span data-i18n="ticketView.comments">Comments</span> (<span id="comments-count">${ticket.comments?.length || 0}</span>)</div>
3209
+ <div class="comments-list" id="comments-list"></div>
3210
+ <div class="add-comment">
3211
+ <textarea id="new-comment" placeholder="Add a comment..." data-i18n-placeholder="ticketView.addComment"></textarea>
3212
+ <button class="btn-comment" onclick="addComment()" data-i18n="btn.add">Add</button>
3213
+ </div>
3214
+ </div>
3215
+
3216
+ <!-- Claude Terminal -->
3217
+ <div class="section claude-section" id="claude-section">
3218
+ <div class="section-title">
3219
+ <span data-i18n="ticketView.claudeTerminal">Claude Terminal</span>
3220
+ <span class="claude-status" id="claude-status" data-i18n="status.waiting">Waiting</span>
3221
+ </div>
3222
+ <pre class="claude-log" id="claude-log"></pre>
3223
+ </div>
3224
+ </div>
3225
+ </main>
3226
+
3227
+ <div class="notification" id="notification"></div>
3228
+
3229
+ <!-- Prompt Modal -->
3230
+ <div class="prompt-modal" id="prompt-modal" onclick="closePromptModal(event)">
3231
+ <div class="prompt-modal-content" onclick="event.stopPropagation()">
3232
+ <div class="prompt-modal-header">
3233
+ <h3 id="prompt-modal-title">Prompt</h3>
3234
+ <button class="prompt-modal-close" onclick="closePromptModal()">&times;</button>
3235
+ </div>
3236
+ <div class="prompt-modal-body">
3237
+ <pre id="prompt-modal-content"></pre>
3238
+ </div>
3239
+ </div>
3240
+ </div>
3241
+
3242
+ <script>
3243
+ const TICKET_KEY = '${ticketKey}';
3244
+ const TICKET = ${ticketData};
3245
+ const COLUMNS = ${columnsData};
3246
+ const STORAGE_KEY = 'autocode-lang';
3247
+
3248
+ let currentLang = localStorage.getItem(STORAGE_KEY) || 'fr';
3249
+ let currentComments = TICKET.comments || [];
3250
+
3251
+ const translations = {
3252
+ en: {
3253
+ 'ticketView.meta': 'Meta',
3254
+ 'ticketView.labels': 'Labels',
3255
+ 'ticketView.description': 'Description',
3256
+ 'ticketView.criteria': 'Acceptance Criteria',
3257
+ 'ticketView.history': 'History',
3258
+ 'ticketView.actions': 'Actions',
3259
+ 'ticketView.save': 'Save',
3260
+ 'ticketView.moveNext': 'Move to next column',
3261
+ 'ticketView.archive': 'Archive',
3262
+ 'ticketView.confirmMove': 'Move this ticket to the next column?',
3263
+ 'ticketView.confirmArchive': 'Archive this ticket?',
3264
+ 'ticketView.comments': 'Comments',
3265
+ 'ticketView.addComment': 'Add a comment...',
3266
+ 'ticketView.noComments': 'No comments yet',
3267
+ 'ticketView.claudeTerminal': 'Claude Terminal',
3268
+ 'ticketView.noDescription': 'No description',
3269
+ 'ticketView.noLog': 'No log yet. Waiting for Claude processing...',
3270
+ 'ticketView.loadingPrompt': 'Loading prompt...',
3271
+ 'ticketView.promptError': 'Error',
3272
+ 'btn.add': 'Add',
3273
+ 'btn.sending': 'Sending...',
3274
+ 'btn.saving': 'Saving...',
3275
+ 'status.waiting': 'Waiting',
3276
+ 'status.processing': 'Processing...',
3277
+ 'status.completed': 'Completed',
3278
+ 'status.failed': 'Failed',
3279
+ 'notify.commentAdded': 'Comment added',
3280
+ 'notify.ticketAdvanced': 'Ticket advanced',
3281
+ 'notify.ticketArchived': 'Ticket archived',
3282
+ 'notify.ticketSaved': 'Ticket saved',
3283
+ 'notify.error': 'Error'
3284
+ },
3285
+ fr: {
3286
+ 'ticketView.meta': 'Méta',
3287
+ 'ticketView.labels': 'Labels',
3288
+ 'ticketView.description': 'Description',
3289
+ 'ticketView.criteria': 'Critères d\\'acceptation',
3290
+ 'ticketView.history': 'Historique',
3291
+ 'ticketView.actions': 'Actions',
3292
+ 'ticketView.save': 'Sauvegarder',
3293
+ 'ticketView.moveNext': 'Déplacer vers la colonne suivante',
3294
+ 'ticketView.archive': 'Archiver',
3295
+ 'ticketView.confirmMove': 'Déplacer ce ticket vers la colonne suivante ?',
3296
+ 'ticketView.confirmArchive': 'Archiver ce ticket ?',
3297
+ 'ticketView.comments': 'Commentaires',
3298
+ 'ticketView.addComment': 'Ajouter un commentaire...',
3299
+ 'ticketView.noComments': 'Aucun commentaire',
3300
+ 'ticketView.claudeTerminal': 'Terminal Claude',
3301
+ 'ticketView.noDescription': 'Aucune description',
3302
+ 'ticketView.noLog': 'Aucun log. En attente du traitement Claude...',
3303
+ 'ticketView.loadingPrompt': 'Chargement du prompt...',
3304
+ 'ticketView.promptError': 'Erreur',
3305
+ 'btn.add': 'Ajouter',
3306
+ 'btn.sending': 'Envoi...',
3307
+ 'btn.saving': 'Sauvegarde...',
3308
+ 'status.waiting': 'En attente',
3309
+ 'status.processing': 'En cours...',
3310
+ 'status.completed': 'Terminé',
3311
+ 'status.failed': 'Échec',
3312
+ 'notify.commentAdded': 'Commentaire ajouté',
3313
+ 'notify.ticketAdvanced': 'Ticket avancé',
3314
+ 'notify.ticketArchived': 'Ticket archivé',
3315
+ 'notify.ticketSaved': 'Ticket sauvegardé',
3316
+ 'notify.error': 'Erreur'
3317
+ }
3318
+ };
3319
+
3320
+ function t(key) {
3321
+ return translations[currentLang]?.[key] || translations['en'][key] || key;
3322
+ }
3323
+
3324
+ function escapeHtml(text) {
3325
+ if (!text) return '';
3326
+ const div = document.createElement('div');
3327
+ div.textContent = text;
3328
+ return div.innerHTML;
3329
+ }
3330
+
3331
+ function renderMarkdown(text) {
3332
+ if (!text) return '';
3333
+ let html = escapeHtml(text);
3334
+ html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
3335
+ html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
3336
+ html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
3337
+ html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
3338
+ html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>');
3339
+ html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
3340
+ html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
3341
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
3342
+ html = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');
3343
+ html = html.replace(/\\n/g, '<br>');
3344
+ return html;
3345
+ }
3346
+
3347
+ function updateLangUI() {
3348
+ document.querySelectorAll('.lang-btn').forEach(btn => {
3349
+ btn.classList.toggle('active', btn.dataset.lang === currentLang);
3350
+ });
3351
+ document.querySelectorAll('[data-i18n]').forEach(el => {
3352
+ el.textContent = t(el.dataset.i18n);
3353
+ });
3354
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
3355
+ el.placeholder = t(el.dataset.i18nPlaceholder);
3356
+ });
3357
+ }
3358
+
3359
+ function renderComments() {
3360
+ const list = document.getElementById('comments-list');
3361
+ const count = document.getElementById('comments-count');
3362
+ count.textContent = currentComments.length;
3363
+
3364
+ if (currentComments.length === 0) {
3365
+ list.innerHTML = '<div class="no-comments">' + t('ticketView.noComments') + '</div>';
3366
+ return;
3367
+ }
3368
+
3369
+ const sorted = [...currentComments].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
3370
+ list.innerHTML = sorted.map((comment, index) => {
3371
+ const date = new Date(comment.created_at);
3372
+ const dateStr = date.toLocaleDateString('en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
3373
+ const source = comment.source || 'user';
3374
+ const sourceBadge = source === 'claude'
3375
+ ? '<span class="comment-source claude">Claude</span>'
3376
+ : '<span class="comment-source user">User</span>';
3377
+ return '<div class="comment" id="comment-' + index + '">' +
3378
+ '<div class="comment-meta" onclick="toggleComment(' + index + ')">' + sourceBadge + '<span class="comment-column">' + (comment.column || 'N/A') + '</span>' +
3379
+ '<span class="comment-date">' + dateStr + '</span></div>' +
3380
+ '<div class="comment-text">' + renderMarkdown(comment.text) + '</div></div>';
3381
+ }).join('');
3382
+ }
3383
+
3384
+ function toggleComment(index) {
3385
+ const comments = document.querySelectorAll('.comment');
3386
+ comments.forEach((comment, i) => {
3387
+ if (i === index) {
3388
+ comment.classList.toggle('expanded');
3389
+ } else {
3390
+ comment.classList.remove('expanded');
3391
+ }
3392
+ });
3393
+ }
3394
+
3395
+ async function addComment() {
3396
+ const textarea = document.getElementById('new-comment');
3397
+ const text = textarea.value.trim();
3398
+ if (!text) return;
3399
+
3400
+ const btn = document.querySelector('.btn-comment');
3401
+ btn.disabled = true;
3402
+ btn.textContent = t('btn.sending');
3403
+
3404
+ try {
3405
+ const res = await fetch('/api/tickets/' + TICKET_KEY + '/comments', {
3406
+ method: 'POST',
3407
+ headers: { 'Content-Type': 'application/json' },
3408
+ body: JSON.stringify({ text, source: 'user' })
3409
+ });
3410
+ const result = await res.json();
3411
+ textarea.value = '';
3412
+ if (result.success && result.data && result.data.comments) {
3413
+ currentComments = result.data.comments;
3414
+ renderComments();
3415
+ }
3416
+ showNotification(t('notify.commentAdded'));
3417
+ } catch (e) {
3418
+ showNotification(t('notify.error') + ': ' + e.message, true);
3419
+ } finally {
3420
+ btn.disabled = false;
3421
+ btn.textContent = t('btn.add');
3422
+ }
3423
+ }
3424
+
3425
+ let isEditingDescription = false;
3426
+
3427
+ function renderDescription() {
3428
+ const view = document.getElementById('description-view');
3429
+ const edit = document.getElementById('description-edit');
3430
+ const description = edit.value || '';
3431
+ if (description) {
3432
+ view.innerHTML = renderMarkdown(description);
3433
+ } else {
3434
+ view.innerHTML = '<span style="color: var(--muted)">' + t('ticketView.noDescription') + '</span>';
3435
+ }
3436
+ }
3437
+
3438
+ function toggleDescriptionEdit() {
3439
+ isEditingDescription = !isEditingDescription;
3440
+ const view = document.getElementById('description-view');
3441
+ const edit = document.getElementById('description-edit');
3442
+ const btn = document.getElementById('btn-edit-description');
3443
+
3444
+ if (isEditingDescription) {
3445
+ view.style.display = 'none';
3446
+ edit.style.display = 'block';
3447
+ btn.classList.add('active');
3448
+ btn.textContent = '✓';
3449
+ edit.focus();
3450
+ } else {
3451
+ view.style.display = 'block';
3452
+ edit.style.display = 'none';
3453
+ btn.classList.remove('active');
3454
+ btn.textContent = '✏️';
3455
+ renderDescription();
3456
+ }
3457
+ }
3458
+
3459
+ async function saveTicket() {
3460
+ const btn = document.getElementById('btn-save');
3461
+ btn.disabled = true;
3462
+ btn.textContent = t('btn.saving');
3463
+
3464
+ try {
3465
+ const res = await fetch('/api/tickets/' + TICKET_KEY, {
3466
+ method: 'PATCH',
3467
+ headers: { 'Content-Type': 'application/json' },
3468
+ body: JSON.stringify({
3469
+ title: document.getElementById('ticket-title').value,
3470
+ description: document.getElementById('description-edit').value
3471
+ })
3472
+ });
3473
+ const result = await res.json();
3474
+ if (result.success) {
3475
+ showNotification(t('notify.ticketSaved'));
3476
+ if (isEditingDescription) {
3477
+ toggleDescriptionEdit();
3478
+ }
3479
+ } else {
3480
+ showNotification(t('notify.error') + ': ' + result.error, true);
3481
+ }
3482
+ } catch (e) {
3483
+ showNotification(t('notify.error') + ': ' + e.message, true);
3484
+ } finally {
3485
+ btn.disabled = false;
3486
+ btn.textContent = t('ticketView.save');
3487
+ }
3488
+ }
3489
+
3490
+ async function advanceTicket() {
3491
+ if (!confirm(t('ticketView.confirmMove'))) return;
3492
+ try {
3493
+ const res = await fetch('/api/tickets/' + TICKET_KEY + '/next', {
3494
+ method: 'POST',
3495
+ headers: { 'Content-Type': 'application/json' },
3496
+ body: JSON.stringify({ lang: currentLang })
3497
+ });
3498
+ const result = await res.json();
3499
+ if (result.success) {
3500
+ showNotification(t('notify.ticketAdvanced'));
3501
+ setTimeout(() => location.reload(), 1000);
3502
+ } else {
3503
+ showNotification(t('notify.error') + ': ' + result.error, true);
3504
+ }
3505
+ } catch (e) {
3506
+ showNotification(t('notify.error') + ': ' + e.message, true);
3507
+ }
3508
+ }
3509
+
3510
+ async function archiveTicket() {
3511
+ if (!confirm(t('ticketView.confirmArchive'))) return;
3512
+ try {
3513
+ const res = await fetch('/api/tickets/' + TICKET_KEY + '/archive', {
3514
+ method: 'POST',
3515
+ headers: { 'Content-Type': 'application/json' },
3516
+ body: JSON.stringify({ lang: currentLang })
3517
+ });
3518
+ const result = await res.json();
3519
+ if (result.success) {
3520
+ showNotification(t('notify.ticketArchived'));
3521
+ setTimeout(() => location.href = '/', 1000);
3522
+ } else {
3523
+ showNotification(t('notify.error') + ': ' + result.error, true);
3524
+ }
3525
+ } catch (e) {
3526
+ showNotification(t('notify.error') + ': ' + e.message, true);
3527
+ }
3528
+ }
3529
+
3530
+ function showNotification(msg, isError) {
3531
+ const notification = document.getElementById('notification');
3532
+ notification.textContent = msg;
3533
+ notification.className = 'notification show' + (isError ? ' error' : '');
3534
+ setTimeout(() => notification.className = 'notification', 3000);
3535
+ }
3536
+
3537
+ // Language switcher
3538
+ document.querySelectorAll('.lang-btn').forEach(btn => {
3539
+ btn.addEventListener('click', () => {
3540
+ const newLang = btn.dataset.lang;
3541
+ if (newLang !== currentLang) {
3542
+ currentLang = newLang;
3543
+ localStorage.setItem(STORAGE_KEY, newLang);
3544
+ updateLangUI();
3545
+ renderComments();
3546
+ }
3547
+ });
3548
+ });
3549
+
3550
+ // ========================================
3551
+ // WEBSOCKET & CLAUDE LOG
3552
+ // ========================================
3553
+ let ws;
3554
+ let logPollingInterval = null;
3555
+
3556
+ function connectWebSocket() {
3557
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
3558
+ ws = new WebSocket(protocol + '//' + location.host + '/ws');
3559
+
3560
+ ws.onmessage = (event) => {
3561
+ try {
3562
+ const data = JSON.parse(event.data);
3563
+ switch (data.type) {
3564
+ case 'ticket_updated':
3565
+ if (data.key === TICKET_KEY) {
3566
+ location.reload();
3567
+ }
3568
+ break;
3569
+ case 'claude_start':
3570
+ if (data.ticket === TICKET_KEY) {
3571
+ onClaudeStart();
3572
+ }
3573
+ break;
3574
+ case 'claude_stream':
3575
+ if (data.ticket === TICKET_KEY) {
3576
+ fetchLog();
3577
+ }
3578
+ break;
3579
+ case 'claude_end':
3580
+ if (data.ticket === TICKET_KEY) {
3581
+ onClaudeEnd(data.success, data.duration);
3582
+ }
3583
+ break;
3584
+ }
3585
+ } catch (e) {
3586
+ console.error('WebSocket message error:', e);
3587
+ }
3588
+ };
3589
+
3590
+ ws.onclose = () => {
3591
+ setTimeout(connectWebSocket, 3000);
3592
+ };
3593
+ }
3594
+
3595
+ function startLogPolling() {
3596
+ stopLogPolling();
3597
+ logPollingInterval = setInterval(fetchLog, 1000);
3598
+ fetchLog();
3599
+ }
3600
+
3601
+ function stopLogPolling() {
3602
+ if (logPollingInterval) {
3603
+ clearInterval(logPollingInterval);
3604
+ logPollingInterval = null;
3605
+ }
3606
+ }
3607
+
3608
+ async function fetchLog() {
3609
+ try {
3610
+ const res = await fetch('/api/tickets/' + TICKET_KEY + '/log');
3611
+ const json = await res.json();
3612
+ const log = document.getElementById('claude-log');
3613
+
3614
+ if (json.success && json.data && (json.data.exists || json.data.content)) {
3615
+ log.textContent = json.data.content || '';
3616
+ log.scrollTop = log.scrollHeight;
3617
+ } else {
3618
+ log.textContent = t('ticketView.noLog');
3619
+ }
3620
+ } catch (e) {
3621
+ console.error('Log fetch error:', e);
3622
+ }
3623
+ }
3624
+
3625
+ function onClaudeStart() {
3626
+ const status = document.getElementById('claude-status');
3627
+ status.className = 'claude-status processing';
3628
+ status.textContent = t('status.processing');
3629
+ startLogPolling();
3630
+ }
3631
+
3632
+ function onClaudeEnd(success, duration) {
3633
+ stopLogPolling();
3634
+ fetchLog();
3635
+ const status = document.getElementById('claude-status');
3636
+ if (success) {
3637
+ status.className = 'claude-status success';
3638
+ status.textContent = t('status.completed') + ' (' + (duration / 1000).toFixed(1) + 's)';
3639
+ } else {
3640
+ status.className = 'claude-status error';
3641
+ status.textContent = t('status.failed');
3642
+ }
3643
+ }
3644
+
3645
+ // Prompt Modal
3646
+ async function showPrompt(columnSlug) {
3647
+ const modal = document.getElementById('prompt-modal');
3648
+ const title = document.getElementById('prompt-modal-title');
3649
+ const content = document.getElementById('prompt-modal-content');
3650
+
3651
+ title.textContent = t('ticketView.loadingPrompt');
3652
+ content.textContent = '';
3653
+ modal.classList.add('visible');
3654
+
3655
+ try {
3656
+ const res = await fetch('/api/tickets/' + TICKET_KEY + '/prompt/' + columnSlug);
3657
+ const json = await res.json();
3658
+ if (json.success) {
3659
+ title.textContent = 'Prompt → ' + json.data.column;
3660
+ content.textContent = json.data.prompt;
3661
+ } else {
3662
+ title.textContent = t('ticketView.promptError');
3663
+ content.textContent = json.error || 'Error loading prompt';
3664
+ }
3665
+ } catch (e) {
3666
+ title.textContent = t('ticketView.promptError');
3667
+ content.textContent = e.message;
3668
+ }
3669
+ }
3670
+
3671
+ function closePromptModal(event) {
3672
+ if (event && event.target !== event.currentTarget) return;
3673
+ document.getElementById('prompt-modal').classList.remove('visible');
3674
+ }
3675
+
3676
+ // Log Modal (reuses prompt modal)
3677
+ async function showLog(columnSlug) {
3678
+ const modal = document.getElementById('prompt-modal');
3679
+ const title = document.getElementById('prompt-modal-title');
3680
+ const content = document.getElementById('prompt-modal-content');
3681
+
3682
+ title.textContent = t('ticketView.loadingLog') || 'Loading log...';
3683
+ content.textContent = '';
3684
+ modal.classList.add('visible');
3685
+
3686
+ try {
3687
+ const res = await fetch('/api/tickets/' + TICKET_KEY + '/log/' + columnSlug);
3688
+ const json = await res.json();
3689
+ if (json.success) {
3690
+ title.textContent = 'Terminal → ' + columnSlug;
3691
+ content.textContent = json.data.content || t('ticketView.noLog') || 'No log available';
3692
+ } else {
3693
+ title.textContent = t('ticketView.logError') || 'Log Error';
3694
+ content.textContent = json.error || 'Error loading log';
3695
+ }
3696
+ } catch (e) {
3697
+ title.textContent = t('ticketView.logError') || 'Log Error';
3698
+ content.textContent = e.message;
3699
+ }
3700
+ }
3701
+
3702
+ // Init
3703
+ updateLangUI();
3704
+ renderDescription();
3705
+ renderComments();
3706
+ connectWebSocket();
3707
+ fetchLog();
3708
+ </script>
3709
+ </body>
3710
+ </html>`;
3711
+ }
2261
3712
  //# sourceMappingURL=dashboard.js.map