Photo Gallery Demo

A camera-enabled photo gallery showcasing native device integration. The same code runs in browsers with webcam, on mobile with native camera (Capacitor), and on desktop with system tray integration (Electron).

Table of Contents

Create the App

Try it live — no install required.

To run locally:

npx github:ruby2js/juntos --demo photo_gallery
cd photo_gallery

This creates a Rails app with:

  • Photo model — stores base64 image data with caption and timestamp
  • Camera controller — Stimulus controller written in Ruby
  • Platform detection — Automatically uses native camera on mobile
  • Tailwind CSS — styled gallery grid and capture UI

Run with Rails

The demo includes a Stimulus controller written in Ruby (app/javascript/controllers/camera_controller.rb). To transpile it automatically, install ruby2js:

bundle add ruby2js --github ruby2js/ruby2js --branch master
bin/rails generate ruby2js:install
RAILS_ENV=production bin/rails db:prepare
bin/rails server -e production

Open http://localhost:3000. Click “Take Photo” to activate your webcam. Capture photos, add captions, and see them appear in the gallery.

Run in the Browser

Stop Rails. Run the same app in your browser:

bin/juntos dev -d dexie

Open http://localhost:3000. Same gallery. Same webcam access. But now:

  • No Ruby runtime — the browser runs transpiled JavaScript
  • IndexedDB storage — photos persist in your browser via Dexie
  • Hot reload — edit a Ruby file, save, browser refreshes

Camera Permissions

The browser will request camera access when you click “Take Photo”. Grant permission to enable the webcam. Photos are captured as base64 JPEG and stored directly in IndexedDB.

Run on Node.js

bin/juntos db:prepare -d sqlite
bin/juntos up -d sqlite

Open http://localhost:3000. Same gallery—but now Node.js serves requests, and better-sqlite3 provides the database.

Build for Mobile (Capacitor)

Capacitor wraps your web app in a native shell, providing access to device APIs like the camera.

bin/juntos build -t capacitor -d dexie
cd dist
npm install
npx cap add ios      # Requires Xcode
npx cap add android  # Requires Android Studio

The Camera plugin is pre-configured in config/ruby2js.yml. When you add platforms, Capacitor automatically installs the native plugin code and configures permissions in Info.plist (iOS) and AndroidManifest.xml (Android).

Run on iOS

npx cap run ios

This opens Xcode. Select a simulator or connected device and run. When you tap “Take Photo”, the native iOS camera opens instead of the webcam.

Run on Android

npx cap run android

This opens Android Studio. Select an emulator or connected device and run. The native Android camera integrates seamlessly.

How It Works

The Stimulus controller detects Capacitor and uses the native Camera plugin:

def takePhoto
  if isCapacitor
    # Native mobile - use Capacitor Camera plugin
    takePhotoCapacitor()
  else
    # Browser - use getUserMedia
    takePhotoBrowser()
  end
end

def takePhotoCapacitor
  Camera = (await import("@capacitor/camera")).Camera
  CameraResultType = (await import("@capacitor/camera")).CameraResultType
  CameraSource = (await import("@capacitor/camera")).CameraSource

  result = await Camera.getPhoto(
    quality: 80,
    resultType: CameraResultType.Base64,
    source: CameraSource.Camera
  )
  handlePhoto(result.base64String)
rescue => error
  console.error("Camera error:", error)
  takePhotoBrowser()  # Fall back to browser camera
end

The dynamic import (await import("@capacitor/camera")) means the plugin is only loaded when needed. If the plugin isn’t installed, the rescue block falls back to the browser camera.

Build for Desktop (Electron)

Electron creates desktop apps with system tray integration and global shortcuts.

bin/juntos build -t electron -d sqlite
cd dist
npm install
npm start

Desktop Experience

The app runs as a background utility:

  • System tray icon — appears in the menu bar (macOS) or system tray (Windows)
  • No dock icon — doesn’t clutter your dock
  • Global shortcutCmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) opens capture popup from any app
  • Frameless popup — positioned below the tray icon for quick captures
  • Auto-hide — popup closes when you click outside

Package for Distribution

npm run package

This creates distributable packages:

  • macOS: dist/Photo Gallery.dmg
  • Windows: dist/Photo Gallery Setup.exe
  • Linux: dist/photo-gallery.AppImage

The Code

The camera controller is written in Ruby. Try it — see how it transpiles:

class CameraController < Stimulus::Controller
  def takePhotoBrowser
    # Start video stream from webcam
    stream = await navigator.mediaDevices.getUserMedia(video: { facingMode: "user" })
    videoTarget.srcObject = stream
    videoTarget.classList.remove("hidden")
    captureBtnTarget.classList.remove("hidden")
  end

  def capture
    # Capture frame from video to canvas
    canvas = document.createElement("canvas")
    canvas.width = videoTarget.videoWidth
    canvas.height = videoTarget.videoHeight
    canvas.getContext("2d").drawImage(videoTarget, 0, 0)

    # Stop video stream
    videoTarget.srcObject.getTracks().each { |track| track.stop() }

    # Get base64 image data
    dataUrl = canvas.toDataURL("image/jpeg", 0.8)
    base64 = dataUrl.sub("data:image/jpeg;base64,", "")
    handlePhoto(base64)
  end

  def handlePhoto(base64)
    imageDataTarget.value = base64
    previewTarget.src = "data:image/jpeg;base64,#{base64}"
    formTarget.classList.remove("hidden")
  end
end

Try it — the model is standard Rails:

class Photo < ApplicationRecord
  validates :image_data, presence: true
end

What This Demo Shows

Camera Integration

  • Browser webcamgetUserMedia() API with video preview
  • Native camera — Capacitor Camera plugin on iOS/Android
  • File picker — “Choose from Gallery” fallback

Stimulus Controller

  • Written in Ruby — transpiles to JavaScript
  • Platform detectionisCapacitor() method
  • Async/await — native camera calls use promises
  • DOM manipulation — canvas capture, class toggling

Desktop Integration (Electron)

  • System tray — menu bar icon with context menu
  • Global shortcuts — works even when app isn’t focused
  • IPC communication — main process triggers renderer actions
  • Background utility — no dock icon, minimal footprint

Data Flow

  • Base64 encoding — photos stored as text in database
  • Turbo Streams — new photos appear without page reload
  • Multi-database — same code works with IndexedDB, SQLite, D1, Neon

What Works Differently

  • Capacitor camera — returns base64 directly, no canvas needed
  • Electron popup — frameless window positioned below tray
  • Permissions — browser prompts; native apps use Info.plist/AndroidManifest

What Doesn’t Work

  • Video recording — this demo captures still photos only
  • Photo editing — no crop, rotate, or filters
  • Server sync — photos are local only (future enhancement)

Next Steps

Back to Juntos/demos