← Portfolio

Technical Documentation

Zineroo

A browser-based React print shop for making printable foldy zines, square zines, saddle stitch booklets, trifold brochures, and Unigrid folded maps from images, with auto-fill, layered photo and text placement, curved text overlays, undo/redo, end-to-end encrypted cloud projects with recoverable sync, booklet preview, imposition layout, and export-ready PDF, PNG, ZIP, and editable project output

TypeScript React Vite Canvas IndexedDB jsPDF


The Idea

Zineroo is a focused publishing tool for turning a small image set into a printable folded object. It keeps the core workflow in the browser: drop images in, choose a foldy zine, square zine, saddle stitch booklet, trifold brochure, or Unigrid map format, auto-fill the editor, add photo and text layers, reorder pages or spreads where the format supports it, tune the frame, preview the booklet or sheet, and export the files. The editor remains local-first by default. Images are kept in the browser, autosaved to IndexedDB, and exported by the user as files unless the user signs in for optional Plus cloud projects.

The interface is built around the physical object. The app has fixed page maps, paper presets, format-aware spread controls, full-sheet imposition views, and export controls for the printable artifact. The newer workspace separates the loop into Grid, Editor, and Preview modes, with a separate public homepage, project manager, account menu, and admin surface around the editor. It is not a general design canvas. Its job is to make the zine-making loop fast enough that layout decisions can stay visual and tactile. Cloud persistence follows that same principle: a project keeps working in the browser while an encrypted, resumable sync job moves it to the user's private workspace.

Make the folded sheet the source of truth.


Features

Folded formats

Image placement

Text and color layers

Layer stacking

Preview and export

Local and cloud persistence

Account and Pro workflows


Tech Stack

Framework React 18 with Vite
Language TypeScript
Rendering HTML Canvas for page previews, booklet spreads, and imposition sheets
Persistence IndexedDB local autosave, resumable sync jobs, and workspace bindings with runtime URL rehydration; optional end-to-end encrypted Supabase cloud projects, versions, and 30-day trash for Plus users
Export jsPDF for standard PDF output, Canvas blob export for PNG sheets, local CMYK PDF generation with ICC output intents, JSON archive/import, and a small in-app ZIP writer for bundled exports
Image pipeline Canvas-generated preview blobs for editing, original uploads for print export
Image input JPEG, PNG, and WebP uploads via file picker or drag-and-drop
Layout sizing ResizeObserver-driven editor frame sizing for stable page previews
Paper presets US Letter, US Legal, US Tabloid, A5, A4, A3, and Unigrid-specific 18" x 24", 21" x 35", 24" x 36", 30" x 42", and 420 x 594 mm presets
Formats Foldy Zine, Foldy Zine (Square), Saddle Stitch Booklet, Trifold Brochure, and Unigrid (NPS Map)
Typography Self-hosted Anton, Bebas Neue, Bungee, Playfair Display, EB Garamond, Space Grotesk, Courier Prime, Special Elite, and Caveat web fonts
Hosting Vercel-hosted Vite build with serverless API routes
Auth and billing Supabase Auth, Postgres, and Storage; browser-side Web Crypto vault and recovery keys; Stripe Checkout, webhooks, and access-code entitlements

Architecture

Source structure

api/             Vercel API routes for auth-protected admin, billing, webhooks, and access codes
src/
  main.tsx       React Router shell for homepage, editor, projects, and admin routes
  App.tsx        editor state, format switching, workspace modes, project history, and export orchestration
  canvas/        shared preview, booklet, imposition, and export rendering
  components/    asset tray, page grid, frame editor, inspector, previews, account menu, and export modals
  context/       auth, private cloud vault, update state, and the active cloud-project workspace/sync bridge
  dom/           drag targets and text-overlay geometry shared by DOM and canvas rendering
  domain/        project model, format definitions, spread and crop rules
  hooks/         viewport gating, update checks, and confirmation helpers
  pages/         public homepage, Projects manager, and admin console
  crypto/        browser-worker encryption format, key hierarchy, recovery keys, and authenticated cloud objects
  services/      assets, IndexedDB storage and sync journal, encrypted cloud projects, entitlements, project JSON, ZIP, PDF, ICC, and downloads
  styles/        app shell, homepage, tray, page grid, frame editor, inspector, preview, account, and export UI

Core project model

type ZineProject = {
  version?: number;
  format?: 'zine' | 'square-zine' | 'trifold' | 'bifold' | 'unigrid';
  pageCount?: number;
  unigridTitleBand?: {
    enabled: boolean;
    text: string;
  };
  title: string;
  assets: ZineAsset[];
  pages: PageSlot[];
  settings: DesignSettings;
};

type TextOverlay = {
  id: string;
  text: string;
  fontFamily: string;
  fontSize: number;
  color: string;
  opacity: number;
  backgroundColor?: string;
  backgroundOpacity: number;
  tracking: number;
  lineHeight: number;
  padding: number;
  x: number;
  y: number;
  rotation: number;
  curve: number;
  align: 'left' | 'center' | 'right';
};

type PhotoLayer = {
  id: string;
  assetId?: string;
  fit: 'cover' | 'contain' | 'spread';
  crop: { x: number; y: number; zoom: number; rotation: number };
  freeformCrop?: { x: number; y: number; zoom: number; rotation: number };
  freeform: boolean;
  margin: number;
  imageBorder?: number;
  imageStroke?: string;
  overlayColor?: string;
  overlayOpacity: number;
  overlayPhotoOnly?: boolean;
};

type ZineAsset = {
  id: string;
  name: string;
  type: string;
  blob: Blob;
  width: number;
  height: number;
  previewBlob?: Blob;
  previewWidth?: number;
  previewHeight?: number;
};

type PageSlot = {
  id: number;
  assetId?: string;
  fit: 'cover' | 'contain' | 'spread';
  crop: { x: number; y: number; zoom: number; rotation: number };
  freeformCrop?: { x: number; y: number; zoom: number; rotation: number };
  freeform: boolean;
  margin: number;
  imageBorder?: number;
  imageStroke?: string;
  overlayColor?: string;
  overlayOpacity: number;
  overlayPhotoOnly?: boolean;
  photoLayers?: PhotoLayer[];
  textOverlays?: TextOverlay[];
  layerOrder?: string[];
  spreadId?: string;
  spreadSide?: 'left' | 'center' | 'right';
  spreadPageIds?: number[];
  spreadIndex?: number;
  spreadLength?: number;
  spreadColumns?: number;
  spreadRows?: number;
  spreadColumnIndex?: number;
  spreadRowIndex?: number;
};

The data model is intentionally small. A project is a title, a format, a local asset library, page slots, optional Unigrid title-band state, and design settings. Page slots own their background image placement, additional photo layers, overlay color, text layers, and spread metadata, including the row/column geometry needed for Unigrid spreads. Runtime object URLs are stripped before autosave, project export, and cloud persistence, then rebuilt when the project is restored. Uploaded originals stay available for export while generated preview blobs keep the editor responsive.

Layer model

type SlotLayer =
  | { id: string; kind: 'photo'; placement: PhotoPlacement }
  | { id: string; kind: 'text'; overlay: TextOverlay };

function allLayersForSlot(slot: PageSlot): SlotLayer[];
function reorderLayer(slot: PageSlot, id: string, beforeId: string | null): PageSlot;

Each page slot keeps a single bottom-to-top layerOrder list of element ids spanning both its photo layers and its text overlays. allLayersForSlot resolves that order into a typed stack that canvas rendering, hit-testing, and the Layers panel all read from instead of walking photos and text separately, so dragging a layer in the panel changes the same order the page actually renders in. normalizeLayerOrder reconciles the stored order against each slot's current layer ids on every edit, so projects saved before layer ordering existed, and pages missing a stored order, still resolve to a sensible default stack.

Preview asset pipeline

const PREVIEW_MAX_LONG_EDGE = 1200;

function imageForAsset(asset: RuntimeAsset, source: 'preview' | 'original' = 'preview') {
  const url = source === 'preview' ? asset.previewUrl ?? asset.url : asset.url;
  return loadImageFromUrl(url, `Could not load ${asset.name}`);
}

The editor renders from preview-sized blobs when possible, but PDF and PNG export request the original image source. That keeps drag, crop, and preview interactions light without lowering print output quality.

Format-aware sheets

const IMPOSITION_PAGES = [7, 6, 5, 4, 8, 1, 2, 3];
const TRIFOLD_OUTSIDE_PAGES = [2, 6, 1];
const TRIFOLD_INSIDE_PAGES = [3, 4, 5];
const UNIGRID_COLUMNS = 6;
const UNIGRID_ROWS = 2;
const UNIGRID_SIDE_PAGE_COUNT = 12;

The full-sheet preview and PDF export use the same format definitions, so the visual preview matches the file that is saved. Foldable zines render one imposed sheet with the top row rotated for folding. Trifolds render separate outside and inside sides for double-sided printing. Bifold booklets render the required imposed sheet sequence so longer booklets can be exported as a set of printable PDF sheets. Unigrid projects render two large-format sides with explicit 6-by-2 cell geometry and format-specific paper choices.

Reorder model

type ReorderDropPosition = 'before' | 'after' | 'on';
type ReorderUnit = {
  kind: 'single' | 'spread';
  length: number;
  startIndex: number;
};

Reordering treats a spread as one unit, whether it spans two pages or a full three-panel trifold side. Boundary drops move content before or after another slot, while direct drops swap compatible blocks. Spread metadata is normalized after each move so paired and grouped pages stay together. Unigrid uses side-aware spread controls instead of free reordering, because its cells are tied to a fixed folded-map grid.

History model

The editor keeps an undo/redo stack around complete project snapshots, with grouping for continuous text and control edits so sliders and typing do not flood history. The frame editor keeps temporary draft state while open, then commits the finished overlay state back into the project history when it closes.

Export flow

async function renderPdfBlob() {
  const paper = sheetSize(project.settings);
  const pdf = new jsPDF({
    orientation: 'landscape',
    unit: 'pt',
    format: [paper.width, paper.height],
  });

  for (const [index, side] of sheetSidesForFormat(projectFormat(project)).entries()) {
    if (index > 0) pdf.addPage([paper.width, paper.height], 'landscape');
    const sheet = await renderImpositionCanvas(project, Math.round(paper.width * 2), {
      showGuides: false,
      imageSource: 'original',
      sideId: side.id,
    });
    pdf.addImage(sheet.toDataURL('image/jpeg', 0.95), 'JPEG', 0, 0, paper.width, paper.height);
  }

  return pdf.output('blob');
}

Export is canvas-first. The app renders the printable side or sides, then routes that same rendering to PDF, PNG, or ZIP. Standard PDFs prioritize quick sharing and home printing. Print shop PDFs render at 300 PPI, can add bleed and crop marks, and can flatten through a local CMYK conversion path when the user supplies a printer profile. Project export is separate but can be bundled with the rendered artifact: it serializes the project state and embeds original image blobs as data URLs so the work can be moved between browsers without carrying runtime preview blobs or object URLs. Plus cloud save uses a separately encrypted manifest and authenticated objects for originals, previews, and thumbnails; the browser hydrates them back into blobs only after the private vault is unlocked.

Private cloud sync model

type CloudSyncJob = {
  projectId: string;
  expectedGeneration: number;
  revision: number;
  state: 'pending' | 'syncing' | 'error' | 'conflict';
  retryCount: number;
  retryAt: number;
  project: ZineProject;
};

type CloudSyncStatus =
  | 'Saved in browser'
  | 'Syncing to cloud…'
  | 'Saved in cloud'
  | 'Offline — will resume'
  | 'Cloud sync needs attention';

The cloud workspace is deliberately queue-based instead of treating a save as a one-shot request. After a short idle delay, a meaningful edit is serialized into IndexedDB with its expected cloud generation and an upload plan. The app reports preparation, upload, and commit progress; retries transient failures with a capped exponential delay; wakes again when the browser comes back online; and restores the active cloud-workspace binding after a reload. A generation mismatch becomes an explicit conflict rather than an overwrite, with an editor path to open the newer version or retain the local work as a separate copy.


Design Notes

The UI uses a print-shop vocabulary: heavy rules, square controls, hard shadows, paper tones, and a narrow red accent. The panels map to the workflow: image tray on the left, working sheet or frame editor in the center, design controls on the right, and a compact top bar for Grid, Editor, Preview, account, print, import, and export actions. The app favors visible mechanics over hidden menus because the user is making a physical object and needs to see the consequences of each change immediately.

The most important view is the full sheet. Individual pages matter, but the final object is the folded imposition. Keeping that view one click away makes mistakes visible before the user prints. A beta desktop gate and responsive editor scaling are also part of that design decision: until the mobile editor is ready, the app explicitly blocks phones and windows below the editor's minimum scale instead of offering a cramped, unreliable canvas.