← Portfolio

Technical Documentation

Zineroo

A browser-based React print shop for making seven kinds of printable zines, booklets, brochures, accordions, and folded maps, with local uploads, open-archive and Pinterest image intake, layered photos, rich text, editable shapes and icons, portrait or landscape documents, undo/redo, end-to-end encrypted cloud projects, exact imposition previews, and PDF, PNG, ZIP, or editable output

TypeScript React Vite Canvas IndexedDB jsPDF Supabase Web Crypto


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: upload, paste, or find source images; choose one of seven physical formats; auto-fill the editor; add photo, text, shape, and icon layers; reorder pages or spreads where the format supports it; tune the composition; preview the finished object or imposed 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, document-orientation controls, format-aware spreads, full-sheet imposition views, and one consolidated export surface for the printable artifact. The workspace separates the loop into Grid, Editor, and Preview modes, with an image portal, project manager, account flow, and public product site 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

Source image Library

Text and color layers

Shapes and icons

Layer stacking

Preview and export

Local and cloud persistence

Accounts, plans, and public workflows


Tech Stack

Framework React 18, React Router, and Vite
Language TypeScript
Rendering Shared DOM and HTML Canvas geometry for live editing, page previews, booklet spreads, imposition sheets, print, and export
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 via file picker, drag-and-drop, clipboard paste, Openverse, Library of Congress, Internet Archive, or optional Pinterest intake
Design elements Native SVG/canvas shape rendering plus server-proxied Iconify search; selected vectors are normalized and embedded in the project
Text layout Self-hosted fonts, source-indexed color runs, TeX-style line breaking, and English hyphenation shared by DOM and Canvas renderers
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), 16-page Foldy, 14-page Accordion, Saddle Stitch Booklet, Trifold Brochure, and Unigrid (NPS Map)
Typography Self-hosted Anton, Bebas Neue, Bungee, Alfa Slab One, UnifrakturMaguntia, Playfair Display, Alegreya, Lora, 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, access-code entitlements, and configurable captcha protection
Observability Vercel Analytics and Speed Insights plus product events for exports, upgrade gates, checkout, Library use, and public conversion paths

Architecture

Source structure

api/             Vercel API routes for admin, billing, auth, Library providers, Pinterest, Iconify, cleanup, and access codes
server/          protected billing, open-archive adapters, provider validation, design-element proxying, and shared security helpers
src/
  main.tsx       React Router shell for public pages, editor, Projects, account returns, and admin routes
  App.tsx        editor state, formats, image intake, clipboard and Library flows, history, and export orchestration
  canvas/        shared preview, booklet, imposition, and export rendering
  components/    image portal, Library, grids, frame editor, text, photos, shapes, icons, inspector, previews, and dialogs
  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, seven format maps, text-color ranges, shape/icon geometry, Library provenance, and interactions
  hooks/         viewport gating, checkout finalization, update checks, and confirmation helpers
  pages/         Home, Pricing, Projects, Classrooms, Teams, Support, policy pages, and admin console
  crypto/        browser-worker encryption format, key hierarchy, recovery keys, and authenticated cloud objects
  services/      assets, clipboard, Library/Pinterest/Iconify clients, IndexedDB sync, cloud crypto, entitlements, JSON, ZIP, PDF, ICC, and analytics
  styles/        app shell, public site, media portal, Library, grid, frame editor, inspector, preview, account, projects, and export UI

Core project model

type ZineProject = {
  version?: number;
  format?:
    | 'zine'
    | 'zine-square'
    | 'foldy-16'
    | 'accordion-14'
    | 'saddle-stitch'
    | 'trifold'
    | 'unigrid';
  pageCount?: number;
  unigridTitleBand?: {
    enabled: boolean;
    text: string;
  };
  title: string;
  assets: ZineAsset[];
  pages: PageSlot[];
  settings: DesignSettings & {
    orientation: 'portrait' | 'landscape';
  };
};

type TextOverlay = {
  id: string;
  text: string;
  fontFamily: string;
  fontSize: number;
  color: string;
  colorRanges?: Array<{ start: number; end: number; color: string }>;
  opacity: number;
  backgroundColor?: string;
  backgroundOpacity: number;
  tracking: number;
  lineHeight: number;
  padding: number;
  x: number;
  y: number;
  rotation: number;
  widthMode?: 'auto' | 'fixed';
  boxWidth?: number;
  heightMode?: 'auto' | 'fixed';
  boxHeight?: number;
  verticalAlign?: 'top' | 'middle' | 'bottom';
  curve: number;
  align: 'left' | 'center' | 'right' | 'justify';
};

type ShapeOverlay = {
  id: string;
  kind: 'rectangle' | 'circle' | 'triangle' | 'polygon' | 'line' |
    'arrow' | 'star' | 'speech-bubble' | 'icon';
  x: number;
  y: number;
  width: number;
  height: number;
  rotation: number;
  fillEnabled: boolean;
  fillColor: string;
  strokeColor: string;
  strokeWidth: number;
  cornerRadius?: number;
  sideCount?: number;
  icon?: IconifyIconData;
  iconStrokeScale?: number;
  opacity: number;
};

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;
  opacity?: number;
  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;
  provenance?: AssetProvenance;
};

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;
  opacity?: number;
  overlayColor?: string;
  overlayOpacity: number;
  overlayPhotoOnly?: boolean;
  photoLayers?: PhotoLayer[];
  textOverlays?: TextOverlay[];
  shapeOverlays?: ShapeOverlay[];
  layerOrder?: string[];
  spreadId?: string;
  spreadSide?: 'left' | 'center' | 'right';
  spreadPageIds?: number[];
  spreadIndex?: number;
  spreadLength?: number;
  spreadColumns?: number;
  spreadRows?: number;
  spreadColumnIndex?: number;
  spreadRowIndex?: number;
};

The project remains a compact, portable document: title, physical format, local asset library, page slots, optional Unigrid title-band state, and design settings including the finished-face orientation. Page slots own background and foreground photo placement, text and per-range color state, vector design elements, one cross-kind stacking order, overlay color, and spread geometry. Assets can also retain source and license provenance. Runtime object URLs are stripped before autosave, project export, and cloud persistence, then rebuilt when the project is restored. Uploaded or downloaded 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 }
  | { id: string; kind: 'shape'; overlay: ShapeOverlay };

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, text overlays, shapes, and icons. allLayersForSlot resolves that order into a typed stack that canvas rendering, hit-testing, and the Layers panel all read from instead of walking element families 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 older projects and pages missing a stored order still resolve to a sensible default stack.

Preview asset pipeline

const PREVIEW_MAX_LONG_EDGE = 2560;

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.

Library intake pipeline

interface LibrarySourceAdapter {
  search(query: string, filters: LibrarySearchFilters, cursor?: string): Promise<LibrarySearchResult>;
  getDocumentPages(externalId: string, cursor?: string): Promise<LibraryDocumentPages>;
  resolveMedia(externalId: string, variant: LibraryMediaVariant, options?: ResolveOptions): Promise<ResolvedLibraryMedia>;
}

Openverse, Library of Congress, and Internet Archive integrations share one server-side adapter contract, while provider-specific validation, allowlists, redirect limits, bounded reads, caching, authentication, and rate limits stay behind the API. The client receives normalized result cards and materializes the chosen image or clipped document region into a normal Zineroo asset. Pinterest uses the same normalized item and provenance model, but has a separately gated URL classifier and unfurl path for pins, share links, media URLs, and boards. Remote results never become permanent runtime dependencies: imported bytes and their source metadata are saved with the project.

Text and vector rendering parity

Text layout preserves source-string offsets while wrapping, justifying, hyphenating, and breaking long tokens, so a foreground-color range applied to a selection reaches the same characters in the textarea mirror, curved DOM glyphs, flat canvas runs, and print output. Shapes and Iconify vectors use normalized page coordinates and document-space stroke widths. Every view resolves them through the same layer order and geometry helpers; selected icon data is embedded in the project so an archive outage cannot change an existing composition.

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 ACCORDION_PRINTED_SIDE = { columns: 4, rows: 4, cells: /* explicit 14-face map */ };
const FOLDY_16_SHEET_SIDES = [
  { id: 'foldy-16-side-a', columns: 4, rows: 2, cells: /* explicit map */ },
  { id: 'foldy-16-side-b', columns: 4, rows: 2, cells: /* explicit map */ },
];
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. The Accordion leaves its two construction cells blank; the 16-page Foldy defines two explicit duplex maps. Trifolds render separate outside and inside sides for double-sided printing. Saddle Stitch booklets render the required imposed sheet sequence so longer booklets can be exported as printable PDF sheets. Unigrid projects render two large-format sides with explicit 6-by-2 cell geometry and format-specific paper choices. A saved document orientation changes logical face geometry and adds the required imposition rotation while the physical sheet dimensions remain stable.

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. The two new fixed formats reuse the same machinery for six Accordion reader pairs and seven 16-page Foldy pairs instead of adding view-specific reorder rules.

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 project history when it closes. Document-orientation changes are one reversible history step, and mixed text-and-shape moves commit as one operation rather than a sequence of unrelated element updates.

Export flow

async function renderPdfBlob(
  options = STANDARD_PDF_EXPORT_OPTIONS,
  sides = sheetSidesForProject(project),
) {
  const paper = sheetSizeForProject(project);
  const pageSize = pdfPageSizeForExport(paper, options);
  const orientation = pageSize.width > pageSize.height ? 'landscape' : 'portrait';
  const pdf = new JsPDF({
    orientation,
    unit: 'pt',
    format: [pageSize.width, pageSize.height],
  });

  for (const [index, side] of sides.entries()) {
    if (index > 0) pdf.addPage([pageSize.width, pageSize.height], orientation);
    const sheet = await renderImpositionCanvas(project, pdfTrimCanvasWidthForExport(paper.width, options), {
      showGuides: false,
      imageSource: 'original',
      sideId: side.id,
      watermark: exportWatermark,
    });
    const preparedSheet = preparePrintCanvas(sheet, options);
    pdf.addImage(preparedSheet.toDataURL('image/jpeg', 0.95), 'JPEG', 0, 0, pageSize.width, pageSize.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, browser print, or ZIP. The physical sheet helper preserves portrait Accordion output and landscape output for the other formats, while a capability-based side list prevents two-sided formats from silently losing a side. Standard PDFs prioritize quick sharing and home printing; Free output is watermarked. 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 move between browsers without 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.

Development and release paths

Local feature work runs in one Docker-backed Test Studio that owns its loopback Supabase stack, migrations, seed data, production-secret isolation, and switchable Free, Plus, Pro, gift, and campaign personas. Release classification is separate: presentation-only registered copy or CSS can use a validated live-presentation lane, while React structure, behavior, auth, APIs, editor rendering, and security changes use the normal application build, tests, and deployment path. That separation keeps fast public copy updates possible without pretending product-code changes are low risk.


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, project, and export actions. A focused Add Images portal keeps uploads, paste, open archives, and Pinterest intake in one place; the full-frame Add control keeps text, shapes, and icons next to the composition. 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, especially for the two-sided 16-page Foldy, the Accordion's blank construction cells, and top-bound landscape variants. 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.