aimeat 1.23.2 → 1.23.3

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.
Files changed (154) hide show
  1. package/dist/locales/en.json +536 -21
  2. package/dist/locales/fi.json +548 -33
  3. package/dist/public/components/ContactCard.js +28 -0
  4. package/dist/public/css/views/admin.css +12 -0
  5. package/dist/public/css/views/agents-detail.css +62 -10
  6. package/dist/public/css/views/landing.css +87 -0
  7. package/dist/public/css/views/offers.css +96 -0
  8. package/dist/public/css/views/portfolio.css +18 -0
  9. package/dist/public/css/views/profile.css +153 -8
  10. package/dist/public/css/views/start.css +96 -0
  11. package/dist/public/img/business-hero.png +0 -0
  12. package/dist/public/img/toimitus.png +0 -0
  13. package/dist/public/js/components/tag-cloud.js +46 -31
  14. package/dist/public/js/components/tag-editor.js +2 -2
  15. package/dist/public/js/recents.js +40 -0
  16. package/dist/public/js/services/admin.js +6 -0
  17. package/dist/public/js/services/agents.js +34 -0
  18. package/dist/public/js/services/offers.js +152 -0
  19. package/dist/public/js/services/packages.js +1 -0
  20. package/dist/public/spa.html +32 -2
  21. package/dist/public/views/admin/subdomains-tab.js +182 -0
  22. package/dist/public/views/admin.js +2 -0
  23. package/dist/public/views/business.js +119 -0
  24. package/dist/public/views/how-it-works.js +159 -0
  25. package/dist/public/views/landing.js +235 -0
  26. package/dist/public/views/portfolio.js +120 -23
  27. package/dist/public/views/pricing.js +75 -0
  28. package/dist/public/views/profile/access-tab.js +124 -51
  29. package/dist/public/views/profile/agents/agent-card.js +68 -37
  30. package/dist/public/views/profile/agents/shared-board.js +56 -22
  31. package/dist/public/views/profile/agents/tab-activity.js +24 -12
  32. package/dist/public/views/profile/agents/tab-agent-config.js +31 -1
  33. package/dist/public/views/profile/agents/tab-data-access.js +9 -13
  34. package/dist/public/views/profile/agents/tab-integration.js +27 -1
  35. package/dist/public/views/profile/agents/tab-messages.js +6 -11
  36. package/dist/public/views/profile/agents/tab-quality.js +45 -5
  37. package/dist/public/views/profile/agents-tab.js +41 -6
  38. package/dist/public/views/profile/apps-tab.js +7 -1
  39. package/dist/public/views/profile/knowledge-tab.js +4 -1
  40. package/dist/public/views/profile/landing-page.js +427 -123
  41. package/dist/public/views/profile/memory-tab.js +409 -211
  42. package/dist/public/views/profile/nodes-tab.js +18 -3
  43. package/dist/public/views/profile/offers-tab.js +310 -0
  44. package/dist/public/views/profile/organisms-tab.js +289 -28
  45. package/dist/public/views/profile/packages-tab.js +152 -16
  46. package/dist/public/views/profile/portfolio-tab.js +98 -31
  47. package/dist/public/views/profile.js +2 -0
  48. package/dist/public/views/start-flows.js +281 -0
  49. package/dist/public/views/start.js +258 -0
  50. package/dist/src/generated/api-types.d.ts +856 -17
  51. package/dist/src/generated/api-types.d.ts.map +1 -1
  52. package/dist/src/generated/prisma-postgres/edge.js +15 -3
  53. package/dist/src/generated/prisma-postgres/index-browser.js +12 -0
  54. package/dist/src/generated/prisma-postgres/index.d.ts +1476 -160
  55. package/dist/src/generated/prisma-postgres/index.js +15 -3
  56. package/dist/src/generated/prisma-postgres/package.json +1 -1
  57. package/dist/src/generated/prisma-postgres/schema.prisma +11 -0
  58. package/dist/src/generated/prisma-postgres/wasm.js +15 -3
  59. package/dist/src/middleware/subdomain.d.ts +28 -0
  60. package/dist/src/middleware/subdomain.d.ts.map +1 -0
  61. package/dist/src/middleware/subdomain.js +32 -0
  62. package/dist/src/middleware/subdomain.js.map +1 -0
  63. package/dist/src/models/offer-schemas.d.ts +229 -0
  64. package/dist/src/models/offer-schemas.d.ts.map +1 -0
  65. package/dist/src/models/offer-schemas.js +100 -0
  66. package/dist/src/models/offer-schemas.js.map +1 -0
  67. package/dist/src/routes/agent-tasks.d.ts.map +1 -1
  68. package/dist/src/routes/agent-tasks.js +33 -0
  69. package/dist/src/routes/agent-tasks.js.map +1 -1
  70. package/dist/src/routes/agents.d.ts.map +1 -1
  71. package/dist/src/routes/agents.js +146 -1
  72. package/dist/src/routes/agents.js.map +1 -1
  73. package/dist/src/routes/apps-backup.d.ts +20 -0
  74. package/dist/src/routes/apps-backup.d.ts.map +1 -0
  75. package/dist/src/routes/apps-backup.js +165 -0
  76. package/dist/src/routes/apps-backup.js.map +1 -0
  77. package/dist/src/routes/ghii.d.ts.map +1 -1
  78. package/dist/src/routes/ghii.js +4 -0
  79. package/dist/src/routes/ghii.js.map +1 -1
  80. package/dist/src/routes/instances.d.ts +2 -1
  81. package/dist/src/routes/instances.d.ts.map +1 -1
  82. package/dist/src/routes/instances.js +59 -1
  83. package/dist/src/routes/instances.js.map +1 -1
  84. package/dist/src/routes/packages.d.ts.map +1 -1
  85. package/dist/src/routes/packages.js +32 -0
  86. package/dist/src/routes/packages.js.map +1 -1
  87. package/dist/src/routes/permissions.d.ts +12 -0
  88. package/dist/src/routes/permissions.d.ts.map +1 -1
  89. package/dist/src/routes/permissions.js +35 -3
  90. package/dist/src/routes/permissions.js.map +1 -1
  91. package/dist/src/routes/portal.d.ts +3 -0
  92. package/dist/src/routes/portal.d.ts.map +1 -1
  93. package/dist/src/routes/portal.js +12 -0
  94. package/dist/src/routes/portal.js.map +1 -1
  95. package/dist/src/routes/public-stats.d.ts +15 -0
  96. package/dist/src/routes/public-stats.d.ts.map +1 -0
  97. package/dist/src/routes/public-stats.js +90 -0
  98. package/dist/src/routes/public-stats.js.map +1 -0
  99. package/dist/src/routes/sse.d.ts +7 -2
  100. package/dist/src/routes/sse.d.ts.map +1 -1
  101. package/dist/src/routes/sse.js +35 -5
  102. package/dist/src/routes/sse.js.map +1 -1
  103. package/dist/src/routes/subdomains.d.ts +33 -0
  104. package/dist/src/routes/subdomains.d.ts.map +1 -0
  105. package/dist/src/routes/subdomains.js +208 -0
  106. package/dist/src/routes/subdomains.js.map +1 -0
  107. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  108. package/dist/src/server-bootstrap/routes-loader.js +12 -1
  109. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  110. package/dist/src/server.d.ts.map +1 -1
  111. package/dist/src/server.js +4 -0
  112. package/dist/src/server.js.map +1 -1
  113. package/dist/src/services/apps-backup-export.d.ts +22 -0
  114. package/dist/src/services/apps-backup-export.d.ts.map +1 -0
  115. package/dist/src/services/apps-backup-export.js +136 -0
  116. package/dist/src/services/apps-backup-export.js.map +1 -0
  117. package/dist/src/services/apps-backup-import.d.ts +120 -0
  118. package/dist/src/services/apps-backup-import.d.ts.map +1 -0
  119. package/dist/src/services/apps-backup-import.js +259 -0
  120. package/dist/src/services/apps-backup-import.js.map +1 -0
  121. package/dist/src/services/component-registrar.d.ts +17 -0
  122. package/dist/src/services/component-registrar.d.ts.map +1 -1
  123. package/dist/src/services/component-registrar.js +63 -5
  124. package/dist/src/services/component-registrar.js.map +1 -1
  125. package/dist/src/services/handbooks/appdev.d.ts +1 -1
  126. package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
  127. package/dist/src/services/handbooks/appdev.js +5 -2
  128. package/dist/src/services/handbooks/appdev.js.map +1 -1
  129. package/dist/src/storage/interface.d.ts +11 -1
  130. package/dist/src/storage/interface.d.ts.map +1 -1
  131. package/dist/src/storage/interface.js.map +1 -1
  132. package/dist/src/storage/providers/mongodb/index.d.ts +10 -1
  133. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  134. package/dist/src/storage/providers/mongodb/index.js +76 -0
  135. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  136. package/dist/src/storage/providers/sqlite/index.d.ts +8 -1
  137. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  138. package/dist/src/storage/providers/sqlite/index.js +61 -0
  139. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  140. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  141. package/dist/src/storage/providers/sqlite/schema.js +11 -0
  142. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  143. package/dist/src/storage/repositories/index.d.ts +1 -0
  144. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  145. package/dist/src/storage/repositories/package.repository.d.ts +2 -0
  146. package/dist/src/storage/repositories/package.repository.d.ts.map +1 -1
  147. package/dist/src/storage/repositories/subdomain-site.repository.d.ts +19 -0
  148. package/dist/src/storage/repositories/subdomain-site.repository.d.ts.map +1 -0
  149. package/dist/src/storage/repositories/subdomain-site.repository.js +2 -0
  150. package/dist/src/storage/repositories/subdomain-site.repository.js.map +1 -0
  151. package/dist/static/app-catalog.html +1139 -28
  152. package/package.json +1 -1
  153. package/prisma/schema.postgres.prisma +11 -0
  154. package/prisma/schema.prisma +11 -0
@@ -1074,8 +1074,107 @@
1074
1074
 
1075
1075
  /* ── Publish Modal ──────────────────────────────── */
1076
1076
 
1077
+ /* ── Backup menu + import modal ─────────────────── */
1078
+
1079
+ .backup-menu-wrap { position: relative; }
1080
+
1081
+ .backup-menu {
1082
+ position: absolute;
1083
+ top: calc(100% + 6px);
1084
+ right: 0;
1085
+ z-index: 600;
1086
+ min-width: 220px;
1087
+ background: var(--surface);
1088
+ border: 1px solid var(--border-subtle);
1089
+ border-radius: var(--radius-sm);
1090
+ box-shadow: 0 8px 32px rgba(0, 0, 0, .18);
1091
+ padding: 6px;
1092
+ display: flex;
1093
+ flex-direction: column;
1094
+ gap: 2px;
1095
+ }
1096
+
1097
+ .backup-menu[hidden] { display: none; }
1098
+
1099
+ .backup-menu button {
1100
+ background: none;
1101
+ border: none;
1102
+ color: var(--text);
1103
+ text-align: left;
1104
+ padding: 8px 10px;
1105
+ border-radius: 6px;
1106
+ cursor: pointer;
1107
+ font-size: .85rem;
1108
+ white-space: nowrap;
1109
+ }
1110
+
1111
+ .backup-menu button:hover { background: var(--hover); }
1112
+
1113
+ #backup-modal { max-height: 84vh; display: flex; flex-direction: column; }
1114
+ #backup-import-body { overflow-y: auto; flex: 1; min-height: 0; }
1115
+
1116
+ .backup-table { width: 100%; border-collapse: collapse; font-size: .85rem; }
1117
+ .backup-table th {
1118
+ text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em;
1119
+ color: var(--text-muted); padding: 6px 8px; border-bottom: 1px solid var(--border-subtle);
1120
+ }
1121
+ .backup-table td { padding: 6px 8px; border-bottom: 1px solid var(--border-subtle); vertical-align: top; }
1122
+ .backup-table .mono-cell { font-family: monospace; font-size: .78rem; color: var(--text-muted); }
1123
+
1124
+ .backup-versions-toggle {
1125
+ background: none; border: none; color: var(--accent); cursor: pointer;
1126
+ font-size: .78rem; padding: 0; text-decoration: underline;
1127
+ }
1128
+ .backup-version-list { margin-top: 6px; display: flex; flex-direction: column; gap: 2px; }
1129
+ .backup-version-list label { font-size: .78rem; color: var(--text-muted); display: flex; gap: 6px; align-items: center; }
1130
+
1131
+ .backup-conflict-select {
1132
+ background: var(--bg-subtle); color: var(--text); border: 1px solid var(--border-subtle);
1133
+ border-radius: 6px; padding: 4px 6px; font-size: .78rem;
1134
+ }
1135
+
1136
+ .backup-status-badge {
1137
+ display: inline-block; font-size: .62rem; font-weight: 600; text-transform: uppercase;
1138
+ padding: 2px 6px; border-radius: 4px;
1139
+ }
1140
+ .backup-status-new { background: rgba(52, 211, 153, .12); color: #34d399; border: 1px solid rgba(52, 211, 153, .3); }
1141
+ .backup-status-exists { background: rgba(245, 158, 11, .10); color: #d97706; border: 1px solid rgba(245, 158, 11, .25); }
1142
+
1143
+ .backup-toolbar-row {
1144
+ display: flex; justify-content: space-between; align-items: center;
1145
+ gap: 8px; margin: 8px 0; flex-wrap: wrap;
1146
+ }
1147
+ .backup-summary-line { font-size: .82rem; color: var(--text-muted); }
1148
+ .backup-link-btn {
1149
+ background: none; border: none; color: var(--accent); cursor: pointer;
1150
+ font-size: .8rem; padding: 0 4px; text-decoration: underline;
1151
+ }
1152
+ .backup-result-list { font-size: .82rem; margin: 4px 0 10px; padding-left: 18px; }
1153
+ .backup-result-errors { color: #ef4444; }
1154
+
1155
+ .server-import-banner {
1156
+ margin: 0 24px 1rem;
1157
+ padding: 10px 14px;
1158
+ display: flex;
1159
+ justify-content: space-between;
1160
+ align-items: center;
1161
+ gap: 12px;
1162
+ flex-wrap: wrap;
1163
+ background: var(--surface-glass);
1164
+ border: 1px solid var(--border-subtle);
1165
+ border-radius: var(--radius-sm);
1166
+ font-size: .85rem;
1167
+ position: relative;
1168
+ z-index: 1;
1169
+ }
1170
+ .server-import-banner-actions { display: flex; gap: 8px; }
1171
+
1077
1172
  #publish-overlay,
1078
- #versions-overlay {
1173
+ #versions-overlay,
1174
+ #subdomain-overlay,
1175
+ #backup-overlay,
1176
+ #server-import-overlay,
1177
+ #json-import-overlay {
1079
1178
  position: fixed;
1080
1179
  inset: 0;
1081
1180
  z-index: 560;
@@ -1089,7 +1188,11 @@
1089
1188
  }
1090
1189
 
1091
1190
  #publish-overlay[hidden],
1092
- #versions-overlay[hidden] {
1191
+ #versions-overlay[hidden],
1192
+ #subdomain-overlay[hidden],
1193
+ #backup-overlay[hidden],
1194
+ #server-import-overlay[hidden],
1195
+ #json-import-overlay[hidden] {
1093
1196
  display: none;
1094
1197
  }
1095
1198
 
@@ -1272,6 +1375,43 @@
1272
1375
  border: 1px solid rgba(245, 158, 11, .25);
1273
1376
  }
1274
1377
 
1378
+ /* Operator-only subdomain chip on own published cards */
1379
+ .subdomain-chip {
1380
+ display: inline-flex;
1381
+ align-items: center;
1382
+ gap: 4px;
1383
+ font-size: .7rem;
1384
+ font-weight: 600;
1385
+ padding: 2px 8px;
1386
+ border-radius: 999px;
1387
+ cursor: pointer;
1388
+ background: rgba(99, 102, 241, .10);
1389
+ color: #6366f1;
1390
+ border: 1px solid rgba(99, 102, 241, .25);
1391
+ margin-top: 4px;
1392
+ max-width: 100%;
1393
+ overflow: hidden;
1394
+ text-overflow: ellipsis;
1395
+ white-space: nowrap;
1396
+ }
1397
+ .subdomain-chip:hover { background: rgba(99, 102, 241, .2); }
1398
+
1399
+ /* AI-published origin badge on local grid cards */
1400
+ .ai-origin-badge {
1401
+ display: inline-block;
1402
+ font-size: .6rem;
1403
+ font-weight: 700;
1404
+ text-transform: uppercase;
1405
+ letter-spacing: .04em;
1406
+ padding: 1px 5px;
1407
+ border-radius: 4px;
1408
+ vertical-align: middle;
1409
+ margin-left: 4px;
1410
+ background: rgba(99, 102, 241, .10);
1411
+ color: #6366f1;
1412
+ border: 1px solid rgba(99, 102, 241, .25);
1413
+ }
1414
+
1275
1415
  /* ── Cortex Extensions Bar ──────────────────────── */
1276
1416
 
1277
1417
  .cortex-mini-card{background:var(--surface);border:1px solid var(--border-subtle);border-radius:10px;padding:.75rem 1rem;cursor:pointer;min-width:160px;flex-shrink:0;transition:all .15s}
@@ -1342,6 +1482,14 @@
1342
1482
  <button class="icon-btn" id="add-btn" data-i18n-title="action.add" title="Add app">+</button>
1343
1483
  <button class="icon-btn" id="generate-btn" data-i18n-title="action.generate" title="Generate with AI">&#x2728;</button>
1344
1484
  <button class="icon-btn" id="homepage-btn" data-i18n-title="action.homepage" title="Generate Homepage">&#x1F3E0;</button>
1485
+ <div class="backup-menu-wrap">
1486
+ <button class="icon-btn" id="backup-btn" data-i18n-title="backup.menu" title="Backup" onclick="window._launcher.toggleBackupMenu(event)">&#x1F4BE;</button>
1487
+ <div id="backup-menu" class="backup-menu" hidden>
1488
+ <button onclick="window._launcher.exportBackupZip()" data-i18n="backup.exportAll">&#x1F4E6; Export all (.zip)</button>
1489
+ <button onclick="window._launcher.importBackupPick()" data-i18n="backup.importFrom">&#x1F4E5; Import from backup...</button>
1490
+ </div>
1491
+ </div>
1492
+ <input type="file" id="backup-file-input" accept=".zip,application/zip,application/x-zip-compressed" hidden />
1345
1493
  <div class="lang-toggle" id="lang-toggle" title="Language / Kieli">
1346
1494
  <button class="lang-btn" data-lang="en" onclick="window._launcher.setLanguage('en')">EN</button>
1347
1495
  <button class="lang-btn" data-lang="fi" onclick="window._launcher.setLanguage('fi')">FI</button>
@@ -1353,6 +1501,15 @@
1353
1501
 
1354
1502
  <nav id="tag-bar"></nav>
1355
1503
 
1504
+ <!-- Server-published apps missing locally — import is ALWAYS user-selected -->
1505
+ <div id="server-import-banner" class="server-import-banner" style="display:none">
1506
+ <span>&#x1F4E5; <strong id="server-import-count">0</strong> <span data-i18n="srvImport.bannerText">published apps from your account are not in this catalog.</span></span>
1507
+ <div class="server-import-banner-actions">
1508
+ <button class="modal-btn primary" onclick="window._launcher.showServerImportModal()" data-i18n="srvImport.review">Review &amp; import...</button>
1509
+ <button class="modal-btn secondary" onclick="window._launcher.dismissServerImportBanner()" data-i18n="srvImport.dismiss">Not now</button>
1510
+ </div>
1511
+ </div>
1512
+
1356
1513
  <!-- Active Extensions Bar -->
1357
1514
  <div id="cortex-bar" style="display:none;margin:0 24px 1.5rem;position:relative;z-index:1">
1358
1515
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem">
@@ -1577,6 +1734,7 @@
1577
1734
  <div class="settings-backup-actions">
1578
1735
  <button type="button" class="modal-btn secondary" onclick="window._launcher.exportBackup()" data-i18n="settings.exportJson">Export JSON</button>
1579
1736
  <button type="button" class="modal-btn secondary" onclick="document.getElementById('import-file-input').click()" data-i18n="settings.importJson">Import JSON</button>
1737
+ <button type="button" class="modal-btn secondary" onclick="window._launcher.removeDuplicateApps()" data-i18n="dedup.btn">&#x1F9F9; Remove duplicate apps</button>
1580
1738
  <input type="file" id="import-file-input" accept=".json" hidden/>
1581
1739
  </div>
1582
1740
  </div>
@@ -1641,6 +1799,64 @@
1641
1799
  </div>
1642
1800
  </div>
1643
1801
 
1802
+ <!-- JSON Backup Import Modal (user-selected, duplicate-aware) -->
1803
+ <div id="json-import-overlay" hidden>
1804
+ <div class="modal" id="json-import-modal">
1805
+ <h2 data-i18n="jsonImport.title">&#x1F4E5; Import JSON backup</h2>
1806
+ <div id="json-import-status" style="margin:4px 0;font-size:.85rem;min-height:1.1em"></div>
1807
+ <div id="json-import-body" style="overflow-y:auto;max-height:55vh"></div>
1808
+ <div class="modal-actions">
1809
+ <button type="button" class="modal-btn secondary" onclick="document.getElementById('json-import-overlay').hidden = true" data-i18n="common.cancel">Cancel</button>
1810
+ <button type="button" class="modal-btn primary" id="json-import-btn" onclick="window._launcher.submitJsonImport()" data-i18n="srvImport.import">Import selected</button>
1811
+ </div>
1812
+ </div>
1813
+ </div>
1814
+
1815
+ <!-- Server Apps Import Modal (user-selected) -->
1816
+ <div id="server-import-overlay" hidden>
1817
+ <div class="modal" id="server-import-modal">
1818
+ <h2 data-i18n="srvImport.title">&#x1F4E5; Import published apps</h2>
1819
+ <div id="server-import-status" style="margin:4px 0;font-size:.85rem;min-height:1.1em"></div>
1820
+ <div id="server-import-body" style="overflow-y:auto;max-height:55vh"></div>
1821
+ <div class="modal-actions">
1822
+ <button type="button" class="modal-btn secondary" onclick="document.getElementById('server-import-overlay').hidden = true" data-i18n="common.cancel">Cancel</button>
1823
+ <button type="button" class="modal-btn primary" onclick="window._launcher.submitServerImport()" data-i18n="srvImport.import">Import selected</button>
1824
+ </div>
1825
+ </div>
1826
+ </div>
1827
+
1828
+ <!-- Backup Import Modal -->
1829
+ <div id="backup-overlay" hidden>
1830
+ <div class="modal" id="backup-modal">
1831
+ <h2 data-i18n="backup.title">&#x1F4E5; Import from backup</h2>
1832
+ <div id="backup-status" style="margin:4px 0;font-size:.85rem;min-height:1.1em"></div>
1833
+ <div id="backup-import-body"></div>
1834
+ <div class="modal-actions">
1835
+ <button type="button" class="modal-btn secondary" onclick="document.getElementById('backup-overlay').hidden = true" data-i18n="common.close">Close</button>
1836
+ <button type="button" class="modal-btn primary" id="backup-restore-btn" onclick="window._launcher.submitBackupRestore()" data-i18n="backup.restore" style="display:none">Restore selected</button>
1837
+ </div>
1838
+ </div>
1839
+ </div>
1840
+
1841
+ <!-- Subdomain Assign Modal (operator only) -->
1842
+ <div id="subdomain-overlay" hidden>
1843
+ <div class="modal" id="subdomain-modal">
1844
+ <h2 data-i18n="subModal.title">&#x1F310; Assign Subdomain</h2>
1845
+ <div style="font-size:.8rem;color:var(--text-muted);margin-bottom:8px" id="subdomain-app-label"></div>
1846
+ <div class="form-group">
1847
+ <label for="subdomain-input" data-i18n="subModal.label">Subdomain</label>
1848
+ <input type="text" id="subdomain-input" class="modal-input" placeholder="sanomat" autocomplete="off"/>
1849
+ <div style="font-size:.75rem;color:var(--text-muted);margin-top:4px" data-i18n="subModal.hint">Lowercase letters, numbers and hyphens (2-63 chars). The app is served at the subdomain root.</div>
1850
+ </div>
1851
+ <div id="subdomain-status" style="margin-top:8px;font-size:.85rem;min-height:1.1em"></div>
1852
+ <div class="modal-actions">
1853
+ <button type="button" class="modal-btn secondary" onclick="document.getElementById('subdomain-overlay').hidden = true" data-i18n="common.cancel">Cancel</button>
1854
+ <button type="button" class="modal-btn secondary" id="subdomain-unassign-btn" onclick="window._launcher.unassignSubdomain()" data-i18n="subModal.unassign">Unassign</button>
1855
+ <button type="button" class="modal-btn primary" id="subdomain-save-btn" onclick="window._launcher.submitSubdomainAssign()" data-i18n="common.save">Save</button>
1856
+ </div>
1857
+ </div>
1858
+ </div>
1859
+
1644
1860
  <!-- Versions Modal -->
1645
1861
  <div id="versions-overlay" hidden>
1646
1862
  <div class="modal" id="versions-modal">
@@ -1790,6 +2006,63 @@
1790
2006
  'card.versions': '🕑 Versions',
1791
2007
  'card.fork': '⑂ Fork',
1792
2008
  'card.restore': '↺ Restore',
2009
+ 'card.subdomain': '🌐 Subdomain',
2010
+ 'jsonImport.title': '📥 Import JSON backup',
2011
+ 'jsonImport.desc': 'Choose which apps to import. Apps already in this catalog are marked as duplicates and unchecked — nothing is imported without your selection.',
2012
+ 'jsonImport.statusDup': 'duplicate',
2013
+ 'jsonImport.config': 'Also import settings (theme, language, server URL)',
2014
+ 'jsonImport.done': 'Imported',
2015
+ 'dedup.btn': '🧹 Remove duplicate apps',
2016
+ 'dedup.none': 'No duplicate apps found.',
2017
+ 'dedup.confirm': 'Delete duplicate copies (favorites and oldest copies are kept)? Copies to delete:',
2018
+ 'dedup.done': 'Duplicates removed',
2019
+ 'srvImport.bannerText': 'published apps from your account are not in this catalog.',
2020
+ 'srvImport.review': '📥 Review & import...',
2021
+ 'srvImport.dismiss': 'Not now',
2022
+ 'srvImport.title': '📥 Import published apps',
2023
+ 'srvImport.desc': 'These apps are published under your account on the server but missing from this local catalog. Choose which ones to import — nothing is imported automatically.',
2024
+ 'srvImport.import': 'Import selected',
2025
+ 'srvImport.importing': 'Importing...',
2026
+ 'backup.menu': 'Backup',
2027
+ 'backup.exportAll': '📦 Export all (.zip)',
2028
+ 'backup.importFrom': '📥 Import from backup...',
2029
+ 'backup.title': '📥 Import from backup',
2030
+ 'backup.inspecting': 'Reading backup...',
2031
+ 'backup.exporting': 'Building backup...',
2032
+ 'backup.from': 'Backup from',
2033
+ 'backup.selectAll': 'Select all',
2034
+ 'backup.selectNone': 'Select none',
2035
+ 'backup.restoring': 'Restoring',
2036
+ 'backup.sumApps': 'apps',
2037
+ 'backup.sumVersions': 'versions',
2038
+ 'backup.sumExts': 'extensions',
2039
+ 'backup.colApp': 'App',
2040
+ 'backup.colVersions': 'Versions',
2041
+ 'backup.colStatus': 'Status',
2042
+ 'backup.statusNew': 'new',
2043
+ 'backup.statusExists': 'exists',
2044
+ 'backup.conflictSkip': 'Skip',
2045
+ 'backup.conflictAppend': 'Append versions',
2046
+ 'backup.conflictCopy': 'Import as copy',
2047
+ 'backup.extensions': '🔌 Extensions',
2048
+ 'backup.restore': 'Restore selected',
2049
+ 'backup.resultTitle': 'Restore complete',
2050
+ 'backup.resCreated': 'Created',
2051
+ 'backup.resAppended': 'Versions appended to',
2052
+ 'backup.resCopied': 'Imported as copy',
2053
+ 'backup.resSkipped': 'Skipped',
2054
+ 'backup.resVersions': 'Versions restored',
2055
+ 'backup.resErrors': 'Errors',
2056
+ 'backup.loginRequired': 'Sign in first to use backups.',
2057
+ 'backup.nothingSelected': 'Nothing selected.',
2058
+ 'backup.empty': 'The backup contains no apps or extensions.',
2059
+ 'subModal.title': '🌐 Assign Subdomain',
2060
+ 'subModal.label': 'Subdomain',
2061
+ 'subModal.hint': 'Lowercase letters, numbers and hyphens (2-63 chars). The app is served at the subdomain root.',
2062
+ 'subModal.unassign': 'Unassign',
2063
+ 'subModal.assigned': 'Subdomain assigned',
2064
+ 'subModal.unassigned': 'Subdomain unassigned',
2065
+ 'subModal.saving': 'Saving...',
1793
2066
  'tag.all': 'All',
1794
2067
  'tag.favorites': '⭐ Favorites',
1795
2068
  'empty.noApps': 'No apps yet',
@@ -1902,6 +2175,63 @@
1902
2175
  'card.versions': '🕑 Versiot',
1903
2176
  'card.fork': '⑂ Haaroita',
1904
2177
  'card.restore': '↺ Palauta',
2178
+ 'card.subdomain': '🌐 Alidomain',
2179
+ 'jsonImport.title': '📥 Tuo JSON-varmuuskopio',
2180
+ 'jsonImport.desc': 'Valitse mitkä appit tuodaan. Katalogissa jo olevat on merkitty duplikaateiksi ja jätetty valitsematta — mitään ei tuoda ilman valintaasi.',
2181
+ 'jsonImport.statusDup': 'duplikaatti',
2182
+ 'jsonImport.config': 'Tuo myös asetukset (teema, kieli, palvelimen osoite)',
2183
+ 'jsonImport.done': 'Tuotu',
2184
+ 'dedup.btn': '🧹 Poista duplikaattiappit',
2185
+ 'dedup.none': 'Duplikaattiappeja ei löytynyt.',
2186
+ 'dedup.confirm': 'Poistetaanko duplikaattikopiot (suosikit ja vanhimmat kopiot säilytetään)? Poistettavia kopioita:',
2187
+ 'dedup.done': 'Duplikaatit poistettu',
2188
+ 'srvImport.bannerText': 'tilisi julkaistua appia puuttuu tästä katalogista.',
2189
+ 'srvImport.review': '📥 Katso ja tuo...',
2190
+ 'srvImport.dismiss': 'Ei nyt',
2191
+ 'srvImport.title': '📥 Tuo julkaistut appit',
2192
+ 'srvImport.desc': 'Nämä appit on julkaistu tililläsi palvelimella mutta puuttuvat paikallisesta katalogista. Valitse mitkä tuodaan — mitään ei tuoda automaattisesti.',
2193
+ 'srvImport.import': 'Tuo valitut',
2194
+ 'srvImport.importing': 'Tuodaan...',
2195
+ 'backup.menu': 'Varmuuskopio',
2196
+ 'backup.exportAll': '📦 Vie kaikki (.zip)',
2197
+ 'backup.importFrom': '📥 Tuo varmuuskopiosta...',
2198
+ 'backup.title': '📥 Tuo varmuuskopiosta',
2199
+ 'backup.inspecting': 'Luetaan varmuuskopiota...',
2200
+ 'backup.exporting': 'Kootaan varmuuskopiota...',
2201
+ 'backup.from': 'Varmuuskopio lähteestä',
2202
+ 'backup.selectAll': 'Valitse kaikki',
2203
+ 'backup.selectNone': 'Tyhjennä valinta',
2204
+ 'backup.restoring': 'Palautetaan',
2205
+ 'backup.sumApps': 'appia',
2206
+ 'backup.sumVersions': 'versiota',
2207
+ 'backup.sumExts': 'laajennusta',
2208
+ 'backup.colApp': 'Appi',
2209
+ 'backup.colVersions': 'Versiot',
2210
+ 'backup.colStatus': 'Tila',
2211
+ 'backup.statusNew': 'uusi',
2212
+ 'backup.statusExists': 'olemassa',
2213
+ 'backup.conflictSkip': 'Ohita',
2214
+ 'backup.conflictAppend': 'Lisää versiot päälle',
2215
+ 'backup.conflictCopy': 'Tuo kopiona',
2216
+ 'backup.extensions': '🔌 Laajennukset',
2217
+ 'backup.restore': 'Palauta valitut',
2218
+ 'backup.resultTitle': 'Palautus valmis',
2219
+ 'backup.resCreated': 'Luotiin',
2220
+ 'backup.resAppended': 'Versiot lisätty päälle',
2221
+ 'backup.resCopied': 'Tuotu kopiona',
2222
+ 'backup.resSkipped': 'Ohitettiin',
2223
+ 'backup.resVersions': 'Versioita palautettu',
2224
+ 'backup.resErrors': 'Virheet',
2225
+ 'backup.loginRequired': 'Kirjaudu ensin sisään käyttääksesi varmuuskopioita.',
2226
+ 'backup.nothingSelected': 'Mitään ei ole valittu.',
2227
+ 'backup.empty': 'Varmuuskopiossa ei ole appeja eikä laajennuksia.',
2228
+ 'subModal.title': '🌐 Liitä alidomain',
2229
+ 'subModal.label': 'Alidomain',
2230
+ 'subModal.hint': 'Pienet kirjaimet, numerot ja viivat (2-63 merkkiä). Appi tarjoillaan alidomainin juuresta.',
2231
+ 'subModal.unassign': 'Poista liitos',
2232
+ 'subModal.assigned': 'Alidomain liitetty',
2233
+ 'subModal.unassigned': 'Liitos poistettu',
2234
+ 'subModal.saving': 'Tallennetaan...',
1905
2235
  'tag.all': 'Kaikki',
1906
2236
  'tag.favorites': '⭐ Suosikit',
1907
2237
  'empty.noApps': 'Ei vielä sovelluksia',
@@ -2813,7 +3143,8 @@
2813
3143
  '<button class="card-menu-btn" onclick="event.stopPropagation(); window._launcher.showContextMenu(event, \'' + escapeHtml(app.id) + '\')" title="Menu">\u22EE</button>' +
2814
3144
  '<div class="app-icon">' + escapeHtml(iconDisplay) + '</div>' +
2815
3145
  '<div class="app-name">' + escapeHtml(app.name) + '</div>' +
2816
- '<div class="app-source">' + sourceLabel(app.source) + '</div>' +
3146
+ '<div class="app-source">' + sourceLabel(app.source) +
3147
+ (app.origin === 'ai-published' ? ' <span class="ai-origin-badge">AI</span>' : '') + '</div>' +
2817
3148
  '<div class="app-actions">' +
2818
3149
  '<button onclick="event.stopPropagation(); window._launcher.launchApp(\'' + escapeHtml(app.id) + '\', \'tab\')" title="Open in tab">\u25B6 Tab</button>' +
2819
3150
  '<button onclick="event.stopPropagation(); window._launcher.launchApp(\'' + escapeHtml(app.id) + '\', \'iframe\')" title="Open in iframe">\u25FB</button>' +
@@ -2995,6 +3326,9 @@
2995
3326
 
2996
3327
  case 'delete':
2997
3328
  if (confirm('Delete "' + (app.name || 'this app') + '"?')) {
3329
+ // Tombstone published apps so the server copy isn't re-imported as a
3330
+ // "new" AI-published app on the next load.
3331
+ if (app.publishedFilename) addImportIgnore(app.publishedFilename);
2998
3332
  deleteApp(appId).then(function () {
2999
3333
  renderApps();
3000
3334
  loadPublishedApps();
@@ -3284,41 +3618,147 @@
3284
3618
  });
3285
3619
  }
3286
3620
 
3621
+ // Identity key for duplicate detection: a published app is the same app
3622
+ // regardless of its local id; unpublished ones match by name + content size.
3623
+ function localAppKey(a) {
3624
+ if (a.publishedFilename) return 'pf:' + a.publishedFilename;
3625
+ return 'nm:' + (a.name || '') + '|' + (a.blob ? a.blob.length : (a.url || ''));
3626
+ }
3627
+
3628
+ // Parsed JSON backup awaiting user selection: { backup, rows: [{app, isDup}] }
3629
+ var jsonImportState = null;
3630
+
3287
3631
  function handleImportBackup(file) {
3288
3632
  if (!file) return;
3289
3633
  var reader = new FileReader();
3290
3634
  reader.onload = function () {
3635
+ var backup;
3291
3636
  try {
3292
- var backup = JSON.parse(reader.result);
3293
- if (!backup.apps || !Array.isArray(backup.apps)) {
3294
- alert('Invalid backup file: missing apps array');
3295
- return;
3296
- }
3297
- // Import config if present
3298
- if (backup.config && typeof backup.config === 'object') {
3299
- var currentConfig = loadConfig();
3300
- var merged = Object.assign({}, currentConfig, backup.config);
3301
- saveConfig(merged);
3302
- applyTheme(merged.theme);
3303
- }
3304
- // Import apps with new IDs to avoid conflicts
3305
- var promises = [];
3306
- for (var i = 0; i < backup.apps.length; i++) {
3307
- var app = Object.assign({}, backup.apps[i]);
3308
- app.id = generateId(); // New ID to avoid conflicts
3309
- promises.push(saveApp(app));
3310
- }
3311
- Promise.all(promises).then(function () {
3312
- alert('Imported ' + backup.apps.length + ' app(s) successfully');
3313
- renderApps();
3314
- });
3637
+ backup = JSON.parse(reader.result);
3315
3638
  } catch (e) {
3316
3639
  alert('Failed to parse backup file: ' + (e.message || e));
3640
+ return;
3641
+ }
3642
+ if (!backup.apps || !Array.isArray(backup.apps)) {
3643
+ alert('Invalid backup file: missing apps array');
3644
+ return;
3317
3645
  }
3646
+ // Inspect-before-write: compare against the current catalog and let the
3647
+ // user choose. Duplicates default to unchecked — nothing is imported
3648
+ // (and no setting is changed) without an explicit selection.
3649
+ getAllApps().then(function (existing) {
3650
+ var existingKeys = {};
3651
+ existing.forEach(function (a) { existingKeys[localAppKey(a)] = true; });
3652
+ jsonImportState = {
3653
+ backup: backup,
3654
+ rows: backup.apps.map(function (a) {
3655
+ return { app: a, isDup: !!existingKeys[localAppKey(a)] };
3656
+ }),
3657
+ };
3658
+ renderJsonImportModal();
3659
+ });
3318
3660
  };
3319
3661
  reader.readAsText(file);
3320
3662
  }
3321
3663
 
3664
+ function renderJsonImportModal() {
3665
+ var rows = jsonImportState.rows;
3666
+ var body = document.getElementById('json-import-body');
3667
+ var html = '<p style="font-size:.8rem;color:var(--text-muted);margin:4px 0 8px">' + t('jsonImport.desc') + '</p>' +
3668
+ '<div class="backup-toolbar-row"><div>' +
3669
+ '<button class="backup-link-btn" onclick="window._launcher.jsonImportSelectAll(true)">' + t('backup.selectAll') + '</button> / ' +
3670
+ '<button class="backup-link-btn" onclick="window._launcher.jsonImportSelectAll(false)">' + t('backup.selectNone') + '</button>' +
3671
+ '</div></div>' +
3672
+ '<table class="backup-table"><tbody>';
3673
+ for (var i = 0; i < rows.length; i++) {
3674
+ var a = rows[i].app;
3675
+ html +=
3676
+ '<tr>' +
3677
+ '<td><input type="checkbox" class="json-import-cb" data-i="' + i + '"' + (rows[i].isDup ? '' : ' checked') + '/></td>' +
3678
+ '<td>' + escapeHtml(a.name || a.publishedFilename || 'Untitled') +
3679
+ (a.publishedFilename ? '<div class="mono-cell">' + escapeHtml(a.publishedFilename) + '</div>' : '') + '</td>' +
3680
+ '<td>' + sourceLabel(a.source) + '</td>' +
3681
+ '<td>' + (rows[i].isDup
3682
+ ? '<span class="backup-status-badge backup-status-exists">' + t('jsonImport.statusDup') + '</span>'
3683
+ : '<span class="backup-status-badge backup-status-new">' + t('backup.statusNew') + '</span>') + '</td>' +
3684
+ '</tr>';
3685
+ }
3686
+ html += '</tbody></table>';
3687
+ if (jsonImportState.backup.config && typeof jsonImportState.backup.config === 'object') {
3688
+ html += '<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.85rem">' +
3689
+ '<input type="checkbox" id="json-import-config"/> ' + t('jsonImport.config') + '</label>';
3690
+ }
3691
+ body.innerHTML = html;
3692
+ document.getElementById('json-import-status').textContent = '';
3693
+ document.getElementById('json-import-btn').style.display = '';
3694
+ document.getElementById('json-import-overlay').hidden = false;
3695
+ }
3696
+
3697
+ function jsonImportSelectAll(checked) {
3698
+ document.querySelectorAll('.json-import-cb').forEach(function (b) { b.checked = checked; });
3699
+ }
3700
+
3701
+ function submitJsonImport() {
3702
+ var statusEl = document.getElementById('json-import-status');
3703
+ var selected = [];
3704
+ document.querySelectorAll('.json-import-cb').forEach(function (b) {
3705
+ if (b.checked) selected.push(jsonImportState.rows[parseInt(b.getAttribute('data-i'), 10)].app);
3706
+ });
3707
+ var configCb = document.getElementById('json-import-config');
3708
+ var importConfig = !!(configCb && configCb.checked);
3709
+ if (selected.length === 0 && !importConfig) {
3710
+ statusEl.style.color = '#ef4444';
3711
+ statusEl.textContent = t('backup.nothingSelected');
3712
+ return;
3713
+ }
3714
+ if (importConfig) {
3715
+ var merged = Object.assign({}, loadConfig(), jsonImportState.backup.config);
3716
+ saveConfig(merged);
3717
+ applyTheme(merged.theme);
3718
+ }
3719
+ Promise.all(selected.map(function (a) {
3720
+ var app = Object.assign({}, a);
3721
+ app.id = generateId(); // new id — the source backup may collide with existing ids
3722
+ return saveApp(app);
3723
+ })).then(function () {
3724
+ statusEl.style.color = '#34d399';
3725
+ statusEl.textContent = '✔ ' + t('jsonImport.done') + ': ' + selected.length;
3726
+ document.getElementById('json-import-btn').style.display = 'none';
3727
+ renderApps();
3728
+ loadPublishedApps();
3729
+ });
3730
+ }
3731
+
3732
+ // ── Remove duplicate apps (cleanup after legacy blind imports) ──
3733
+
3734
+ function removeDuplicateApps() {
3735
+ getAllApps().then(function (apps) {
3736
+ var groups = {};
3737
+ apps.forEach(function (a) {
3738
+ var k = localAppKey(a);
3739
+ (groups[k] = groups[k] || []).push(a);
3740
+ });
3741
+ var toDelete = [];
3742
+ Object.keys(groups).forEach(function (k) {
3743
+ var g = groups[k];
3744
+ if (g.length < 2) return;
3745
+ // Keep the best copy: favorites first, then the earliest added
3746
+ g.sort(function (x, y) {
3747
+ if (!!y.favorite !== !!x.favorite) return (y.favorite ? 1 : 0) - (x.favorite ? 1 : 0);
3748
+ return (x.addedAt || '') < (y.addedAt || '') ? -1 : 1;
3749
+ });
3750
+ toDelete = toDelete.concat(g.slice(1));
3751
+ });
3752
+ if (toDelete.length === 0) { alert(t('dedup.none')); return; }
3753
+ if (!confirm(t('dedup.confirm') + ' ' + toDelete.length)) return;
3754
+ Promise.all(toDelete.map(function (a) { return deleteApp(a.id); })).then(function () {
3755
+ renderApps();
3756
+ loadPublishedApps();
3757
+ alert(t('dedup.done') + ': ' + toDelete.length);
3758
+ });
3759
+ });
3760
+ }
3761
+
3322
3762
  // ── Clear All Data ──────────────────────────────
3323
3763
 
3324
3764
  function clearAllData() {
@@ -3663,6 +4103,591 @@
3663
4103
  return !!a && a === bareOwnerName(sessionOwner);
3664
4104
  }
3665
4105
 
4106
+ // ── Operator subdomain mappings ─────────────────
4107
+ // Operators can map a subdomain to a published app (served at the subdomain
4108
+ // root). Mappings are loaded only for operator sessions; everyone else never
4109
+ // sees the controls and the admin API returns 403 anyway.
4110
+
4111
+ function getSessionRoles() {
4112
+ try {
4113
+ var token = getCortexOwnerToken();
4114
+ if (!token) return [];
4115
+ var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
4116
+ return payload.roles || [];
4117
+ } catch (e) { return []; }
4118
+ }
4119
+
4120
+ function isOperatorSession() {
4121
+ return getSessionRoles().indexOf('operator') !== -1;
4122
+ }
4123
+
4124
+ // target ("owner/filename") → site record; null until loaded
4125
+ var subdomainsByTarget = null;
4126
+
4127
+ function loadSubdomainSites() {
4128
+ if (!isOperatorSession()) {
4129
+ subdomainsByTarget = null;
4130
+ return Promise.resolve();
4131
+ }
4132
+ var config = loadConfig();
4133
+ var aimeatUrl = config.aimeatUrl ? config.aimeatUrl.replace(/\/+$/, '') : '';
4134
+ if (!aimeatUrl) { subdomainsByTarget = null; return Promise.resolve(); }
4135
+ return fetch(aimeatUrl + '/v1/admin/subdomains', {
4136
+ headers: { 'Authorization': 'Bearer ' + getCortexOwnerToken() }
4137
+ })
4138
+ .then(function (resp) { if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.json(); })
4139
+ .then(function (json) {
4140
+ subdomainsByTarget = {};
4141
+ var sites = (json.data && json.data.sites) || [];
4142
+ for (var i = 0; i < sites.length; i++) {
4143
+ if (sites[i].kind === 'app') subdomainsByTarget[sites[i].target] = sites[i];
4144
+ }
4145
+ })
4146
+ .catch(function () { subdomainsByTarget = null; });
4147
+ }
4148
+
4149
+ function apexHostLabel() {
4150
+ try {
4151
+ var config = loadConfig();
4152
+ return new URL(config.aimeatUrl || window.location.origin).host;
4153
+ } catch (e) { return window.location.host; }
4154
+ }
4155
+
4156
+ // The app target currently open in the subdomain modal: { target, existingSub }
4157
+ var subdomainModalState = null;
4158
+
4159
+ function showSubdomainModal(owner, filename) {
4160
+ var target = bareOwnerName(owner) + '/' + filename;
4161
+ var existing = (subdomainsByTarget && subdomainsByTarget[target]) || null;
4162
+ subdomainModalState = { target: target, existingSub: existing ? existing.subdomain : null };
4163
+ document.getElementById('subdomain-app-label').textContent = target;
4164
+ document.getElementById('subdomain-input').value = existing ? existing.subdomain : '';
4165
+ document.getElementById('subdomain-status').textContent = '';
4166
+ document.getElementById('subdomain-unassign-btn').style.display = existing ? '' : 'none';
4167
+ document.getElementById('subdomain-overlay').hidden = false;
4168
+ }
4169
+
4170
+ function subdomainApiCall(method, path, body) {
4171
+ var config = loadConfig();
4172
+ var aimeatUrl = config.aimeatUrl.replace(/\/+$/, '');
4173
+ return fetch(aimeatUrl + path, {
4174
+ method: method,
4175
+ headers: {
4176
+ 'Authorization': 'Bearer ' + getCortexOwnerToken(),
4177
+ 'Content-Type': 'application/json'
4178
+ },
4179
+ body: body ? JSON.stringify(body) : undefined
4180
+ }).then(function (resp) {
4181
+ return resp.json().then(function (json) {
4182
+ if (!json.ok) {
4183
+ var msg = (json.error && (json.error.message || json.error.code)) || ('HTTP ' + resp.status);
4184
+ throw new Error(msg);
4185
+ }
4186
+ return json;
4187
+ });
4188
+ });
4189
+ }
4190
+
4191
+ function submitSubdomainAssign() {
4192
+ if (!subdomainModalState) return;
4193
+ var sub = document.getElementById('subdomain-input').value.trim().toLowerCase();
4194
+ var statusEl = document.getElementById('subdomain-status');
4195
+ if (!sub) return;
4196
+ if (sub === subdomainModalState.existingSub) {
4197
+ document.getElementById('subdomain-overlay').hidden = true;
4198
+ return;
4199
+ }
4200
+ statusEl.style.color = 'var(--text-muted)';
4201
+ statusEl.textContent = t('subModal.saving');
4202
+ var oldSub = subdomainModalState.existingSub;
4203
+ subdomainApiCall('POST', '/v1/admin/subdomains', {
4204
+ subdomain: sub, kind: 'app', target: subdomainModalState.target
4205
+ })
4206
+ .then(function () {
4207
+ // Re-pointing to a new subdomain: drop the old mapping after the new
4208
+ // one exists, so the app is never left unmapped on failure.
4209
+ if (oldSub) return subdomainApiCall('DELETE', '/v1/admin/subdomains/' + encodeURIComponent(oldSub));
4210
+ })
4211
+ .then(function () {
4212
+ statusEl.style.color = '#34d399';
4213
+ statusEl.textContent = '✔ ' + t('subModal.assigned') + ' — ' + sub + '.' + apexHostLabel();
4214
+ return loadSubdomainSites();
4215
+ })
4216
+ .then(function () { loadPublishedApps(); })
4217
+ .catch(function (err) {
4218
+ statusEl.style.color = '#ef4444';
4219
+ statusEl.textContent = err.message || String(err);
4220
+ });
4221
+ }
4222
+
4223
+ function unassignSubdomain() {
4224
+ if (!subdomainModalState || !subdomainModalState.existingSub) return;
4225
+ var statusEl = document.getElementById('subdomain-status');
4226
+ statusEl.style.color = 'var(--text-muted)';
4227
+ statusEl.textContent = t('subModal.saving');
4228
+ subdomainApiCall('DELETE', '/v1/admin/subdomains/' + encodeURIComponent(subdomainModalState.existingSub))
4229
+ .then(function () {
4230
+ statusEl.style.color = '#34d399';
4231
+ statusEl.textContent = '✔ ' + t('subModal.unassigned');
4232
+ document.getElementById('subdomain-unassign-btn').style.display = 'none';
4233
+ subdomainModalState.existingSub = null;
4234
+ return loadSubdomainSites();
4235
+ })
4236
+ .then(function () { loadPublishedApps(); })
4237
+ .catch(function (err) {
4238
+ statusEl.style.color = '#ef4444';
4239
+ statusEl.textContent = err.message || String(err);
4240
+ });
4241
+ }
4242
+
4243
+ // ── Backup: export all + selective import ────────
4244
+ // Follows the organism-export bundle model: one ZIP, manifest + per-item
4245
+ // folders, inspect-before-write, explicit conflict modes — never a silent
4246
+ // overwrite.
4247
+
4248
+ function toggleBackupMenu(event) {
4249
+ if (event && event.stopPropagation) event.stopPropagation();
4250
+ var menu = document.getElementById('backup-menu');
4251
+ menu.hidden = !menu.hidden;
4252
+ }
4253
+
4254
+ function backupApiBase() {
4255
+ var config = loadConfig();
4256
+ return (config.aimeatUrl || window.location.origin).replace(/\/+$/, '');
4257
+ }
4258
+
4259
+ function exportBackupZip() {
4260
+ document.getElementById('backup-menu').hidden = true;
4261
+ var token = getCortexOwnerToken();
4262
+ if (!token) { alert(t('backup.loginRequired')); return; }
4263
+ var btn = document.getElementById('backup-btn');
4264
+ btn.disabled = true;
4265
+ fetch(backupApiBase() + '/v1/apps/backup', { headers: { 'Authorization': 'Bearer ' + token } })
4266
+ .then(function (r) {
4267
+ if (!r.ok) throw new Error('HTTP ' + r.status);
4268
+ var cd = r.headers.get('Content-Disposition') || '';
4269
+ var m = cd.match(/filename="([^"]+)"/);
4270
+ return r.blob().then(function (b) { return { blob: b, name: m ? m[1] : 'aimeat-apps-backup.zip' }; });
4271
+ })
4272
+ .then(function (o) {
4273
+ var a = document.createElement('a');
4274
+ a.href = URL.createObjectURL(o.blob);
4275
+ a.download = o.name;
4276
+ a.click();
4277
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000);
4278
+ })
4279
+ .catch(function (e) { alert('Export failed: ' + (e.message || e)); })
4280
+ .finally(function () { btn.disabled = false; });
4281
+ }
4282
+
4283
+ var backupInspectData = null; // inspect response (apps, extensions, backup_token)
4284
+
4285
+ function importBackupPick() {
4286
+ document.getElementById('backup-menu').hidden = true;
4287
+ var token = getCortexOwnerToken();
4288
+ if (!token) { alert(t('backup.loginRequired')); return; }
4289
+ document.getElementById('backup-file-input').click();
4290
+ }
4291
+
4292
+ function importBackupFile(file) {
4293
+ var overlay = document.getElementById('backup-overlay');
4294
+ var statusEl = document.getElementById('backup-status');
4295
+ var body = document.getElementById('backup-import-body');
4296
+ document.getElementById('backup-restore-btn').style.display = 'none';
4297
+ body.innerHTML = '';
4298
+ statusEl.style.color = 'var(--text-muted)';
4299
+ statusEl.textContent = t('backup.inspecting');
4300
+ overlay.hidden = false;
4301
+
4302
+ file.arrayBuffer()
4303
+ .then(function (buf) {
4304
+ return fetch(backupApiBase() + '/v1/apps/backup/inspect', {
4305
+ method: 'POST',
4306
+ headers: {
4307
+ 'Authorization': 'Bearer ' + getCortexOwnerToken(),
4308
+ 'Content-Type': 'application/zip'
4309
+ },
4310
+ body: buf
4311
+ });
4312
+ })
4313
+ .then(function (r) { return r.json().then(function (j) { return { status: r.status, json: j }; }); })
4314
+ .then(function (o) {
4315
+ if (!o.json.ok) {
4316
+ var msg = (o.json.error && (o.json.error.message || o.json.error.code)) || ('HTTP ' + o.status);
4317
+ throw new Error(msg);
4318
+ }
4319
+ backupInspectData = o.json.data;
4320
+ statusEl.textContent = '';
4321
+ renderBackupSelection();
4322
+ })
4323
+ .catch(function (e) {
4324
+ statusEl.style.color = '#ef4444';
4325
+ statusEl.textContent = e.message || String(e);
4326
+ });
4327
+ }
4328
+
4329
+ function renderBackupSelection() {
4330
+ var d = backupInspectData;
4331
+ var body = document.getElementById('backup-import-body');
4332
+ if (!d || (d.apps.length === 0 && d.extensions.length === 0)) {
4333
+ body.innerHTML = '<p style="color:var(--text-muted)">' + t('backup.empty') + '</p>';
4334
+ return;
4335
+ }
4336
+
4337
+ var conflictOptions =
4338
+ '<option value="skip">' + t('backup.conflictSkip') + '</option>' +
4339
+ '<option value="append">' + t('backup.conflictAppend') + '</option>' +
4340
+ '<option value="copy">' + t('backup.conflictCopy') + '</option>';
4341
+
4342
+ var html =
4343
+ '<div style="font-size:.8rem;color:var(--text-muted)">' + t('backup.from') + ': <span style="font-family:monospace">' +
4344
+ escapeHtml((d.source.owner || '?') + '@' + (d.source.nodeId || '?')) + '</span>' +
4345
+ (d.exported_at ? ' · ' + new Date(d.exported_at).toLocaleString() : '') +
4346
+ '</div>' +
4347
+ '<div class="backup-toolbar-row">' +
4348
+ '<div>' +
4349
+ '<button class="backup-link-btn" onclick="window._launcher.backupSelectAll(true)">' + t('backup.selectAll') + '</button> / ' +
4350
+ '<button class="backup-link-btn" onclick="window._launcher.backupSelectAll(false)">' + t('backup.selectNone') + '</button>' +
4351
+ '</div>' +
4352
+ '<div class="backup-summary-line" id="backup-summary"></div>' +
4353
+ '</div>' +
4354
+ '<table class="backup-table"><thead><tr>' +
4355
+ '<th></th><th>' + t('backup.colApp') + '</th><th>' + t('backup.colVersions') + '</th><th>' + t('backup.colStatus') + '</th>' +
4356
+ '</tr></thead><tbody>';
4357
+
4358
+ for (var i = 0; i < d.apps.length; i++) {
4359
+ var app = d.apps[i];
4360
+ var versionList = '';
4361
+ for (var v = 0; v < app.versions.length; v++) {
4362
+ var ver = app.versions[v];
4363
+ versionList +=
4364
+ '<label><input type="checkbox" class="backup-ver" data-app="' + i + '" data-v="' + ver.version + '" checked ' +
4365
+ 'onchange="window._launcher.backupUpdateSummary()"/> v' + ver.version +
4366
+ (ver.semver ? ' (' + escapeHtml(ver.semver) + ')' : '') +
4367
+ ' · ' + (ver.size < 1024 ? ver.size + ' B' : (ver.size / 1024).toFixed(1) + ' KB') +
4368
+ (ver.created_at ? ' · ' + new Date(ver.created_at).toLocaleDateString() : '') +
4369
+ '</label>';
4370
+ }
4371
+ html +=
4372
+ '<tr>' +
4373
+ '<td><input type="checkbox" id="backup-app-' + i + '" checked onchange="window._launcher.backupUpdateSummary()"/></td>' +
4374
+ '<td>' + escapeHtml(app.name || app.filename) +
4375
+ '<div class="mono-cell">' + escapeHtml(app.filename) + '</div></td>' +
4376
+ '<td>' + app.versions.length + ' ' +
4377
+ '<button class="backup-versions-toggle" onclick="var el=document.getElementById(\'backup-app-' + i + '-versions\');el.style.display=el.style.display===\'none\'?\'\':\'none\'">' + t('backup.colVersions').toLowerCase() + ' ▾</button>' +
4378
+ '<div class="backup-version-list" id="backup-app-' + i + '-versions" style="display:none">' + versionList + '</div></td>' +
4379
+ '<td>' +
4380
+ (app.exists
4381
+ ? '<span class="backup-status-badge backup-status-exists">' + t('backup.statusExists') + '</span><br/>' +
4382
+ '<select class="backup-conflict-select" id="backup-app-' + i + '-conflict">' + conflictOptions + '</select>'
4383
+ : '<span class="backup-status-badge backup-status-new">' + t('backup.statusNew') + '</span>') +
4384
+ '</td>' +
4385
+ '</tr>';
4386
+ }
4387
+ html += '</tbody></table>';
4388
+
4389
+ if (d.extensions.length > 0) {
4390
+ html += '<h3 style="font-size:.9rem;margin:14px 0 6px">' + t('backup.extensions') + '</h3>';
4391
+ for (var e = 0; e < d.extensions.length; e++) {
4392
+ var ext = d.extensions[e];
4393
+ html +=
4394
+ '<div style="display:flex;align-items:center;gap:8px;padding:4px 8px;font-size:.85rem">' +
4395
+ '<input type="checkbox" id="backup-ext-' + e + '" checked onchange="window._launcher.backupUpdateSummary()"/>' +
4396
+ '<span style="font-family:monospace">' + escapeHtml(ext.name) + '</span>' +
4397
+ (ext.exists
4398
+ ? '<span class="backup-status-badge backup-status-exists">' + t('backup.statusExists') + '</span>' +
4399
+ '<select class="backup-conflict-select" id="backup-ext-' + e + '-conflict">' +
4400
+ '<option value="skip">' + t('backup.conflictSkip') + '</option>' +
4401
+ '<option value="copy">' + t('backup.conflictCopy') + '</option>' +
4402
+ '</select>'
4403
+ : '<span class="backup-status-badge backup-status-new">' + t('backup.statusNew') + '</span>') +
4404
+ '</div>';
4405
+ }
4406
+ }
4407
+
4408
+ body.innerHTML = html;
4409
+ document.getElementById('backup-restore-btn').style.display = '';
4410
+ backupUpdateSummary();
4411
+ }
4412
+
4413
+ // Reads the current selection straight from the DOM — no parallel state to drift.
4414
+ function backupCollectSelections() {
4415
+ var d = backupInspectData;
4416
+ var selections = [];
4417
+ var extensions = [];
4418
+ if (!d) return { selections: selections, extensions: extensions, versionTotal: 0 };
4419
+ var versionTotal = 0;
4420
+ for (var i = 0; i < d.apps.length; i++) {
4421
+ var cb = document.getElementById('backup-app-' + i);
4422
+ if (!cb || !cb.checked) continue;
4423
+ var app = d.apps[i];
4424
+ var checkedVers = [];
4425
+ var verBoxes = document.querySelectorAll('.backup-ver[data-app="' + i + '"]');
4426
+ verBoxes.forEach(function (b) { if (b.checked) checkedVers.push(parseInt(b.getAttribute('data-v'), 10)); });
4427
+ if (checkedVers.length === 0) continue;
4428
+ var sel = { filename: app.filename };
4429
+ if (checkedVers.length !== app.versions.length) sel.versions = checkedVers;
4430
+ if (app.exists) {
4431
+ var conflictEl = document.getElementById('backup-app-' + i + '-conflict');
4432
+ sel.conflict = conflictEl ? conflictEl.value : 'skip';
4433
+ }
4434
+ versionTotal += checkedVers.length;
4435
+ selections.push(sel);
4436
+ }
4437
+ for (var e = 0; e < d.extensions.length; e++) {
4438
+ var ecb = document.getElementById('backup-ext-' + e);
4439
+ if (!ecb || !ecb.checked) continue;
4440
+ var ext = { name: d.extensions[e].name };
4441
+ if (d.extensions[e].exists) {
4442
+ var ecEl = document.getElementById('backup-ext-' + e + '-conflict');
4443
+ ext.conflict = ecEl ? ecEl.value : 'skip';
4444
+ }
4445
+ extensions.push(ext);
4446
+ }
4447
+ return { selections: selections, extensions: extensions, versionTotal: versionTotal };
4448
+ }
4449
+
4450
+ function backupUpdateSummary() {
4451
+ var sel = backupCollectSelections();
4452
+ var el = document.getElementById('backup-summary');
4453
+ if (!el) return;
4454
+ el.textContent = t('backup.restoring') + ': ' + sel.selections.length + ' ' + t('backup.sumApps') +
4455
+ ', ' + sel.versionTotal + ' ' + t('backup.sumVersions') +
4456
+ (sel.extensions.length ? ', ' + sel.extensions.length + ' ' + t('backup.sumExts') : '');
4457
+ }
4458
+
4459
+ function backupSelectAll(checked) {
4460
+ var d = backupInspectData;
4461
+ if (!d) return;
4462
+ for (var i = 0; i < d.apps.length; i++) {
4463
+ var cb = document.getElementById('backup-app-' + i);
4464
+ if (cb) cb.checked = checked;
4465
+ document.querySelectorAll('.backup-ver[data-app="' + i + '"]').forEach(function (b) { b.checked = checked; });
4466
+ }
4467
+ for (var e = 0; e < d.extensions.length; e++) {
4468
+ var ecb = document.getElementById('backup-ext-' + e);
4469
+ if (ecb) ecb.checked = checked;
4470
+ }
4471
+ backupUpdateSummary();
4472
+ }
4473
+
4474
+ function submitBackupRestore() {
4475
+ var sel = backupCollectSelections();
4476
+ var statusEl = document.getElementById('backup-status');
4477
+ if (sel.selections.length === 0 && sel.extensions.length === 0) {
4478
+ statusEl.style.color = '#ef4444';
4479
+ statusEl.textContent = t('backup.nothingSelected');
4480
+ return;
4481
+ }
4482
+ statusEl.style.color = 'var(--text-muted)';
4483
+ statusEl.textContent = t('backup.restoring') + '...';
4484
+ var btn = document.getElementById('backup-restore-btn');
4485
+ btn.disabled = true;
4486
+ fetch(backupApiBase() + '/v1/apps/backup/restore', {
4487
+ method: 'POST',
4488
+ headers: {
4489
+ 'Authorization': 'Bearer ' + getCortexOwnerToken(),
4490
+ 'Content-Type': 'application/json'
4491
+ },
4492
+ body: JSON.stringify({
4493
+ backup_token: backupInspectData.backup_token,
4494
+ selections: sel.selections,
4495
+ extensions: sel.extensions
4496
+ })
4497
+ })
4498
+ .then(function (r) { return r.json(); })
4499
+ .then(function (json) {
4500
+ if (!json.ok) {
4501
+ var msg = (json.error && (json.error.message || json.error.code)) || 'Restore failed';
4502
+ throw new Error(msg);
4503
+ }
4504
+ statusEl.textContent = '';
4505
+ renderBackupResult(json.data);
4506
+ refreshAll();
4507
+ })
4508
+ .catch(function (e) {
4509
+ statusEl.style.color = '#ef4444';
4510
+ statusEl.textContent = e.message || String(e);
4511
+ })
4512
+ .finally(function () { btn.disabled = false; });
4513
+ }
4514
+
4515
+ function renderBackupResult(s) {
4516
+ var body = document.getElementById('backup-import-body');
4517
+ document.getElementById('backup-restore-btn').style.display = 'none';
4518
+ var section = function (label, items) {
4519
+ if (!items || items.length === 0) return '';
4520
+ var rendered = items.map(function (x) {
4521
+ if (typeof x === 'string') return escapeHtml(x);
4522
+ if (x && x.from && x.to) return escapeHtml(x.from) + ' → ' + escapeHtml(x.to);
4523
+ if (x && x.item) return escapeHtml(x.item) + ': ' + escapeHtml(x.message || '');
4524
+ return escapeHtml(JSON.stringify(x));
4525
+ });
4526
+ return '<div style="font-size:.85rem;font-weight:600;margin-top:8px">' + label + ' (' + items.length + ')</div>' +
4527
+ '<ul class="backup-result-list">' + rendered.map(function (r) { return '<li>' + r + '</li>'; }).join('') + '</ul>';
4528
+ };
4529
+ body.innerHTML =
4530
+ '<h3 style="font-size:.95rem;margin:6px 0">✔ ' + t('backup.resultTitle') + '</h3>' +
4531
+ '<div style="font-size:.85rem">' + t('backup.resVersions') + ': ' + s.versions_restored + '</div>' +
4532
+ section(t('backup.resCreated'), s.apps_created.concat(s.extensions_created)) +
4533
+ section(t('backup.resAppended'), s.apps_appended) +
4534
+ section(t('backup.resCopied'), s.apps_copied.concat(s.extensions_copied)) +
4535
+ section(t('backup.resSkipped'), s.apps_skipped.concat(s.extensions_skipped)) +
4536
+ (s.errors.length ? '<div class="backup-result-errors">' + section(t('backup.resErrors'), s.errors) + '</div>' : '');
4537
+ }
4538
+
4539
+ // ── Server → local import (user-selected, NEVER automatic) ────
4540
+ // Apps published under this owner's account (e.g. via MCP) may exist only on
4541
+ // the server. Importing one into the local catalog gives it the exact same
4542
+ // management actions (edit, rename, tags, versions, delete) as locally
4543
+ // created apps. The import is ALWAYS user-initiated: a banner reports how
4544
+ // many published apps are missing locally, and a review modal lets the user
4545
+ // pick exactly which ones to import. The tombstone list marks apps the user
4546
+ // deleted locally so the banner doesn't nag about them (they stay reachable
4547
+ // in the modal, unchecked).
4548
+
4549
+ var IMPORT_IGNORE_KEY = 'appCatalogImportIgnore';
4550
+
4551
+ function getImportIgnore() {
4552
+ try { return JSON.parse(localStorage.getItem(IMPORT_IGNORE_KEY) || '[]'); } catch (e) { return []; }
4553
+ }
4554
+
4555
+ function addImportIgnore(filename) {
4556
+ var list = getImportIgnore();
4557
+ if (list.indexOf(filename) === -1) {
4558
+ list.push(filename);
4559
+ localStorage.setItem(IMPORT_IGNORE_KEY, JSON.stringify(list));
4560
+ }
4561
+ }
4562
+
4563
+ function removeImportIgnore(filename) {
4564
+ var list = getImportIgnore().filter(function (f) { return f !== filename; });
4565
+ localStorage.setItem(IMPORT_IGNORE_KEY, JSON.stringify(list));
4566
+ }
4567
+
4568
+ // State for the review modal: own server apps with no local copy.
4569
+ var serverImportState = { missing: [], aimeatUrl: '' };
4570
+
4571
+ function updateServerImportState(ownApps, localByFilename, aimeatUrl) {
4572
+ serverImportState.missing = ownApps.filter(function (sa) {
4573
+ return sa.filename && !localByFilename[sa.filename];
4574
+ });
4575
+ serverImportState.aimeatUrl = aimeatUrl;
4576
+ renderServerImportBanner();
4577
+ }
4578
+
4579
+ function renderServerImportBanner() {
4580
+ var banner = document.getElementById('server-import-banner');
4581
+ if (!banner) return;
4582
+ var ignore = getImportIgnore();
4583
+ // Tombstoned apps (deleted locally on purpose) don't count toward the nag
4584
+ var fresh = serverImportState.missing.filter(function (sa) { return ignore.indexOf(sa.filename) === -1; });
4585
+ if (fresh.length === 0 || sessionStorage.getItem('aimeatServerImportDismissed') === '1') {
4586
+ banner.style.display = 'none';
4587
+ return;
4588
+ }
4589
+ document.getElementById('server-import-count').textContent = fresh.length;
4590
+ banner.style.display = '';
4591
+ }
4592
+
4593
+ function dismissServerImportBanner() {
4594
+ sessionStorage.setItem('aimeatServerImportDismissed', '1');
4595
+ renderServerImportBanner();
4596
+ }
4597
+
4598
+ function showServerImportModal() {
4599
+ var missing = serverImportState.missing;
4600
+ var body = document.getElementById('server-import-body');
4601
+ var ignore = getImportIgnore();
4602
+ var html = '<p style="font-size:.8rem;color:var(--text-muted);margin:4px 0 8px">' + t('srvImport.desc') + '</p>' +
4603
+ '<div class="backup-toolbar-row"><div>' +
4604
+ '<button class="backup-link-btn" onclick="window._launcher.serverImportSelectAll(true)">' + t('backup.selectAll') + '</button> / ' +
4605
+ '<button class="backup-link-btn" onclick="window._launcher.serverImportSelectAll(false)">' + t('backup.selectNone') + '</button>' +
4606
+ '</div></div>' +
4607
+ '<table class="backup-table"><tbody>';
4608
+ for (var i = 0; i < missing.length; i++) {
4609
+ var sa = missing[i];
4610
+ var name = (sa.manifest && sa.manifest.name) ? sa.manifest.name : sa.filename;
4611
+ // Locally deleted (tombstoned) apps stay reachable here but default to unchecked
4612
+ var checked = ignore.indexOf(sa.filename) === -1 ? ' checked' : '';
4613
+ html +=
4614
+ '<tr>' +
4615
+ '<td><input type="checkbox" class="server-import-cb" data-i="' + i + '"' + checked + '/></td>' +
4616
+ '<td>' + escapeHtml(name) + '<div class="mono-cell">' + escapeHtml(sa.filename) + '</div></td>' +
4617
+ '<td>' + (sa.version_number ? 'v' + sa.version_number : '') + '</td>' +
4618
+ '<td>' + (sa.created_at ? new Date(sa.created_at).toLocaleDateString() : '') + '</td>' +
4619
+ '</tr>';
4620
+ }
4621
+ html += '</tbody></table>';
4622
+ body.innerHTML = html;
4623
+ document.getElementById('server-import-status').textContent = '';
4624
+ document.getElementById('server-import-overlay').hidden = false;
4625
+ }
4626
+
4627
+ function serverImportSelectAll(checked) {
4628
+ document.querySelectorAll('.server-import-cb').forEach(function (b) { b.checked = checked; });
4629
+ }
4630
+
4631
+ function submitServerImport() {
4632
+ var selected = [];
4633
+ document.querySelectorAll('.server-import-cb').forEach(function (b) {
4634
+ if (b.checked) selected.push(serverImportState.missing[parseInt(b.getAttribute('data-i'), 10)]);
4635
+ });
4636
+ var statusEl = document.getElementById('server-import-status');
4637
+ if (selected.length === 0) {
4638
+ statusEl.style.color = '#ef4444';
4639
+ statusEl.textContent = t('backup.nothingSelected');
4640
+ return;
4641
+ }
4642
+ statusEl.style.color = 'var(--text-muted)';
4643
+ statusEl.textContent = t('srvImport.importing');
4644
+ importServerApps(selected, serverImportState.aimeatUrl).then(function () {
4645
+ // Importing an app the user once deleted is an explicit re-opt-in
4646
+ selected.forEach(function (sa) { removeImportIgnore(sa.filename); });
4647
+ document.getElementById('server-import-overlay').hidden = true;
4648
+ renderApps();
4649
+ loadPublishedApps();
4650
+ }).catch(function (e) {
4651
+ statusEl.style.color = '#ef4444';
4652
+ statusEl.textContent = e.message || String(e);
4653
+ });
4654
+ }
4655
+
4656
+ function importServerApps(toImport, aimeatUrl) {
4657
+ if (toImport.length === 0) return Promise.resolve(false);
4658
+ return Promise.all(toImport.map(function (sa) {
4659
+ var url = aimeatUrl + '/v1/apps/' + encodeURIComponent(sa.owner || '') + '/' + encodeURIComponent(sa.filename) + '?mode=inline';
4660
+ return fetch(url)
4661
+ .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
4662
+ .then(function (source) {
4663
+ var manifest = sa.manifest || {};
4664
+ var app = {
4665
+ id: generateId(),
4666
+ name: manifest.name || sa.filename,
4667
+ description: manifest.description || '',
4668
+ source: 'aimeat',
4669
+ origin: 'ai-published',
4670
+ url: null,
4671
+ blob: btoa(unescape(encodeURIComponent(source))),
4672
+ tags: manifest.tags || [],
4673
+ openMode: 'tab',
4674
+ icon: manifest.icon || '\u{1F916}',
4675
+ screenshot: null,
4676
+ favorite: false,
4677
+ addedAt: sa.created_at || new Date().toISOString(),
4678
+ lastOpenedAt: null,
4679
+ published: true,
4680
+ publishedFilename: sa.filename,
4681
+ publishedAt: sa.created_at || new Date().toISOString(),
4682
+ publishedUrl: '/v1/apps/' + encodeURIComponent(sa.owner || '') + '/' + encodeURIComponent(sa.filename),
4683
+ publishedVersionNumber: sa.version_number || 1
4684
+ };
4685
+ return saveApp(app);
4686
+ })
4687
+ .catch(function () { return null; }); // protected/unreachable app — skip silently
4688
+ })).then(function () { return true; });
4689
+ }
4690
+
3666
4691
  // Manifest cache keyed by "owner\nfilename" — lets Restore/Fork reuse the
3667
4692
  // app's metadata (name, description, category, tags, icon) without a re-fetch.
3668
4693
  var serverAppManifests = {};
@@ -3729,8 +4754,14 @@
3729
4754
  ? serverApps.filter(function(a) { return !sameOwner(a.owner, currentOwner); })
3730
4755
  : serverApps;
3731
4756
 
3732
- renderPublishedApps(localPublished, ownApps, localByFilename, aimeatUrl, section, grid, countEl);
3733
- renderCommunityApps(communityApps, aimeatUrl, communitySection, communityGrid, communityCountEl, currentOwner);
4757
+ return loadSubdomainSites().then(function () {
4758
+ renderPublishedApps(localPublished, ownApps, localByFilename, aimeatUrl, section, grid, countEl);
4759
+ renderCommunityApps(communityApps, aimeatUrl, communitySection, communityGrid, communityCountEl, currentOwner);
4760
+ // Server-published own apps missing from the local catalog are
4761
+ // NEVER imported automatically — the banner offers a review modal
4762
+ // where the user picks exactly which ones to import.
4763
+ updateServerImportState(ownApps, localByFilename, aimeatUrl);
4764
+ });
3734
4765
  })
3735
4766
  .catch(function() {
3736
4767
  renderPublishedApps(localPublished, [], localByFilename, aimeatUrl, section, grid, countEl);
@@ -3829,11 +4860,29 @@
3829
4860
  section.style.display = '';
3830
4861
  countEl.textContent = '(' + merged.length + ')';
3831
4862
 
4863
+ // Operator-only: subdomain chip/assign controls on own server-published cards
4864
+ var showSubdomainControls = !!subdomainsByTarget && isOperatorSession();
4865
+
3832
4866
  var html = '';
3833
4867
  for (var i = 0; i < merged.length; i++) {
3834
4868
  var app = merged[i];
3835
4869
  var badgeClass = app.source === 'both' ? 'both' : (app.source === 'server' ? 'server' : 'local');
3836
4870
  var badgeLabel = app.source === 'both' ? 'PORTAL + MCP' : (app.source === 'server' ? 'MCP' : 'PORTAL');
4871
+
4872
+ var subChipHtml = '';
4873
+ var subBtnHtml = '';
4874
+ if (showSubdomainControls && app.isOwn && app.filename) {
4875
+ var targetKey = bareOwnerName(app.owner) + '/' + app.filename;
4876
+ var mapping = subdomainsByTarget[targetKey] || null;
4877
+ var openModal = 'window._launcher.showSubdomainModal(\'' + escapeHtml(app.owner || '') + '\', \'' + escapeHtml(app.filename) + '\')';
4878
+ if (mapping) {
4879
+ subChipHtml = '<div><span class="subdomain-chip" onclick="' + openModal + '" title="' + t('subModal.title') + '">&#x1F310; ' +
4880
+ escapeHtml(mapping.subdomain + '.' + apexHostLabel()) + '</span></div>';
4881
+ } else {
4882
+ subBtnHtml = '<button onclick="' + openModal + '">' + t('card.subdomain') + '</button>';
4883
+ }
4884
+ }
4885
+
3837
4886
  html +=
3838
4887
  '<div class="published-card">' +
3839
4888
  '<div class="published-card-name">' + escapeHtml(app.name) + (app.version ? ' <span style="opacity:.5;font-size:.85em">' + escapeHtml(app.version) + '</span>' : '') +
@@ -3843,9 +4892,11 @@
3843
4892
  '<span>' + escapeHtml(app.filename) + '</span>' +
3844
4893
  '<span>' + app.date + '</span>' +
3845
4894
  '</div>' +
4895
+ subChipHtml +
3846
4896
  '<div class="published-card-actions">' +
3847
4897
  (app.viewUrl ? '<button onclick="window.open(\'' + escapeHtml(app.viewUrl) + '?mode=inline\', \'_blank\')">' + t('card.view') + '</button>' : '') +
3848
4898
  (app.isOwn && app.filename ? '<button onclick="window._launcher.showVersionsModal(\'' + escapeHtml(app.owner || '') + '\', \'' + escapeHtml(app.filename) + '\')">' + t('card.versions') + '</button>' : '') +
4899
+ subBtnHtml +
3849
4900
  (app.localId ? '<button onclick="window._launcher.unpublishApp(\'' + escapeHtml(app.localId) + '\')" class="danger">' + t('card.remove') + '</button>' :
3850
4901
  app.isOwn ? '<button onclick="window._launcher.deleteServerApp(\'' + escapeHtml(app.filename) + '\')" class="danger">' + t('card.remove') + '</button>' : '') +
3851
4902
  '</div>' +
@@ -3875,6 +4926,7 @@
3875
4926
  .then(function(resp) { return resp.json(); })
3876
4927
  .then(function(json) {
3877
4928
  if (json.ok) {
4929
+ removeImportIgnore(app.publishedFilename);
3878
4930
  app.published = false;
3879
4931
  delete app.publishedFilename;
3880
4932
  delete app.publishedAt;
@@ -3906,6 +4958,7 @@
3906
4958
  .then(function(resp) { return resp.json(); })
3907
4959
  .then(function(json) {
3908
4960
  if (json.ok) {
4961
+ removeImportIgnore(filename); // a future republish should import again
3909
4962
  loadPublishedApps();
3910
4963
  } else {
3911
4964
  alert('Failed to delete: ' + (json.error && json.error.message ? json.error.message : 'Unknown error'));
@@ -4886,6 +5939,22 @@
4886
5939
  switchDbMode: switchDbMode,
4887
5940
  unpublishApp: unpublishApp,
4888
5941
  deleteServerApp: deleteServerApp,
5942
+ showSubdomainModal: showSubdomainModal,
5943
+ submitSubdomainAssign: submitSubdomainAssign,
5944
+ unassignSubdomain: unassignSubdomain,
5945
+ jsonImportSelectAll: jsonImportSelectAll,
5946
+ submitJsonImport: submitJsonImport,
5947
+ removeDuplicateApps: removeDuplicateApps,
5948
+ showServerImportModal: showServerImportModal,
5949
+ dismissServerImportBanner: dismissServerImportBanner,
5950
+ serverImportSelectAll: serverImportSelectAll,
5951
+ submitServerImport: submitServerImport,
5952
+ toggleBackupMenu: toggleBackupMenu,
5953
+ exportBackupZip: exportBackupZip,
5954
+ importBackupPick: importBackupPick,
5955
+ backupSelectAll: backupSelectAll,
5956
+ backupUpdateSummary: backupUpdateSummary,
5957
+ submitBackupRestore: submitBackupRestore,
4889
5958
  showVersionsModal: showVersionsModal,
4890
5959
  restoreVersion: restoreVersion,
4891
5960
  forkVersion: forkVersion,
@@ -5002,6 +6071,40 @@
5002
6071
  }
5003
6072
  });
5004
6073
 
6074
+ // ── Click subdomain overlay to close ────────────
6075
+ document.getElementById('subdomain-overlay').addEventListener('click', function (e) {
6076
+ if (e.target === this) {
6077
+ this.hidden = true;
6078
+ }
6079
+ });
6080
+
6081
+ // ── Backup: file picker + overlay/menu close ────
6082
+ document.getElementById('backup-file-input').addEventListener('change', function () {
6083
+ if (this.files && this.files[0]) importBackupFile(this.files[0]);
6084
+ this.value = '';
6085
+ });
6086
+ document.getElementById('backup-overlay').addEventListener('click', function (e) {
6087
+ if (e.target === this) {
6088
+ this.hidden = true;
6089
+ }
6090
+ });
6091
+ document.getElementById('server-import-overlay').addEventListener('click', function (e) {
6092
+ if (e.target === this) {
6093
+ this.hidden = true;
6094
+ }
6095
+ });
6096
+ document.getElementById('json-import-overlay').addEventListener('click', function (e) {
6097
+ if (e.target === this) {
6098
+ this.hidden = true;
6099
+ }
6100
+ });
6101
+ document.addEventListener('click', function (e) {
6102
+ var menu = document.getElementById('backup-menu');
6103
+ if (!menu.hidden && !menu.contains(e.target) && e.target.id !== 'backup-btn') {
6104
+ menu.hidden = true;
6105
+ }
6106
+ });
6107
+
5005
6108
  // ── Click cortex popup overlay to close ─────────
5006
6109
  document.getElementById('cortex-popup-overlay').addEventListener('click', function (e) {
5007
6110
  if (e.target === this) {
@@ -5142,6 +6245,14 @@
5142
6245
  document.getElementById('aimeat-import-overlay').hidden = true;
5143
6246
  } else if (!document.getElementById('publish-overlay').hidden) {
5144
6247
  document.getElementById('publish-overlay').hidden = true;
6248
+ } else if (!document.getElementById('subdomain-overlay').hidden) {
6249
+ document.getElementById('subdomain-overlay').hidden = true;
6250
+ } else if (!document.getElementById('backup-overlay').hidden) {
6251
+ document.getElementById('backup-overlay').hidden = true;
6252
+ } else if (!document.getElementById('server-import-overlay').hidden) {
6253
+ document.getElementById('server-import-overlay').hidden = true;
6254
+ } else if (!document.getElementById('json-import-overlay').hidden) {
6255
+ document.getElementById('json-import-overlay').hidden = true;
5145
6256
  } else if (!document.getElementById('settings-overlay').hidden) {
5146
6257
  closeSettings();
5147
6258
  } else if (!document.getElementById('modal-overlay').hidden) {