Skip to content

Instantly share code, notes, and snippets.

@bluntbrain
Created May 11, 2026 18:00
Show Gist options
  • Select an option

  • Save bluntbrain/a648bbc985a111ceefce9ce8980739fb to your computer and use it in GitHub Desktop.

Select an option

Save bluntbrain/a648bbc985a111ceefce9ce8980739fb to your computer and use it in GitHub Desktop.
Talkamore — Journal Photos & Stickers Feature Spec (Frontend + Backend)

Journal Photos & Stickers — Backend Implementation Spec

Overview

Support image storage, retrieval, and management for the journal feature. Users can reference images from chat conversations and upload new images directly. The backend handles storage (Cloudflare R2), generates thumbnails, and serves image metadata to the frontend.


Architecture

User Device → Backend API → Cloudflare R2 (via S3 SDK)
                                ↓
                    Cloudflare Worker (public serving)
                    https://talkamore-media-worker.team-e29.workers.dev/
  • Upload path: Device → Backend → R2
  • Serving path: Frontend → Cloudflare Worker → R2 (direct, no backend involved)
  • Storage: Cloudflare R2 bucket (already set up with Worker)

R2 Folder Structure

talkamore-media/
├── chat/
│   └── {userId}/
│       ├── {imageId}.jpg          # original chat image
│       └── thumb_{imageId}.jpg    # thumbnail (300px wide)
├── journal/
│   └── {userId}/
│       ├── {imageId}.jpg          # original uploaded image
│       └── thumb_{imageId}.jpg    # thumbnail

Database Schema

New table: images

CREATE TABLE images (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  source        VARCHAR(10) NOT NULL CHECK (source IN ('chat', 'upload')),
  persona_id    VARCHAR(20),          -- nullable, only for chat images
  original_url  TEXT NOT NULL,         -- full R2 URL
  thumbnail_url TEXT NOT NULL,         -- thumbnail R2 URL
  file_name     VARCHAR(255),
  mime_type     VARCHAR(50) NOT NULL,
  file_size     INTEGER NOT NULL,      -- bytes
  width         INTEGER,               -- original width in px
  height        INTEGER,               -- original height in px
  created_at    TIMESTAMP DEFAULT NOW(),
  deleted_at    TIMESTAMP              -- soft delete
);

CREATE INDEX idx_images_user_id ON images(user_id);
CREATE INDEX idx_images_user_source ON images(user_id, source);

Update table: journal_entries

Add a column or use a join table for image placements:

CREATE TABLE journal_entry_images (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  journal_entry_id UUID NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
  image_id        UUID NOT NULL REFERENCES images(id),
  x               FLOAT NOT NULL DEFAULT 0,      -- x position
  y               FLOAT NOT NULL DEFAULT 0,      -- y position
  width           FLOAT NOT NULL DEFAULT 200,    -- display width
  height          FLOAT NOT NULL DEFAULT 200,    -- display height
  rotation        FLOAT NOT NULL DEFAULT 0,      -- degrees
  z_index         INTEGER NOT NULL DEFAULT 0,    -- layer order
  created_at      TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_jei_entry ON journal_entry_images(journal_entry_id);

Prisma schema (if using Prisma)

model Image {
  id           String   @id @default(uuid())
  userId       String   @map("user_id")
  source       String   // 'chat' | 'upload'
  personaId    String?  @map("persona_id")
  originalUrl  String   @map("original_url")
  thumbnailUrl String   @map("thumbnail_url")
  fileName     String?  @map("file_name")
  mimeType     String   @map("mime_type")
  fileSize     Int      @map("file_size")
  width        Int?
  height       Int?
  createdAt    DateTime @default(now()) @map("created_at")
  deletedAt    DateTime? @map("deleted_at")

  user                User                 @relation(fields: [userId], references: [id], onDelete: Cascade)
  journalEntryImages  JournalEntryImage[]

  @@index([userId])
  @@index([userId, source])
  @@map("images")
}

model JournalEntryImage {
  id             String  @id @default(uuid())
  journalEntryId String  @map("journal_entry_id")
  imageId        String  @map("image_id")
  x              Float   @default(0)
  y              Float   @default(0)
  width          Float   @default(200)
  height         Float   @default(200)
  rotation       Float   @default(0)
  zIndex         Int     @default(0) @map("z_index")
  createdAt      DateTime @default(now()) @map("created_at")

  journalEntry   JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade)
  image          Image        @relation(fields: [imageId], references: [id])

  @@index([journalEntryId])
  @@map("journal_entry_images")
}

API Endpoints

1. GET /api/journal/images/chat

Fetch user's images from chat conversations.

Auth: Required (JWT)

Query params:

  • cursor (optional) — pagination cursor
  • limit (optional, default 20, max 50)
  • personaId (optional) — filter by persona

Response:

{
  "images": [
    {
      "imageId": "uuid",
      "url": "https://talkamore-media-worker.team-e29.workers.dev/chat/{userId}/{imageId}.jpg",
      "thumbnailUrl": "https://talkamore-media-worker.team-e29.workers.dev/chat/{userId}/thumb_{imageId}.jpg",
      "sentAt": "2026-05-10T14:30:00Z",
      "personaId": "maya",
      "personaName": "Maya"
    }
  ],
  "nextCursor": "string | null",
  "hasMore": true
}

2. POST /api/journal/images/upload

Upload a new image from device.

Auth: Required (JWT)

Request: multipart/form-data

  • image — file field (JPG, PNG, WEBP, GIF)

Validation:

  • Max file size: 10MB
  • Allowed MIME types: image/jpeg, image/png, image/webp, image/gif
  • Max dimensions: 4096x4096 (resize if larger)

Process:

  1. Validate file type and size
  2. Generate UUID for imageId
  3. Process image: extract dimensions, generate thumbnail (300px wide, maintain aspect ratio)
  4. Upload original to R2: journal/{userId}/{imageId}.{ext}
  5. Upload thumbnail to R2: journal/{userId}/thumb_{imageId}.{ext}
  6. Insert record into images table
  7. Return response

Response:

{
  "imageId": "uuid",
  "url": "https://talkamore-media-worker.team-e29.workers.dev/journal/{userId}/{imageId}.jpg",
  "thumbnailUrl": "https://talkamore-media-worker.team-e29.workers.dev/journal/{userId}/thumb_{imageId}.jpg",
  "width": 1200,
  "height": 800
}

Libraries needed:

  • sharp — for image resizing / thumbnail generation
  • @aws-sdk/client-s3 — for R2 upload (S3-compatible API)
  • multer or busboy — for multipart form parsing

3. PUT /api/journal/entries/:id

Update journal entry to include image placements.

Auth: Required (JWT, must own the entry)

Request body addition:

{
  "content": "...",
  "images": [
    {
      "imageId": "uuid",
      "x": 120,
      "y": 45,
      "width": 250,
      "height": 180,
      "rotation": 0,
      "zIndex": 1
    }
  ]
}

Process:

  1. Validate all imageIds belong to the authenticated user
  2. Delete existing journal_entry_images for this entry
  3. Bulk insert new journal_entry_images records
  4. Update journal entry content as usual

4. GET /api/journal/entries/:id

Load journal entry with image placements.

Response addition:

{
  "id": "uuid",
  "content": "...",
  "images": [
    {
      "imageId": "uuid",
      "url": "https://talkamore-media-worker.team-e29.workers.dev/journal/{userId}/{imageId}.jpg",
      "thumbnailUrl": "https://talkamore-media-worker.team-e29.workers.dev/journal/{userId}/thumb_{imageId}.jpg",
      "x": 120,
      "y": 45,
      "width": 250,
      "height": 180,
      "rotation": 0,
      "zIndex": 1
    }
  ]
}

5. DELETE /api/journal/images/:imageId

Soft-delete an uploaded image.

Auth: Required (JWT, must own the image)

Process:

  1. Set deleted_at = NOW() on the image record
  2. Remove any journal_entry_images referencing this image
  3. Optionally: queue a background job to delete from R2 after 30 days (grace period)

Response: 204 No Content


R2 Integration Code

Setup (lib/r2.ts)

import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';

const r2Client = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

const BUCKET = process.env.R2_BUCKET_NAME!; // 'talkamore-media'
const WORKER_URL = process.env.R2_WORKER_URL!; // 'https://talkamore-media-worker.team-e29.workers.dev'

export async function uploadToR2(key: string, body: Buffer, contentType: string) {
  await r2Client.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body: body,
    ContentType: contentType,
  }));
  return `${WORKER_URL}/${key}`;
}

export async function deleteFromR2(key: string) {
  await r2Client.send(new DeleteObjectCommand({
    Bucket: BUCKET,
    Key: key,
  }));
}

Upload handler (services/imageService.ts)

import sharp from 'sharp';
import { v4 as uuid } from 'uuid';
import { uploadToR2 } from '../lib/r2';

const THUMBNAIL_WIDTH = 300;
const MAX_DIMENSION = 4096;

export async function processAndUploadImage(
  fileBuffer: Buffer,
  mimeType: string,
  userId: string,
  source: 'chat' | 'upload',
  personaId?: string
) {
  const imageId = uuid();
  const ext = mimeType.split('/')[1] === 'jpeg' ? 'jpg' : mimeType.split('/')[1];

  // Get metadata and resize if needed
  const metadata = await sharp(fileBuffer).metadata();
  let processed = sharp(fileBuffer);

  if ((metadata.width && metadata.width > MAX_DIMENSION) ||
      (metadata.height && metadata.height > MAX_DIMENSION)) {
    processed = processed.resize(MAX_DIMENSION, MAX_DIMENSION, { fit: 'inside' });
  }

  const originalBuffer = await processed.toBuffer();
  const thumbnailBuffer = await sharp(fileBuffer)
    .resize(THUMBNAIL_WIDTH, null, { fit: 'inside' })
    .toBuffer();

  const folder = source === 'chat' ? 'chat' : 'journal';
  const originalKey = `${folder}/${userId}/${imageId}.${ext}`;
  const thumbnailKey = `${folder}/${userId}/thumb_${imageId}.${ext}`;

  const [originalUrl, thumbnailUrl] = await Promise.all([
    uploadToR2(originalKey, originalBuffer, mimeType),
    uploadToR2(thumbnailKey, thumbnailBuffer, mimeType),
  ]);

  return {
    imageId,
    originalUrl,
    thumbnailUrl,
    width: metadata.width,
    height: metadata.height,
    fileSize: originalBuffer.length,
  };
}

Environment Variables

Add to .env:

R2_ACCOUNT_ID=your_cloudflare_account_id
R2_ACCESS_KEY_ID=your_r2_access_key
R2_SECRET_ACCESS_KEY=your_r2_secret_key
R2_BUCKET_NAME=talkamore-media
R2_WORKER_URL=https://talkamore-media-worker.team-e29.workers.dev

Chat Image Integration

When a user sends an image in a chat conversation (to any persona), the existing chat image handling should ALSO create a record in the images table:

// In your existing chat message handler, after storing the chat message:
if (message.hasImage) {
  await processAndUploadImage(
    imageBuffer,
    mimeType,
    userId,
    'chat',      // source
    personaId    // which persona they sent it to
  );
}

This ensures chat images automatically appear in the journal image picker without any additional user action.


Security

  • Auth: All endpoints require valid JWT. Users can only access their own images.
  • Validation: Strict MIME type checking (don't trust Content-Type alone — use sharp or file-type to verify actual image bytes)
  • Rate limiting: Max 20 uploads per minute per user
  • Size limits: 10MB per file, max 10 images per journal entry
  • CORS: R2 Worker already has Access-Control-Allow-Origin: * — consider restricting to your domain in production
  • Soft delete: Always soft-delete images (set deleted_at). Hard-delete from R2 via background job after 30-day grace period.

Journal Photos & Stickers — Frontend Implementation Spec

Overview

Users can add photos to their journal entries — both images they've already sent in chat conversations and new images from their device. Photos can be placed on journal pages like stickers, freely positioned and resized to decorate their journals.


Feature Breakdown

1. Image Sources

Users can add images from two sources:

A. Chat Images (already sent)

  • Pull images the user has previously sent in conversations with any persona (Maya, Sage, Theo, Luna)
  • Display as a gallery/picker sorted by most recent
  • Each image should show: thumbnail, date sent, which persona conversation it came from
  • API: GET /api/journal/images/chat — returns paginated list of user's chat images

B. Device Upload (new images)

  • Standard file picker / camera roll access
  • Accept: JPG, PNG, WEBP, GIF
  • Max file size: 10MB per image
  • Upload to backend, receive back a URL
  • API: POST /api/journal/images/upload — multipart form upload, returns { imageId, url }

2. Image Picker UI

Location: Inside the journal entry editor, add a toolbar button (📷 or image icon)

Picker Modal:

  • Two tabs: "From Chats" | "Upload New"
  • From Chats tab:
    • Grid layout, 3 columns
    • Lazy-loaded thumbnails
    • Infinite scroll / pagination
    • Filter by persona (optional, dropdown: All / Maya / Sage / Theo / Luna)
    • Tap to select (multi-select supported, max 5 per entry)
  • Upload New tab:
    • Drag & drop zone (desktop)
    • "Choose from device" button
    • Camera capture option (mobile)
    • Show upload progress bar
    • Preview before confirming

3. Sticker-Style Placement on Journal Pages

Once an image is selected/uploaded, it appears on the journal page as a draggable, resizable element.

Behavior:

  • Drag: User can freely position the image anywhere on the journal entry canvas
  • Resize: Corner handles to scale proportionally (maintain aspect ratio by default, free resize with shift/modifier)
  • Rotate: Optional rotation handle or two-finger rotate on mobile
  • Layer order: Images can overlap text and each other. Tap to bring to front.
  • Delete: Long press or tap → show delete button (trash icon overlay)
  • Snap/grid: Optional subtle grid snapping for alignment (can be toggled)

Implementation approach:

  • Use a canvas/overlay layer on top of the journal text content
  • Each image is an absolutely positioned element within the journal entry container
  • Store position data as: { imageId, x, y, width, height, rotation, zIndex }
  • Consider using a library like react-draggable + re-resizable or a unified solution like react-rnd for drag + resize
  • For mobile: handle touch events for drag, pinch-to-zoom for resize, two-finger rotate

Data structure per image on a journal page:

interface JournalImage {
  imageId: string;          // references the stored image
  url: string;              // CDN/worker URL for display
  x: number;                // x position (percentage or px from left)
  y: number;                // y position (percentage or px from top)
  width: number;            // display width
  height: number;           // display height
  rotation: number;         // degrees, default 0
  zIndex: number;           // layer order
  source: 'chat' | 'upload'; // where it came from
  personaId?: string;       // if from chat, which persona
}

4. Journal Entry Editor Updates

Current flow (assumed): User writes text in a journal entry editor.

Updated flow:

  1. User opens journal entry (new or existing)
  2. Text editor remains as-is
  3. New toolbar button: 📷 "Add Photo"
  4. Tapping opens the Image Picker Modal
  5. Selected images appear on the journal canvas
  6. User positions/resizes images freely
  7. On save, journal entry payload includes both text content AND image placement data

Save payload addition:

interface JournalEntry {
  // ...existing fields
  images: JournalImage[];   // array of placed images with positions
}

5. Journal Entry View (Read Mode)

When viewing a saved journal entry:

  • Render images at their saved positions, sizes, and rotations
  • Images are NOT draggable/resizable in view mode
  • Tap on image → open full-size lightbox/preview
  • Ensure images load with proper aspect ratios (use the CDN URL with appropriate sizing)

6. API Integration

Endpoints to integrate:

Method Endpoint Purpose
GET /api/journal/images/chat Fetch user's chat images (paginated)
POST /api/journal/images/upload Upload new image from device
PUT /api/journal/entries/:id Save journal entry (now includes images[] array)
GET /api/journal/entries/:id Load journal entry (now returns images[] array)
DELETE /api/journal/images/:imageId Delete an uploaded image

Chat images response:

{
  "images": [
    {
      "imageId": "img_abc123",
      "url": "https://talkamore-media-worker.team-e29.workers.dev/chat/user123/img_abc123.jpg",
      "thumbnailUrl": "https://talkamore-media-worker.team-e29.workers.dev/chat/user123/thumb_img_abc123.jpg",
      "sentAt": "2026-05-10T14:30:00Z",
      "personaId": "maya",
      "personaName": "Maya"
    }
  ],
  "nextCursor": "cursor_xyz",
  "hasMore": true
}

7. UX Considerations

  • Empty state: If user has no chat images, show message: "No images yet. Start a conversation and share photos to see them here."
  • Loading states: Skeleton loaders for image grid, shimmer effect on thumbnails
  • Error handling: Failed uploads show retry button. Failed loads show placeholder with retry.
  • Mobile responsiveness: Image picker should be a bottom sheet on mobile, modal on desktop
  • Performance: Use thumbnail URLs in the picker grid, full URLs only when placed on journal. Lazy load images below the fold.
  • Undo: Support ctrl+Z / undo for image placement changes (move, resize, delete)
  • Max images per entry: Cap at 10 images per journal entry to keep pages performant

8. Tech Stack Notes

  • Framework: Next.js / React
  • Drag & resize: react-rnd (recommended) or react-draggable + re-resizable
  • Image upload: Use FormData with fetch or axios
  • Image optimization: Consider using next/image for optimized loading where applicable
  • State management: Store image placements in local state during editing, persist on save
  • Touch support: Ensure all interactions work with touch events on mobile browsers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment