Update.svelte raw

   1  <script>
   2      import { onMount } from 'svelte';
   3      import { userSigner, userPubkey, binariesData, isLoading, error } from '../stores.js';
   4      import { fetchBinaries, updateBinaries, rollbackVersion, restartServices, restartService, fetchReleases } from '../api.js';
   5  
   6      let version = '';
   7      let releaseBaseUrl = '';
   8      let architecture = 'amd64';
   9      let updateResult = null;
  10      let isUpdating = false;
  11      let launcherUpdated = false;
  12  
  13      // Official releases - fetched via backend proxy to avoid CORS
  14      const RELEASES_BASE = 'https://git.nostrdev.com/mleku/next.orly.dev/releases/download';
  15      let availableReleases = [];
  16      let selectedRelease = '';
  17      let loadingReleases = false;
  18  
  19      // Category definitions with available options
  20      const categoryDefs = {
  21          launcher: {
  22              label: 'Launcher',
  23              options: [
  24                  { value: 'orly-launcher', label: 'orly-launcher' },
  25                  { value: 'custom', label: 'Custom' }
  26              ],
  27              required: true
  28          },
  29          relay: {
  30              label: 'Relay',
  31              options: [
  32                  { value: 'orly', label: 'orly' },
  33                  { value: 'custom', label: 'Custom' }
  34              ],
  35              required: true
  36          },
  37          database: {
  38              label: 'Database',
  39              options: [
  40                  { value: 'orly-db-badger', label: 'Badger' },
  41                  { value: 'orly-db-neo4j', label: 'Neo4j' },
  42                  { value: 'custom', label: 'Custom' }
  43              ],
  44              required: true
  45          },
  46          acl: {
  47              label: 'ACL',
  48              options: [
  49                  { value: 'none', label: 'None (disabled)' },
  50                  { value: 'orly-acl-follows', label: 'Follows' },
  51                  { value: 'orly-acl-managed', label: 'Managed' },
  52                  { value: 'orly-acl-curation', label: 'Curation' },
  53                  { value: 'custom', label: 'Custom' }
  54              ],
  55              required: false
  56          },
  57          sync: {
  58              label: 'Sync',
  59              options: [
  60                  { value: 'none', label: 'None (disabled)' },
  61                  { value: 'orly-sync-negentropy', label: 'Negentropy' },
  62                  { value: 'custom', label: 'Custom' }
  63              ],
  64              required: false
  65          }
  66      };
  67  
  68      // Current selections for each category
  69      let categories = {
  70          launcher: { selected: 'orly-launcher', customUrl: '', url: '', installing: false, installed: false },
  71          relay: { selected: 'orly', customUrl: '', url: '', installing: false, installed: false },
  72          database: { selected: 'orly-db-badger', customUrl: '', url: '', installing: false, installed: false },
  73          acl: { selected: 'none', customUrl: '', url: '', installing: false, installed: false },
  74          sync: { selected: 'none', customUrl: '', url: '', installing: false, installed: false }
  75      };
  76  
  77      onMount(async () => {
  78          await loadBinaries();
  79          await loadAvailableReleases();
  80      });
  81  
  82      async function loadAvailableReleases() {
  83          loadingReleases = true;
  84          try {
  85              const result = await fetchReleases($userSigner, $userPubkey);
  86              if (result.releases) {
  87                  availableReleases = result.releases.map(r => ({
  88                      tag: r.tag,
  89                      message: r.message || ''
  90                  }));
  91              }
  92          } catch (e) {
  93              console.error('Failed to fetch releases:', e);
  94          } finally {
  95              loadingReleases = false;
  96          }
  97      }
  98  
  99      function handleReleaseSelect() {
 100          if (!selectedRelease) return;
 101          version = selectedRelease;
 102          releaseBaseUrl = `${RELEASES_BASE}/${selectedRelease}`;
 103          updateUrls();
 104      }
 105  
 106      async function loadBinaries() {
 107          $isLoading = true;
 108          try {
 109              $binariesData = await fetchBinaries($userSigner, $userPubkey);
 110              $error = '';
 111          } catch (e) {
 112              $error = e.message;
 113          } finally {
 114              $isLoading = false;
 115          }
 116      }
 117  
 118      function generateUrl(binaryName) {
 119          if (!releaseBaseUrl || !version) return '';
 120          const verNum = version.replace(/^v/, '');
 121          return `${releaseBaseUrl}/${binaryName}-${verNum}-linux-${architecture}`;
 122      }
 123  
 124      function updateUrls() {
 125          for (const key of Object.keys(categories)) {
 126              const cat = categories[key];
 127              if (cat.selected !== 'none' && cat.selected !== 'custom') {
 128                  cat.url = generateUrl(cat.selected);
 129              } else if (cat.selected === 'custom') {
 130                  cat.url = cat.customUrl;
 131              } else {
 132                  cat.url = '';
 133              }
 134          }
 135          categories = categories;
 136      }
 137  
 138      function handleSelectionChange(categoryKey) {
 139          updateUrls();
 140      }
 141  
 142      function setReleaseUrl() {
 143          let inputUrl = prompt('Enter release URL (e.g., https://git.mleku.dev/mleku/next.orly.dev/releases/tag/v0.56.1):');
 144          if (!inputUrl) return;
 145  
 146          // Normalize the URL
 147          let cleanBase = inputUrl.replace(/\/$/, '');
 148          if (cleanBase.includes('/releases/tag/')) {
 149              cleanBase = cleanBase.replace('/releases/tag/', '/releases/download/');
 150          } else if (!cleanBase.includes('/releases/download/')) {
 151              const ver = version.trim() || 'v0.56.1';
 152              cleanBase = cleanBase + '/releases/download/' + ver;
 153          }
 154  
 155          // Extract version from URL
 156          const urlParts = cleanBase.split('/');
 157          const ver = urlParts[urlParts.length - 1];
 158  
 159          releaseBaseUrl = cleanBase;
 160          if (!version) {
 161              version = ver;
 162          }
 163  
 164          updateUrls();
 165      }
 166  
 167      function getBinaryName(categoryKey) {
 168          const cat = categories[categoryKey];
 169          if (cat.selected === 'custom') {
 170              // Try to extract binary name from URL
 171              const urlParts = cat.customUrl.split('/');
 172              const filename = urlParts[urlParts.length - 1];
 173              // Remove version suffix like -0.56.1-linux-amd64
 174              return filename.replace(/-[\d.]+-linux-(amd64|arm64)$/, '') || categoryKey;
 175          }
 176          return cat.selected;
 177      }
 178  
 179      function getEffectiveUrl(categoryKey) {
 180          const cat = categories[categoryKey];
 181          if (cat.selected === 'custom') {
 182              return cat.customUrl;
 183          }
 184          return cat.url;
 185      }
 186  
 187      async function installCategory(categoryKey) {
 188          const cat = categories[categoryKey];
 189          const url = getEffectiveUrl(categoryKey);
 190  
 191          if (!url.trim()) {
 192              $error = `URL is required for ${categoryDefs[categoryKey].label}`;
 193              return;
 194          }
 195  
 196          if (!version.trim()) {
 197              $error = 'Version is required';
 198              return;
 199          }
 200  
 201          cat.installing = true;
 202          categories = categories;
 203          $error = '';
 204  
 205          try {
 206              const binaryName = getBinaryName(categoryKey);
 207              const urls = { [binaryName]: url.trim() };
 208              const result = await updateBinaries($userSigner, $userPubkey, version.trim(), urls);
 209  
 210              if (result.success) {
 211                  cat.installed = true;
 212  
 213                  if (categoryKey === 'launcher') {
 214                      launcherUpdated = true;
 215                      updateResult = {
 216                          success: true,
 217                          message: `Downloaded ${binaryName}. Click 'Restart Launcher' to apply.`,
 218                          downloaded_files: result.downloaded_files
 219                      };
 220                  } else {
 221                      updateResult = {
 222                          success: true,
 223                          message: `Downloaded ${binaryName}, restarting service...`,
 224                          downloaded_files: result.downloaded_files
 225                      };
 226  
 227                      try {
 228                          await restartService($userSigner, $userPubkey, binaryName);
 229                          updateResult = {
 230                              success: true,
 231                              message: `${binaryName} installed and restart initiated`,
 232                              downloaded_files: result.downloaded_files
 233                          };
 234                      } catch (restartErr) {
 235                          updateResult = {
 236                              success: true,
 237                              message: `Downloaded ${binaryName}, but restart failed: ${restartErr.message}`,
 238                              downloaded_files: result.downloaded_files
 239                          };
 240                      }
 241                  }
 242  
 243                  await loadBinaries();
 244              }
 245          } catch (e) {
 246              $error = `Failed to install ${categoryDefs[categoryKey].label}: ${e.message}`;
 247          } finally {
 248              cat.installing = false;
 249              categories = categories;
 250          }
 251      }
 252  
 253      async function handleInstallAll() {
 254          const urls = {};
 255          let hasLauncher = false;
 256  
 257          for (const key of Object.keys(categories)) {
 258              const cat = categories[key];
 259              if (cat.selected !== 'none') {
 260                  const url = getEffectiveUrl(key);
 261                  if (url.trim()) {
 262                      const binaryName = getBinaryName(key);
 263                      urls[binaryName] = url.trim();
 264                      if (key === 'launcher') hasLauncher = true;
 265                  }
 266              }
 267          }
 268  
 269          if (!version.trim()) {
 270              $error = 'Version is required';
 271              return;
 272          }
 273  
 274          if (Object.keys(urls).length === 0) {
 275              $error = 'No binaries selected for installation';
 276              return;
 277          }
 278  
 279          isUpdating = true;
 280          updateResult = null;
 281          launcherUpdated = false;
 282          $error = '';
 283  
 284          try {
 285              updateResult = await updateBinaries($userSigner, $userPubkey, version.trim(), urls);
 286              await loadBinaries();
 287  
 288              if (hasLauncher && updateResult.success) {
 289                  launcherUpdated = true;
 290              }
 291          } catch (e) {
 292              $error = e.message;
 293          } finally {
 294              isUpdating = false;
 295          }
 296      }
 297  
 298      async function handleRollback() {
 299          if (!confirm('Are you sure you want to rollback to the previous version?')) {
 300              return;
 301          }
 302  
 303          isUpdating = true;
 304          $error = '';
 305  
 306          try {
 307              const result = await rollbackVersion($userSigner, $userPubkey);
 308              updateResult = {
 309                  success: true,
 310                  message: `Rolled back from ${result.previous_version} to ${result.current_version}. Restart services to apply.`
 311              };
 312              await loadBinaries();
 313          } catch (e) {
 314              $error = e.message;
 315          } finally {
 316              isUpdating = false;
 317          }
 318      }
 319  
 320      async function handleRestartLauncher() {
 321          if (!confirm('Restart the launcher? This will briefly disconnect you.')) {
 322              return;
 323          }
 324          try {
 325              await restartServices($userSigner, $userPubkey);
 326              updateResult = {
 327                  success: true,
 328                  message: 'Launcher restart initiated. The page will reconnect automatically...'
 329              };
 330              setTimeout(() => {
 331                  window.location.reload();
 332              }, 5000);
 333          } catch (e) {
 334              $error = e.message;
 335          }
 336      }
 337  
 338      // React to architecture or version changes
 339      $: if (architecture || version) {
 340          updateUrls();
 341      }
 342  </script>
 343  
 344  <div class="update-page">
 345      <div class="page-header">
 346          <h2>Update Binaries</h2>
 347      </div>
 348  
 349      {#if $error}
 350          <div class="error-banner">{$error}</div>
 351      {/if}
 352  
 353      {#if updateResult?.success}
 354          <div class="success-banner">
 355              {updateResult.message}
 356              {#if updateResult.downloaded_files?.length}
 357                  <br>Downloaded: {updateResult.downloaded_files.join(', ')}
 358              {/if}
 359              {#if launcherUpdated}
 360                  <div class="launcher-restart">
 361                      <strong>Launcher was updated!</strong>
 362                      <button class="restart-launcher-btn" on:click={handleRestartLauncher}>
 363                          Restart Launcher Now
 364                      </button>
 365                  </div>
 366              {/if}
 367          </div>
 368      {/if}
 369  
 370      <div class="current-version">
 371          <h3>Current Version</h3>
 372          <div class="version-info">
 373              <span class="version">{$binariesData?.current_version || 'unknown'}</span>
 374              <button
 375                  class="rollback-btn"
 376                  on:click={handleRollback}
 377                  disabled={isUpdating || ($binariesData?.available_versions?.length || 0) < 2}
 378              >
 379                  Rollback
 380              </button>
 381          </div>
 382      </div>
 383  
 384      <div class="update-form">
 385          <h3>Install New Version</h3>
 386  
 387          <div class="release-settings">
 388              <div class="form-row">
 389                  <div class="form-group">
 390                      <label for="release-select">Official Release</label>
 391                      <select
 392                          id="release-select"
 393                          bind:value={selectedRelease}
 394                          on:change={handleReleaseSelect}
 395                          disabled={isUpdating || loadingReleases}
 396                      >
 397                          <option value="">
 398                              {loadingReleases ? 'Loading...' : '-- Select release --'}
 399                          </option>
 400                          {#each availableReleases as release}
 401                              <option value={release.tag}>
 402                                  {release.tag}{release.message ? ` - ${release.message.slice(0, 40)}` : ''}
 403                              </option>
 404                          {/each}
 405                      </select>
 406                  </div>
 407                  <div class="form-group">
 408                      <label for="arch">Architecture</label>
 409                      <select id="arch" bind:value={architecture} disabled={isUpdating}>
 410                          <option value="amd64">AMD64 (x86_64)</option>
 411                          <option value="arm64">ARM64 (aarch64)</option>
 412                      </select>
 413                  </div>
 414              </div>
 415  
 416              <div class="form-row custom-release-row">
 417                  <div class="form-group">
 418                      <label for="version">Or Custom Version</label>
 419                      <input
 420                          type="text"
 421                          id="version"
 422                          bind:value={version}
 423                          placeholder="v0.56.1"
 424                          disabled={isUpdating}
 425                      />
 426                  </div>
 427                  <div class="form-group">
 428                      <label>&nbsp;</label>
 429                      <button class="helper-btn fill-btn" on:click={setReleaseUrl} disabled={isUpdating}>
 430                          Set Custom URL
 431                      </button>
 432                  </div>
 433              </div>
 434  
 435              {#if releaseBaseUrl}
 436                  <div class="release-url-display">
 437                      <span class="release-label">Release:</span>
 438                      <code>{releaseBaseUrl}</code>
 439                  </div>
 440              {/if}
 441          </div>
 442  
 443          <div class="categories">
 444              {#each Object.entries(categoryDefs) as [key, def]}
 445                  <div class="category-row">
 446                      <div class="category-header">
 447                          <span class="category-label">{def.label}</span>
 448                          {#if !def.required}
 449                              <span class="optional-badge">optional</span>
 450                          {/if}
 451                      </div>
 452                      <div class="category-controls">
 453                          <select
 454                              bind:value={categories[key].selected}
 455                              on:change={() => handleSelectionChange(key)}
 456                              disabled={isUpdating || categories[key].installing}
 457                          >
 458                              {#each def.options as opt}
 459                                  <option value={opt.value}>{opt.label}</option>
 460                              {/each}
 461                          </select>
 462  
 463                          {#if categories[key].selected === 'custom'}
 464                              <input
 465                                  type="text"
 466                                  class="custom-url"
 467                                  bind:value={categories[key].customUrl}
 468                                  on:input={() => { categories[key].url = categories[key].customUrl; }}
 469                                  placeholder="https://... (custom binary URL)"
 470                                  disabled={isUpdating || categories[key].installing}
 471                              />
 472                          {:else if categories[key].selected !== 'none'}
 473                              <input
 474                                  type="text"
 475                                  class="url-display"
 476                                  value={categories[key].url}
 477                                  readonly
 478                                  placeholder="Set release URL above"
 479                              />
 480                          {/if}
 481  
 482                          {#if categories[key].selected !== 'none'}
 483                              <button
 484                                  class="install-btn"
 485                                  on:click={() => installCategory(key)}
 486                                  disabled={isUpdating || categories[key].installing || !getEffectiveUrl(key)}
 487                                  title="Download and install this component"
 488                              >
 489                                  {#if categories[key].installing}
 490                                      ...
 491                                  {:else if categories[key].installed}
 492                                      Done
 493                                  {:else}
 494                                      Install
 495                                  {/if}
 496                              </button>
 497                          {/if}
 498                      </div>
 499                  </div>
 500              {/each}
 501          </div>
 502  
 503          <button
 504              class="update-btn"
 505              on:click={handleInstallAll}
 506              disabled={isUpdating}
 507          >
 508              {isUpdating ? 'Installing...' : 'Install All Selected'}
 509          </button>
 510      </div>
 511  
 512      {#if $binariesData?.available_versions?.length}
 513          <div class="versions-list">
 514              <h3>Installed Versions</h3>
 515              <table>
 516                  <thead>
 517                      <tr>
 518                          <th>Version</th>
 519                          <th>Installed</th>
 520                          <th>Binaries</th>
 521                          <th>Status</th>
 522                      </tr>
 523                  </thead>
 524                  <tbody>
 525                      {#each $binariesData.available_versions as ver}
 526                          <tr class:current={ver.is_current}>
 527                              <td class="version-cell">{ver.version}</td>
 528                              <td>{new Date(ver.installed_at).toLocaleString()}</td>
 529                              <td>{ver.binaries?.length || 0} files</td>
 530                              <td>
 531                                  {#if ver.is_current}
 532                                      <span class="current-badge">Current</span>
 533                                  {/if}
 534                              </td>
 535                          </tr>
 536                      {/each}
 537                  </tbody>
 538              </table>
 539          </div>
 540      {/if}
 541  </div>
 542  
 543  <style>
 544      .update-page {
 545          padding: 20px 0;
 546      }
 547  
 548      .page-header {
 549          margin-bottom: 24px;
 550      }
 551  
 552      .page-header h2 {
 553          font-size: 1.5rem;
 554          color: var(--text-color);
 555      }
 556  
 557      .error-banner {
 558          background: #ffebee;
 559          color: #c62828;
 560          padding: 12px 16px;
 561          border-radius: 6px;
 562          margin-bottom: 20px;
 563          border: 1px solid #ffcdd2;
 564      }
 565  
 566      .success-banner {
 567          background: #e8f5e9;
 568          color: #2e7d32;
 569          padding: 12px 16px;
 570          border-radius: 6px;
 571          margin-bottom: 20px;
 572          border: 1px solid #c8e6c9;
 573      }
 574  
 575      .launcher-restart {
 576          margin-top: 12px;
 577          padding-top: 12px;
 578          border-top: 1px solid #c8e6c9;
 579          display: flex;
 580          align-items: center;
 581          gap: 12px;
 582      }
 583  
 584      .restart-launcher-btn {
 585          padding: 8px 16px;
 586          background: #1976d2;
 587          border: none;
 588          color: white;
 589          border-radius: 4px;
 590          cursor: pointer;
 591          font-weight: 500;
 592      }
 593  
 594      .restart-launcher-btn:hover {
 595          background: #1565c0;
 596      }
 597  
 598      .current-version,
 599      .update-form,
 600      .versions-list {
 601          background: var(--card-bg);
 602          border: 1px solid var(--border-color);
 603          border-radius: 8px;
 604          padding: 20px;
 605          margin-bottom: 24px;
 606      }
 607  
 608      h3 {
 609          font-size: 1.1rem;
 610          color: var(--text-color);
 611          margin-bottom: 16px;
 612      }
 613  
 614      .version-info {
 615          display: flex;
 616          align-items: center;
 617          justify-content: space-between;
 618      }
 619  
 620      .version {
 621          font-size: 1.5rem;
 622          font-weight: 600;
 623          font-family: monospace;
 624          color: var(--text-color);
 625      }
 626  
 627      .rollback-btn {
 628          padding: 8px 16px;
 629          background: var(--warning);
 630          border: none;
 631          color: white;
 632          border-radius: 4px;
 633          cursor: pointer;
 634      }
 635  
 636      .rollback-btn:hover:not(:disabled) {
 637          opacity: 0.9;
 638      }
 639  
 640      .rollback-btn:disabled {
 641          opacity: 0.5;
 642          cursor: not-allowed;
 643      }
 644  
 645      .release-settings {
 646          margin-bottom: 24px;
 647          padding-bottom: 20px;
 648          border-bottom: 1px solid var(--border-color);
 649      }
 650  
 651      .form-row {
 652          display: flex;
 653          gap: 16px;
 654          align-items: flex-end;
 655      }
 656  
 657      .form-group {
 658          flex: 1;
 659      }
 660  
 661      .form-group label {
 662          display: block;
 663          font-size: 0.85rem;
 664          color: var(--text-color);
 665          margin-bottom: 6px;
 666          font-weight: 500;
 667      }
 668  
 669      .form-group input[type="text"],
 670      .form-group select {
 671          width: 100%;
 672          padding: 8px 12px;
 673          border: 1px solid var(--border-color);
 674          border-radius: 4px;
 675          font-size: 0.9rem;
 676          background: var(--bg-color);
 677          color: var(--text-color);
 678      }
 679  
 680      .form-group input:focus,
 681      .form-group select:focus {
 682          outline: none;
 683          border-color: var(--primary);
 684      }
 685  
 686      .helper-btn {
 687          padding: 8px 16px;
 688          font-size: 0.85rem;
 689          background: var(--primary);
 690          border: none;
 691          border-radius: 4px;
 692          color: white;
 693          cursor: pointer;
 694          white-space: nowrap;
 695      }
 696  
 697      .helper-btn:hover:not(:disabled) {
 698          opacity: 0.9;
 699      }
 700  
 701      .helper-btn:disabled {
 702          opacity: 0.5;
 703          cursor: not-allowed;
 704      }
 705  
 706      .fill-btn {
 707          width: 100%;
 708      }
 709  
 710      .custom-release-row {
 711          margin-top: 12px;
 712          padding-top: 12px;
 713          border-top: 1px dashed var(--border-color);
 714      }
 715  
 716      .release-url-display {
 717          margin-top: 12px;
 718          padding: 8px 12px;
 719          background: var(--bg-color);
 720          border-radius: 4px;
 721          font-size: 0.8rem;
 722          display: flex;
 723          align-items: center;
 724          gap: 8px;
 725      }
 726  
 727      .release-label {
 728          color: var(--muted-color);
 729      }
 730  
 731      .release-url-display code {
 732          color: var(--text-color);
 733          word-break: break-all;
 734      }
 735  
 736      .categories {
 737          display: flex;
 738          flex-direction: column;
 739          gap: 12px;
 740          margin-bottom: 20px;
 741      }
 742  
 743      .category-row {
 744          padding: 12px;
 745          background: var(--bg-color);
 746          border-radius: 6px;
 747          border: 1px solid var(--border-color);
 748      }
 749  
 750      .category-header {
 751          display: flex;
 752          align-items: center;
 753          gap: 8px;
 754          margin-bottom: 8px;
 755      }
 756  
 757      .category-label {
 758          font-weight: 600;
 759          color: var(--text-color);
 760          font-size: 0.95rem;
 761      }
 762  
 763      .optional-badge {
 764          font-size: 0.7rem;
 765          color: var(--muted-color);
 766          background: var(--border-color);
 767          padding: 2px 6px;
 768          border-radius: 3px;
 769      }
 770  
 771      .category-controls {
 772          display: flex;
 773          gap: 8px;
 774          align-items: center;
 775      }
 776  
 777      .category-controls select {
 778          min-width: 140px;
 779          padding: 6px 10px;
 780          border: 1px solid var(--border-color);
 781          border-radius: 4px;
 782          font-size: 0.85rem;
 783          background: var(--card-bg);
 784          color: var(--text-color);
 785      }
 786  
 787      .category-controls .custom-url,
 788      .category-controls .url-display {
 789          flex: 1;
 790          padding: 6px 10px;
 791          border: 1px solid var(--border-color);
 792          border-radius: 4px;
 793          font-size: 0.8rem;
 794          font-family: monospace;
 795          background: var(--card-bg);
 796          color: var(--text-color);
 797      }
 798  
 799      .category-controls .url-display {
 800          background: var(--bg-color);
 801          color: var(--muted-color);
 802      }
 803  
 804      .install-btn {
 805          padding: 6px 14px;
 806          background: var(--primary);
 807          border: none;
 808          color: white;
 809          border-radius: 4px;
 810          cursor: pointer;
 811          font-size: 0.8rem;
 812          min-width: 70px;
 813      }
 814  
 815      .install-btn:hover:not(:disabled) {
 816          opacity: 0.9;
 817      }
 818  
 819      .install-btn:disabled {
 820          opacity: 0.5;
 821          cursor: not-allowed;
 822      }
 823  
 824      .update-btn {
 825          width: 100%;
 826          padding: 12px;
 827          background: var(--primary);
 828          border: none;
 829          color: white;
 830          border-radius: 6px;
 831          font-size: 1rem;
 832          cursor: pointer;
 833      }
 834  
 835      .update-btn:hover:not(:disabled) {
 836          background: var(--primary-hover);
 837      }
 838  
 839      .update-btn:disabled {
 840          opacity: 0.5;
 841          cursor: not-allowed;
 842      }
 843  
 844      table {
 845          width: 100%;
 846          border-collapse: collapse;
 847      }
 848  
 849      th, td {
 850          padding: 10px 12px;
 851          text-align: left;
 852          border-bottom: 1px solid var(--border-color);
 853      }
 854  
 855      th {
 856          font-size: 0.85rem;
 857          color: var(--muted-color);
 858          font-weight: 500;
 859      }
 860  
 861      td {
 862          font-size: 0.9rem;
 863          color: var(--text-color);
 864      }
 865  
 866      .version-cell {
 867          font-family: monospace;
 868      }
 869  
 870      tr.current {
 871          background: rgba(0, 188, 212, 0.1);
 872      }
 873  
 874      .current-badge {
 875          background: var(--primary);
 876          color: white;
 877          padding: 2px 8px;
 878          border-radius: 4px;
 879          font-size: 0.75rem;
 880      }
 881  
 882      @media (max-width: 768px) {
 883          .form-row {
 884              flex-direction: column;
 885              gap: 12px;
 886          }
 887  
 888          .category-controls {
 889              flex-wrap: wrap;
 890          }
 891  
 892          .category-controls select {
 893              min-width: 100%;
 894          }
 895  
 896          .category-controls .custom-url,
 897          .category-controls .url-display {
 898              min-width: 100%;
 899          }
 900      }
 901  </style>
 902