visar.log
Technical notes from building things
← all posts

Converting the Admin Portal to a SvelteKit 5 PWA

The Problem

The admin portal at admin.veron3.space was a single 632-line index.html file. No version control history — just raw rsync to the server. Adding a new service meant copy-pasting a card block, getting the HTML entities right, and remembering which section it belonged to. With 26 services across four sections (Live, Infrastructure, In Development, Ideas), this was becoming tedious.

What We Wanted

  • Git history — track changes, revert mistakes
  • Data-driven cards — add a service = add an object to an array, no template changes
  • PWA installability — add to home screen on mobile
  • Deploy pipeline — same watcher/deploy system as every other project

Approach

SvelteKit 5 static SPA using the same patterns as comix-frontend:

  • adapter-static with fallback: 'index.html' for SPA routing
  • ssr = false — client-side only
  • Svelte 5 runes ($props, $derived)
  • No CSS framework — pure CSS matching the existing design tokens exactly

The Data Layer

Single source of truth in src/lib/data/services.ts:

export interface Service {
  name: string;
  code: string;
  icon: string;
  description: string;
  url: string;
  domain: string;
  status: 'live' | 'dev' | 'idea';
  section: 'live' | 'infra' | 'dev' | 'ideas';
}

export const services: Service[] = [
  {
    name: 'Life Document Organizer',
    code: 'LIFEDOCUME',
    icon: '\u{1F4C4}',
    // ...
  },
  // 25 more entries
];

Adding a new project to the portal is now a 6-line object addition. The page component groups by section automatically:

{#each sections as { key, label }}
  {@const sectionServices = services.filter(s => s.section === key)}
  {#if sectionServices.length > 0}
    <div class="section">
      <div class="section-label">{label}</div>
      <div class="grid">
        {#each sectionServices as service (service.code)}
          <Card {service} />
        {/each}
      </div>
    </div>
  {/if}
{/each}

The Mouse Hover Effect

The original had a radial gradient that followed the cursor using CSS custom properties. Ported directly to Svelte with an onmousemove handler:

function onMouseMove(e: MouseEvent) {
  const card = e.currentTarget as HTMLElement;
  const r = card.getBoundingClientRect();
  card.style.setProperty('--x', (e.clientX - r.left) + 'px');
  card.style.setProperty('--y', (e.clientY - r.top) + 'px');
}

The CSS ::before pseudo-element uses these variables for a subtle indigo glow that tracks the mouse position.

PWA Setup

Minimal setup for iOS 18 installability, following the comix-frontend pattern:

  • manifest.json with standalone display mode and dark theme
  • sw.js — fetch-through service worker (just enough for the browser to recognize it as installable)
  • Service worker registration in +layout.svelte via onMount
  • Generated “v3” icons (192px + 512px) using Pillow

Deploy Pipeline Integration

The admin portal lives inside the hetzner-infra monorepo, not in its own repo. This required slightly different deploy config — the build commands cd into the subdirectory:

[admin]="hetzner-infra"                       # REPO_DIRS
[admin]="cd projects-portal && npm ci"        # INSTALL_CMDS
[admin]="cd projects-portal && npm run build" # BUILD_CMDS
[admin]="projects-portal/build"               # BUILD_OUTPUTS

The watcher polls every 60s and auto-deploys when origin/main changes. First deploy was triggered manually since the watcher hadn’t cycled yet.

Result

  • 632-line monolith → 14 source files with clear separation
  • Adding a service: ~30 lines of HTML → 6 lines of TypeScript
  • Full build in under 5 seconds
  • PWA-installable on iOS and Android
  • Auto-deploys through the same pipeline as 12 other projects

Files Created

projects-portal/
├── package.json
├── svelte.config.js
├── vite.config.ts
├── tsconfig.json
├── src/
│   ├── app.html
│   ├── app.d.ts
│   ├── lib/
│   │   ├── styles.css
│   │   ├── data/services.ts
│   │   └── components/Card.svelte
│   └── routes/
│       ├── +layout.svelte
│       ├── +layout.ts
│       └── +page.svelte
└── static/
    ├── manifest.json
    ├── sw.js
    ├── favicon.ico
    ├── icon-192.png
    └── icon-512.png