Photo Sharing

Wouldn't it be cool if your friends and family could share photos from their mobile devices, and you could see the photos on your TV? Let's vibe code it! Here's a chat in which ChatGPT created this app.

Prompt

Sent to ChatGPT...

Let's write an app that lets friends and family use a link to share photos to your TV. They could do this in real time while they are in the room with you or on a video chat. Or they could share photos into the stream while they're on vacation and they you'll be able to see them on the TV the next time you open the app. Here's the flow:

  • When the TV app starts up, it will generate a random stream ID in the format ABCD-EFGH and save it to local storage, unless there already is one in which case it will just use that.
  • It will generate a permalink containing the code, and show a QR code for that link in the corner.
  • Someone can open that link on their phone to a web page that has an upload button. They can tap it to browse for a photo on the camera roll. The app can then call an API to upload the photo.
  • It saves the photo to S3 in a subfolder for the stream ID.
  • When the TV app launches it looks for photos in that folder.
  • When the server processes a new photo, it pushes a message to the TV using Socket.io to tell it to refresh.
  • The app won't won't rely on socket.io, and will also refresh periodically from the server.
  • The user can use the left/right arrows buttons to select photos.
  • The user can use the back (escape) button to clear the photos.

Let's discuss the plan and then once we've talked it through we'll start on the implementation.

Plan

Proposed by ChatGPT...

Love the idea! Sounds like a great app. I’ll sketch out a formal architecture so we’re set up for implementation.

TV App

  • holds streamId (+ optional streamSecret)
  • shows QR
  • fetches current photos
  • listens on socket for new arrivals

Phone App

  • loads upload page for streamId
  • uploads photo
  • tiny gallery preview

Backend

  • API service
  • create/get stream
  • presign upload
  • emit socket events

Storage

  • S3: streams/{streamId}/{photoFilename}

Flow

  1. TV starts → gets/creates streamId → shows QR
  2. Phone opens link
  3. Phone uploads
  4. Server records photo + emits socket update
  5. TV receives update → fetches photo list → renders

Let me know when you're ready to get started with the code!

Server

Create an .env to store information for connecting to AWS. Note that the credentials are not loaded by the app; they're read directly by the S3 client library.

AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1
S3_BUCKET=your-bucket

Create a file server.js and start with this setup code that loads the needed libraries, configures AWS, and serves the static content using express.

import "dotenv/config";
import express from "express";
import http from "http";
import path from "path";
import crypto from "crypto";
import { Server } from "socket.io";
import { 
  S3Client,
  ListObjectsV2Command,
  PutObjectCommand,
  DeleteObjectsCommand
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const AWS_REGION = process.env.AWS_REGION || "us-east-1";
const S3_BUCKET = process.env.S3_BUCKET;
const BASE_PREFIX = "photo-stream/";

const s3 = new S3Client({ region: AWS_REGION });

const app = express();
app.use(express.json({ limit: "1mb" }));
app.use(express.static(path.join(__dirname, "public")));

const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
  console.log(`Photo stream demo server running on http://localhost:${PORT}`);
  console.log(`Using bucket: ${S3_BUCKET} in region: ${AWS_REGION}`);
});

Add an API endpoint for listing photos in the stream:

app.get("/api/photos/:streamId", async (req, res) => {
  const { streamId } = req.params;
  if (!safeStreamId(streamId)) {
    return res.status(400).json({ error: "Invalid streamId" });
  }

  const Prefix = streamPrefix(streamId);

  try {
    const cmd = new ListObjectsV2Command({
      Bucket: S3_BUCKET,
      Prefix,
    });

    const out = await s3.send(cmd);

    const items = (out.Contents || [])
      .filter(o => o.Key && o.Key !== Prefix)
      .map(o => ({
        key: o.Key,
        filename: o.Key.replace(Prefix, ""),
        lastModified: o.LastModified ? o.LastModified.toISOString() : null,
        size: o.Size || 0,
      }))Z      .sort((a, b) => new Date(a.lastModified) - new Date(b.lastModified));

    res.json({ streamId, photos: items });
  } catch (err) {
    console.error("ListObjects error", err);
    res.status(500).json({ error: "Failed to list photos" });
  }
});

Add an API endpoint that allows the client to get a pre-signed URL for uploading photos directly to S3:

app.post("/api/presign", async (req, res) => {
  const { streamId, contentType } = req.body || {};

  if (!safeStreamId(streamId)) {
    return res.status(400).json({ error: "Invalid streamId" });
  }

  const ext = extensionFromContentType(contentType);
  const filename = makeRandomFilename(ext);
  const key = streamPrefix(streamId) + filename;

  try {
    const putCmd = new PutObjectCommand({
      Bucket: S3_BUCKET,
      Key: key,
      ContentType: contentType || "image/jpeg",
      // Demo caching posture: immutable objects
      CacheControl: "public, max-age=31536000, immutable",
    });

    const uploadUrl = await getSignedUrl(s3, putCmd, {
      expiresIn: 60 * 5, // 5 minutes
    });

    res.json({ uploadUrl, key, filename });
  } catch (err) {
    console.error("Presign error", err);
    res.status(500).json({ error: "Failed to presign upload" });
  }
});

Add an API endpoint for the client to tell the server that it has completed uploading a photo. This sends a message to the TV app using Socket.io with then name of the new photo.

app.post("/api/complete", (req, res) => {
  const { streamId, key, filename } = req.body || {};

  if (!safeStreamId(streamId) || typeof key !== "string") {
    return res.status(400).json({ error: "Invalid payload" });
  }

  // Broadcast to TVs listening on that stream room
  io.to(streamId).emit("photoAdded", {
    streamId,
    key,
    filename: filename || key.split("/").pop(),
    at: new Date().toISOString(),
  });

  res.json({ ok: true });
});

Add an API endpoint for clearing the photos in the stream.

app.post("/api/clear/:streamId", async (req, res) => {
  const { streamId } = req.params;
  if (!safeStreamId(streamId)) return res.status(400).json({ error: "Invalid streamId" });

  const Prefix = streamPrefix(streamId);

  try {
    // List everything in the stream folder
    const listed = await s3.send(new ListObjectsV2Command({
      Bucket: S3_BUCKET,
      Prefix
    }));

    const keys = (listed.Contents || [])
      .map(o => o.Key)
      .filter(Boolean);

    if (!keys.length) return res.json({ ok: true, deleted: 0 });

    await s3.send(new DeleteObjectsCommand({
      Bucket: S3_BUCKET,
      Delete: {
        Objects: keys.map(Key => ({ Key })),
        Quiet: true
      }
    }));

    io.to(streamId).emit("streamCleared", { streamId, at: new Date().toISOString() });

    res.json({ ok: true, deleted: keys.length });
  } catch (err) {
    console.error("Clear stream error", err);
    res.status(500).json({ error: "Failed to clear stream" });
  }
});

Add a small Socket.io server for sending new photo messages to the TV app:

const server = http.createServer(app);
const io = new Server(server, {
  cors: {
    origin: "*", // demo
    methods: ["GET", "POST"],
  },
});

io.on("connection", (socket) => {
  socket.on("joinStream", ({ streamId }) => {
    if (!safeStreamId(streamId)) return;
    socket.join(streamId);
    socket.emit("joinedStream", { streamId });
  });

  socket.on("disconnect", () => {});
});

Add a few helper functions for:

  • validating stream IDs
  • getting file extensions
  • making random filenames
  • calculating file paths on the server
function safeStreamId(id) {
  return typeof id === "string" && /^[A-Z]{4}-[A-Z]{4}$/.test(id);
}

function extensionFromContentType(ct) {
  if (!ct) return "jpg";
  const lower = ct.toLowerCase();
  if (lower.includes("png")) return "png";
  if (lower.includes("webp")) return "webp";
  if (lower.includes("heic") || lower.includes("heif")) return "heic";
  return "jpg";
}

function makeRandomFilename(ext) {
  const rand = crypto.randomBytes(8).toString("hex");
  return `${Date.now()}-${rand}.${ext}`;
}

function streamPrefix(streamId) {
  return `${BASE_PREFIX}${streamId}/`;
}

That's it for the server.

TV app

Start with an index.html file and index.css style sheet. The code will go in index.js.

Start by defining some constants, and a variable that we'll use to store the signature of the current group of photos to help us check if the contents have changed.

const qrcodeImg = document.getElementById("qrcode");
const streamIdEl = document.getElementById("streamId");
const thumbsEl = document.getElementById("thumbs");
const mainImgEl = document.getElementById("mainImg");
const emptyStateEl = document.getElementById("emptyState");
const statusEl = document.getElementById("status");

const S3_BUCKET = "senza-developer";
const BASE_PREFIX = "photo-stream";
const POLL_MS = 15000;

let lastSignature = "";

Next, implement some functions for handling the stream ID. We'll generate a new one if needed, and use local storage to save the current one. That way the app will use the same photo stream even if it is restarted.

function generateCode(text, size) {
  const data = encodeURIComponent(text);
  const src = `https://api.qrserver.com/v1/create-qr-code/?data=${data}&size=${size}x${size}`;
  qrcodeImg.src = src;
  qrcodeImg.width = size;
  qrcodeImg.height = size;
}

function randomLetters(n) {
  const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  let out = "";
  for (let i = 0; i < n; i++) {
    out += letters[Math.floor(Math.random() * letters.length)];
  }
  return out;
}

function generateStreamId() {
  return `${randomLetters(4)}-${randomLetters(4)}`;
}

function getOrCreateStreamId() {
  const key = "photoStreamId";
  let id = localStorage.getItem(key);
  if (!id) {
    id = generateStreamId();
    localStorage.setItem(key, id);
  }
  return id;
}

function s3UrlForKey(key) {
  // public bucket demo URL
  return `https://${S3_BUCKET}.s3.amazonaws.com/${key}`;
}

let streamId = getOrCreateStreamId();
let photos = [];
let activeIndex = -1;

function setStatus(msg) {
  statusEl.textContent = msg;
}

Add some code for rendering the user interface:

function render() {
  thumbsEl.innerHTML = "";

  if (!photos.length) {
    emptyStateEl.style.display = "block";
    mainImgEl.style.display = "none";
    activeIndex = -1;
    return;
  }

  emptyStateEl.style.display = "none";
  mainImgEl.style.display = "block";

  photos.forEach((p, idx) => {
    const img = document.createElement("img");
    img.src = s3UrlForKey(p.key);
    img.title = p.filename || p.key;
    if (idx === activeIndex) img.classList.add("active");
    img.onclick = () => {
      activeIndex = idx;
      showActive();
      renderThumbActive();
    };
    thumbsEl.appendChild(img);
  });

  // default to latest photo if nothing selected
  if (activeIndex < 0) activeIndex = photos.length - 1;
  showActive();
  renderThumbActive();

  // scroll thumbs so active is visible
  const activeThumb = thumbsEl.querySelector("img.active");
  if (activeThumb) activeThumb.scrollIntoView({ inline: "center", behavior: "smooth" });
}

Add some code for displaying the active photo and selecting photos with the arrow keys:

function renderThumbActive() {
  [...thumbsEl.querySelectorAll("img")].forEach((el, i) => {
    el.classList.toggle("active", i === activeIndex);
  });
}

function showActive() {
  const p = photos[activeIndex];
  if (!p) return;
  const url = s3UrlForKey(p.key);
  mainImgEl.style.opacity = "0";
  setTimeout(() => {
    mainImgEl.src = url;
    mainImgEl.onload = () => { mainImgEl.style.opacity = "1"; };
    mainImgEl.onerror = () => { mainImgEl.style.opacity = "1"; };
  }, 120);
}

function focusActiveThumb() {
  const activeThumb = thumbsEl.querySelector("img.active");
  if (activeThumb) {
    activeThumb.scrollIntoView({ inline: "center", behavior: "smooth", block: "nearest" });
  }
}

function setActiveIndex(nextIndex) {
  if (!photos.length) return;

  if (nextIndex < 0) nextIndex = photos.length - 1;
  if (nextIndex >= photos.length) nextIndex = 0;

  if (nextIndex === activeIndex) return;

  activeIndex = nextIndex;
  showActive();
  renderThumbActive();
  focusActiveThumb();
}

Add some code for polling to fetch the latest photos every few seconds:

function signatureFor(list) {
  return list.map(p => p.key).join("|");
}

async function refreshPhotos() {
  try {
    const r = await fetch(`/api/photos/${streamId}`);
    const data = await r.json();
    if (!data.photos) throw new Error("Bad response");

    const newList = data.photos;
    const newSig = signatureFor(newList);

    if (newSig === lastSignature) {
      setStatus(`Stream ${streamId} • ${photos.length} photo(s)`);
      return;
    }

    lastSignature = newSig;

    const prevKeys = new Set(photos.map(p => p.key));
    const added = newList.filter(p => !prevKeys.has(p.key));

    photos = newList;

    if (added.length) {
      activeIndex = photos.length - 1; // jump to latest
    } else if (activeIndex >= photos.length) {
      activeIndex = photos.length - 1;
    }

    render();
    setStatus(`Stream ${streamId} • ${photos.length} photo(s)`);
  } catch (e) {
    console.error(e);
    setStatus("Error loading photos");
  }
}

function startPolling() {
  refreshPhotos();
  setInterval(refreshPhotos, POLL_MS);
}

Add a function for clearing the photos in the stream:

async function clearPhotos() {
  try {
    setStatus("Clearing photos…");
    const r = await fetch(`/api/clear/${streamId}`, { method: "POST" });
    if (!r.ok) throw new Error("Clear failed");

    photos = [];
    activeIndex = -1;
    lastSignature = ""; // so next refresh repaints
    render();

    setStatus(`Stream ${streamId} • 0 photo(s)`);
  } catch (e) {
    console.error(e);
    setStatus("Error clearing photos");
  }
}

Finish up the TV app with an init function. It includes a key handler for selecting and clearing photos.

function init() {
  streamIdEl.textContent = streamId;

  const phoneLink = `${location.origin}/phone.html?stream=${streamId}`;
  generateCode(phoneLink, 160);

  startPolling();

  document.addEventListener("keydown", async function (event) {
    switch (event.key) {
      case "Enter": break;
      case "Escape": await clearPhotos(); break;
      case "ArrowUp": break;
      case "ArrowDown": break;
      case "ArrowLeft": setActiveIndex(activeIndex - 1); break;
      case "ArrowRight": setActiveIndex(activeIndex + 1); break;
      default: return;
    }
    event.preventDefault();
  });

  try {
    const socket = io();
    socket.emit("joinStream", { streamId });
    socket.on("photoAdded", () => refreshPhotos());
  } catch (e) {
    console.warn("Socket init failed (ok for demo)", e);
  }
}

init();

That's it for the TV app.

Phone app

Start with an phone.html file and phone.css style sheet. Here is the complete phone.js code.

It lets the user tap a button to select a photo from the camera roll. Then it requests a pre-signed URL from the server, uploads the file to S3, tells the server that it has finished, and displays a thumbnail in the list of uploaded photos.

const streamIdEl = document.getElementById("streamId");
const fileInput = document.getElementById("fileInput");
const pickBtn = document.getElementById("pickBtn");
const previewEl = document.getElementById("preview");
const statusEl = document.getElementById("status");
const recentCard = document.getElementById("recentCard");
const tinyGallery = document.getElementById("tinyGallery");

pickBtn.addEventListener("click", (e) => {
  e.preventDefault();
  fileInput.click();
});

function parseStreamId() {
  // 1) query param ?stream=ABCD-EFGH
  const params = new URLSearchParams(location.search);
  let id = params.get("stream");

  // 2) if someone hits /ABCD-EFGH directly, use that path segment
  if (!id) {
    const m = location.pathname.match(/([A-Za-z]{4}-[A-Za-z]{4})/);
    if (m) id = m[1];
  }

  // normalize to uppercase
  if (id) id = id.toUpperCase();
  return id;
}

const streamId = parseStreamId();
if (!streamId) {
  streamIdEl.textContent = "No stream ID in URL.";
  statusEl.innerHTML = `Make sure you opened the link from the TV.`;
  pickBtn.disabled = true;
} else {
  streamIdEl.textContent = `Stream: ${streamId}`;
}

function setStatus(msg) {
  statusEl.textContent = msg;
}

function showPreview(file) {
  const url = URL.createObjectURL(file);
  previewEl.src = url;
  previewEl.style.display = "block";
}

async function presignUpload(file) {
  const r = await fetch("/api/presign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      streamId,
      contentType: file.type || "image/jpeg"
    })
  });
  if (!r.ok) throw new Error("Presign failed");
  return r.json();
}

async function putToS3(uploadUrl, file) {
  const r = await fetch(uploadUrl, {
    method: "PUT",
    headers: {
      "Content-Type": file.type || "image/jpeg"
    },
    body: file
  });
  if (!r.ok) throw new Error("S3 PUT failed");
}

async function completeUpload({ key, filename }) {
  const r = await fetch("/api/complete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ streamId, key, filename })
  });
  if (!r.ok) throw new Error("Complete failed");
}

function addToRecent(url) {
  recentCard.style.display = "block";
  const img = document.createElement("img");
  img.src = url;
  tinyGallery.prepend(img);
}

fileInput.addEventListener("change", async (e) => {
  const file = e.target.files && e.target.files[0];
  if (!file) return;

  showPreview(file);
  pickBtn.disabled = true;

  try {
    setStatus("Preparing upload…");
    const { uploadUrl, key, filename } = await presignUpload(file);

    setStatus("Uploading to S3…");
    await putToS3(uploadUrl, file);

    setStatus("Finalizing…");
    await completeUpload({ key, filename });

    setStatus("✅ Uploaded! Check your TV.");
    addToRecent(URL.createObjectURL(file));
  } catch (err) {
    console.error(err);
    setStatus("❌ Upload failed. Try again.");
  } finally {
    pickBtn.disabled = false;
    fileInput.value = "";
  }
});

That's the whole phone app.

S3 Configuration

To complete the setup, you'll need an S3 bucket that can server files to the web using CloudFront.

In order for the web app to upload files with a PUT request using a pre-signed URL, you'll need to make a few changes to the CORS settings for your S3 bucket. In the AWS console, select your S3 bucket and go to Permissions > Cross-origin resource sharing (CORS). Edit the configuration like this:

[
    {
        "AllowedHeaders": ["*"],
        "AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
        "AllowedOrigins": ["*"],
        "ExposeHeaders": ["ETag", "x-amz-request-id"],
        "MaxAgeSeconds": 3000
    }

Testing

Try running the app on Senza, and you'll see a screen with a QR code and an 8-letter code. Scan the code with your mobile phone, and you'll see a page with an upload button. Tap the upload button and select a photo to upload it.

Then you'll see the photo appear on the TV!

You can upload photos any time. If the TV app is running you'll see them displayed as soon as they are uploaded. If it isn't running, it will display all the uploaded photos the next time it launches.

Use the left/right buttons to move between the displayed photos, and the back button to clear the photos.

Conclusion

That's it! We've vibe coded a great platform for uploading photos to a TV. ChatGPT wrote the entire app, including server, TV client and phone client, from a single prompt and everything worked as expected on first run.