Rift Thumbnail Bild

RIFT

Februar 2025

RIFT ist ein Dateitransfer-Tool – in derselben Kategorie wie WeTransfer oder Dropbox. Es ist jedoch keine weitere saubere, vergessliche Drag-and-Drop-Box. Jede Demo unten ist die Live-Komponente, direkt aus RIFT's eigenem Storybook, keine Bildschirmaufnahme.

RIFT landing page with the halftone shader backdrop and the RIFT-Kanal upload card

Was es ist

Sie öffnen einen „RIFT“ – die Beschriftung auf der Erstellungskarte lautet „RIFT KANAL / Neuen RIFT öffnen.“ Dateien ablegen, Link erhalten. Die kostenlose Stufe begrenzt einen Transfer standardmäßig auf 2 GB mit einer Gültigkeit von 3 Tagen; kostenpflichtige Stufen erhöhen beides. Jede Datei in einem Transfer wird in der Benutzeroberfläche als „Shard“ gezählt („2 Shards · 1 GB“) – das ist der einzige Gaming-Begriff, der es bis ins ausgelieferte Produkt geschafft hat.

Große Uploads werden serverseitig gegen Supabase-Speicher zerlegt und wieder zusammengesetzt, wobei Supabase auch die Authentifizierung und die Transferdatenbank verwaltet. Next.js 16 auf Turbopack betreibt die App, Base UI stellt die ungestylten Primitiven bereit, Tailwind übernimmt das Styling, Framer Motion steuert die Übergänge.

Halbton-Hintergrund und Bild-Dithering

Der dithering-ähnliche Hintergrund ist kein CSS-Gradienten-Trick – es ist ein GPU-Shader. RiftHalftoneBackdrop rendert einen HalftoneDots-Shader von @paper-design/shaders-react über einem Hintergrundbild und überwacht prefers-color-scheme, sodass er sich im Dark Mode anpasst. Dieselbe Bibliothek ermöglicht das Bild-Dithering an anderer Stelle in der Benutzeroberfläche.

rift
Halftone shader backdrop
GPU shader (HalftoneDots from @paper-design/shaders-react) over a background image, dark-mode aware.
rift
Image dithering shader
Same shader library, applied to images instead of the backdrop — black/white dither variant.

Beide Verwendungen existieren aus demselben Grund: @paper-design/shaders-react initialisiert seinen Canvas standardmäßig auf fest codierte 300×150 Pixel. Jeder Wrapper fügt einen ResizeObserver hinzu, um den Canvas beim Mounten und bei Größenänderungen auf seine tatsächliche Containergröße zu korrigieren.

typescript
// Recurring fix, used by both RiftHalftoneBackdrop and ImageDitheringWrapper
useEffect(() => {
  const container = containerRef.current
  if (!container) return

  const resizeObserver = new ResizeObserver((entries) => {
    const canvas = container.querySelector('canvas')
    if (!canvas) return
    const { width, height } = entries[0].contentRect
    canvas.width = width
    canvas.height = height
  })
  resizeObserver.observe(container)
  return () => resizeObserver.disconnect()
}, [])

Upload: drei Schritte, ein PIN-ähnliches Passwort

UploadCard verwendet eine einfache dreistufige Zustandsmaschine – Upload, Konfiguration, Hochladen – anstelle einer Wizard-Bibliothek. Der Konfigurationsschritt legt die Gültigkeitsdauer fest (standardmäßig 3 Tage für kostenlose Konten), eine optionale Download-Begrenzung und ein optionales 6-stelliges numerisches Passwort, das wie eine Telefon-PIN eingegeben wird: ein Feld pro Ziffer, mit automatisch vorrückendem Fokus.

rift
Upload — configuration step
Set expiration (3-day free default), an optional download cap, and the 6-digit PIN-style password.
rift
Upload — in progress
Chunked upload in flight, reassembled server-side against Supabase storage.
typescript
type Step = "upload" | "config" | "uploading"

const [step, setStep] = useState<Step>("upload")
const [expirationDays, setExpirationDays] = useState(3) // free-tier default
const [passwordChars, setPasswordChars] = useState<string[]>(Array(6).fill(""))
const [enablePassword, setEnablePassword] = useState(false)

const activeStep: Step = isUploading ? "uploading" : step

Was der Empfänger sieht

DownloadPage schützt den Zugriff mit demselben 6-stelligen Code, wenn ein Transfer passwortgeschützt ist. Bevor der Code eingegeben wird, wird die E-Mail des Absenders durch EmailScramble geleitet – ein deterministischer Hash verschlüsselt sie in Platzhalterzeichen und entschlüsselt sie dann Zeichen für Zeichen, sobald sie enthüllt wird, anstatt die Sichtbarkeit einfach mit CSS umzuschalten.

rift
EmailScramble reveal
Sender's email stays scrambled until the recipient passes the password check, then decodes character by character.
typescript
// Deterministic hash → placeholder chars, so the scramble is
// stable per email instead of re-randomizing on every render
function makePlaceholder(seed: string, length: number): string {
  let h = 0
  for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
  let out = ""
  for (let i = 0; i < length; i++) {
    h = Math.imul(1103515245, h) + 12345
    out += CHARS[Math.abs(h >>> 0) % CHARS.length]
  }
  return out
}

// revealed flips true only after password verification succeeds
<EmailScramble text={senderEmail} revealed={!needsPassword || isVerified} />
rift
Download page — password gate
The 6-digit code entry the recipient sees for a password-protected transfer.
rift
Download page — file previews
Previewable files render inline before download; PDFs are pre-fetched once verified so preview opens instantly.
rift
Download page — expired state
What a recipient sees once a Rift's expiration window has passed.

Warnungen mit Gefahrenstreifen

Warnungen und Fehler verwenden einen Gefahrenmodus – buchstäblich diagonale Gefahrenstreifen-Hintergründe, die unter der Statusfarbe liegen, eher wie Industrieklebeband als ein typisches Toast-Fenster.

rift
Hazard alert states
Diagonal hazard-stripe backgrounds (hazard-bg-red/yellow/green/blue/purple) layered under each status color.

Der Drawer-Stapel

Ein Klick auf „Verlauf“ öffnet einen Verlaufs-Drawer. Ein Klick auf einen Transfer darin öffnet einen verschachtelten Detail-Drawer darüber. Eine Dateivorschau von dort öffnet ein Medienvorschau-Modal darüber – drei gestapelte Overlays, die echte Iteration erforderten, um sie richtig zu implementieren, ohne dass eines die Klicks der anderen verschluckt.

The Verlauf drawer listing past transfers, each counted in Shards

Die Drawer begannen mit Vaul und wurden dann zu Base UI's Dialog mit modal={true} migriert – Scroll-Sperre ist kostenlos enthalten, und Ein-/Ausblendanimationen basieren auf data-[open]/data-[closed]-Attributen anstelle einer Swipe-Gesten-Bibliothek.

bash
Sidebar                          z-40
TransferModal overlay            z-50
TransferModal content            z-[60]
TransferDetailDrawer backdrop    z-[65]
TransferDetailDrawer popup       z-[70]
Sonner toasts                    z-99998

Die Verschachtelung folgt einer Regel, deren Erlernen eine Debugging-Sitzung kostete: Der Detail-Drawer ist als echtes Kind-Dialog (keepMounted, damit Exit-Animationen abgeschlossen werden können) im Verlaufs-Drawer verschachtelt, aber das Medienvorschau-Modal wird auf die oberste Ebene gehoben – ein Geschwisterelement beider Drawer, kein Kind. Ein AlertDialog oder Modal, das in einem anderen Dialog verschachtelt ist, schließt beide beim Schließen, daher wird alles, was den Lebenszyklus seines Elternteils überdauern kann, stattdessen als Geschwisterelement gerendert.

typescript
// TransferModal owns preview state, both drawers just report into it
const [preview, setPreview] = useState<{ files: Shard[]; index: number } | null>(null)

<Dialog.Root open={verlaufOpen}>
  {/* ...transfer list... */}
  <TransferDetailDrawer
    onOpenPreview={(files, index) => setPreview({ files, index })}
  />
</Dialog.Root>

{/* sibling, not nested — survives the drawer above it closing */}
<MediaPreviewModal state={preview} onClose={() => setPreview(null)} />

Snap-Scroll und ein Ref statt eines States

Die Homepage scrollt mit Snap-Effekt zwischen den Abschnitten bei Mauseingabe. Das Öffnen eines Drawers oder Modals muss dies unterbrechen, da sonst das Scrollen des Drawer-Inhalts auch die darunterliegende Seite scrollt. Die naive Lösung ist ein State-Wert, den der Wheel-Handler bei jedem Event prüft, was bedeutet, den Listener jedes Mal neu zu registrieren, wenn ein Modal geöffnet oder geschlossen wird.

typescript
// MainLayout — a ref instead of state, so the listener registers once
const modalOpenRef = useRef(false)

useEffect(() => {
  function handleWheel(e: WheelEvent) {
    if (modalOpenRef.current) return // suspended, no re-register needed
    // ...snap-scroll logic
  }
  window.addEventListener("wheel", handleWheel, { passive: false })
  return () => window.removeEventListener("wheel", handleWheel)
}, []) // empty deps — the ref makes that safe

// opening any modal just flips the ref, no re-render required
const openModal = () => { modalOpenRef.current = true; setModalOpen(true) }

Stack

  • Next.js 16 (Turbopack)
  • Supabase – Authentifizierung, Datenbank, segmentierter Dateispeicher
  • Base UI (Dialog/AlertDialog-Primitive) + Tailwind
  • Framer Motion für Übergänge
  • @paper-design/shaders-react für den Halbton-Hintergrund und Bild-Dithering
  • Deutsch-erste Benutzeroberfläche, mit „Shard“/„RIFT“ als den verbleibenden Gaming-Begriffen