-
Places Diary #5: Check-ins and Instant Image Previews for Image Uploads
Check-ins on places and instant image previews using web APIs.
One of the key features of Places is the ability to keep track of places you’ve been. I like to remember what I’ve tried and how it was, so Places includes “Check-ins” to add notes or a photo anytime there is something I want to remember about a place. If a note and photo is omitted, the date and time of a visit is saved. A timeline of check-ins is visible on each place’s page and on the dashboard.
I’ve had the feature working for quite a while, but I wasn’t happy with the UI for adding a photo. Last weekend I finished rewriting the photo uploader so that an image preview appears instantly when an image is added to a check-in. Before, it would wait for the upload to complete before showing a preview, leaving me to wonder whether the upload was working at all when out and about or on a slow connection.
Under the hood, this uses the
FileReader
API which lets web apps read the contents of a file as soon as it’s selected by the user instead of waiting for it to upload and getting information about the file from the server. Using thereadAsDataURL()
method, the image is converted to a base64 string that can be displayed as in image in the browser while the image uploads.The actual image upload only begins once the image preview is displayed (which essentially happens instantly) and a loading icon is displayed over the image preview until the upload is complete.
This is what the code looks:
const [media, setMedia] = React.useState([]); const handleFileInputChange = async (event) => { const files = event.target.files; if (!files) { return; } /** Create an image preview for each file */ for (const file of files) { const reader = new FileReader(); reader.addEventListener( "load", () => { /** Convert image file to base64 string */ const img = new Image(); img.src = reader.result as string; /** This is called when the read operation is completed */ img.onload = async () => { /** Add the image that will be uploaded to the media state to display while the image is being uploaded */ setMedia((prevMedia) => [ ...prevMedia, { height: img.naturalHeight, width: img.naturalWidth, src: img.src, filename: file.name, /** Indicates the image is being uploaded so a spinner can be shown */ state: "uploading", }, ]); /** Upload the image */ const formData = new FormData(); formData.append("file", file); const upload = await fetch("/media", { method: "POST", body: formData, }) .then((response) => response.json()) .then((data) => { setMedia((prevMedia) => { return prevMedia.map((media) => { /** Replace the optimistic media with the uploaded media */ if (media.filename === data.originalFilename) { return { height: data.height, width: data.width, src: `${process.env.ASSETS_URL}/${data.filename}?h=126&dpr=2`, filename: data.originalFilename, }; } return media; }); }); return data; }); }; }, false ); reader.readAsDataURL(file); } }; /** Show the images */ {media.map((media, index) => ( <div className={`relative h-32`} key={index}> <img height={media.height} width={media.width} src={media.src} className={`h-full max-h-full w-auto ${ media.state === "uploading" ? `opacity-50` : `` }`} /> {/** Show a spinner for media that is still uploading */} {media.state === "uploading" ? <Spinner /> : null} </div> ))}
Interested in trying Places?
If you are interested in trying Places, sign up for the waitlist and be among the first to know when it’s ready.
Other posts in this series
-
Added an option in Places to sort by date places were saved. This is useful when you can’t remember the name of a place you saved recently or want to try somewhere new to you. Sorting still defaults to places nearest you.
-
Added sections to collections in Places. I find them handy for organizing places in a collection by days when planning a trip, but they can be used for anything — like grouping places by category (e.g. food spots, entertainment, sights, etc.). Places can be moved into a section using drag and drop and reordered the same way. Still need to implement optimistic UI to avoid the visual jump that occurs when an item is dropped and the update is handled over the network.
-
Places Diary #4: MapKit Tokens
Creating a short-lived JSON Web Token (JWT) for MapKit JS using Remix
Places uses Apple’s MapKit JS for maps and points of interest. MapKit JS requires a signed JSON Web Token (JWT) to authenticate against its API. I initially created an endpoint that I could load in the browser to retrieve a token that was good for a year. This made testing and development easy: save the token to a
.env
file on the server, forget about it for a year until it expires, then repeat.Before opening Places up for more people to use, I wanted to:
- Shorten the time the token was good for
- Remove the need to manually update it
- And constrain the token to specific domains prevent to others from using the token
Providing a short-lived token to MapKit JS
The MapKit JS docs outline the header fields and payload needed to generate a token and the
jsonwebtoken
package handles generating it. Set the expiration time to the number of seconds since UNIX Epoch when you want the token to expire.Date.now() / 1000 + (60 * 5)
signs a token with a 5 minute expiration.import jwt from "jsonwebtoken"; export async function getAuthorizationToken({ expiration = 60 * 5, origin }) { return jwt.sign( { iss: APPLE_DEVELOPER_TEAM_ID /* Issuer: This is a 10-character Team ID obtained from your Apple Developer account. */, iat: Date.now() / 1000 /* Issued at: Number of seconds since UNIX Epoch, in UTC */, exp: Date.now() / 1000 + expiration /* Expiration: When the token expires, in terms of the number of seconds since UNIX Epoch, in UTC */, origin: origin /* An optional claim that constrains the token to a specific website or domain. The value of this claim is a fully qualified domain, without a URL scheme */, }, APPLE_MAPS_AUTH_KEY /* The private key that you obtain from your Apple Developer account */, { header: { kid: APPLE_MAPS_KEY_ID /* A 10-character key identifier that provides the ID of the private key that you obtain from your Apple Developer account. */, typ: "JWT" /* The type, “JWT” */, alg: "ES256" /* The algorithm you use to encrypt the token. Use the ES256 algorithm */, } } ); }
A endpoint is needed so the token can be retrieved. The route verifies the request is from an allowed origin, calls the
getAuthorizationToken
function constrained to the server’s hostname with a 5 minute expiration, and then returns it.import { getAuthorizationToken } from "~/mapkit.server"; export async function loader({ request }) { const url = new URL(request.url); const serverHostName = process.env.SERVER_HOSTNAME; const allowedOrigins = ["localhost:3000", "example.com"]; /** Get the fully qualified domain, without a URL scheme, from the request */ const origin = url.origin.replace(/^https?:\/\//, ""); /** Check if the origin is allowed */ if (!allowedOrigins.includes(origin) || origin !== serverHostName) { return new Response("Request not allowed", { status: 403 }); } /** Generate a new authorization token */ const authorizationToken = await getAuthorizationToken({ expiration: 60 * 5 /** five minutes */, origin: process.env.NODE_ENV === "development" ? undefined : process.env.SERVER_HOSTNAME /** `localhost` isn’t a valid origin, so omit this in development */, }); return new Response(authorizationToken); }
Now, instead of manually adding a token that needs to be updated when it expires, the
authorizationCallback
function MapKit JS requires for initialization can fetch a token from the endpoint. If MapKit JS detects an expired token, it will fetch a new one by calling theauthorizationCallback
function again.mapkit.init({ authorizationCallback: function(done) { fetch("/mapkit-token") .then(res => res.text()) .then(done) .catch(error => { console.error(error.message, "Error fetching Apple Maps token"); }); }, });
Interested in trying Places?
If you are interested in trying Places, sign up for the waitlist and be among the first to know when it’s ready.
Other posts in this series
-
Places Diary #3: Side Quests
Type-safe SQL queries, migrations, and updating from Remix v1 to v2 (plus ESM and Vite).
After parting ways with Prisma, I dedicated time to shoring up a few other aspects of the project.
Type-safe SQL queries
Initially opting for raw SQL queries post-Prisma, I’ve now refactored once more to use Kysely, a lightweight SQL query builder. It still uses
better-sqlite3
under the hood, but adds type-safe SQL queries and exposes the database schema to the TypeScript compiler for autocompletion based on the database schema. This helps prevent common errors like typos and referencing non-existant columns.Migrations
Replacing Prisma also meant needing to find a new way to handle migrations. Thankfully, Kysely provides examples for handling migrations. That paired with a script to execute them on each deploy addressed the remaining gaps from removing Prisma.
Remix v1 to Remix v2 (plus ESM and Vite)
I also updated from Remix v1 to v2. Remix’s “Future Flags” made it pretty straightforward to adopt v2 changes while still on v1 and then seamlessly make the upgrade to v2 once I had each new feature working.
Remix v2 uses ESM as the default server module output, so I adopted that as well. The templates
create-remix
uses to create a new project are in the Remix repo, so it was simple enough to copy the ESM server modules from the Express template over the CJS modules that needed to be updated.Once on v2, I replaced Remix’s compiler with Vite, which Remix plans to make its default in the future. Remix’s compiler requires a workaround for bundling ESM server modules. Moving to Vite allowed me to quit tracking the dependencies that Remix needed to bundle differently since Vite supports both ESM and CJS dependencies.
One Annoying Issue
The move to Vite has introduced one annoying issue. Occasionally after making a change, the dev server will show
504 (Outdated Optimize Dep)
errors in Safari’s console (it doesn’t seem to affect Chromium browsers). This results in interactive features not working until cached data is cleared forlocalhost
(Safari → Settings → Privacy → Manage Website Data). Everything works fine in production, so I will track the issue at GitHub and hope it gets resolved soon.Now back to new features…
Interested in trying Places?
If you are interested in trying Places, sign up for the waitlist and be among the first to know when it’s ready.
Other Posts In This Series
-
Places Diary #2: Removing Prisma
Replacing an ORM and interacting directly with the database for improved productivity and greater flexibility.
I started building Places using a Remix “stack” that made some decisions about development tools so I could get started iterating on ideas instead of spending time doing a bunch of tedious setup. One of the decisions that came with the stack was the use of Prisma as an ORM.
Prisma makes a lot of things easy:
- Connecting to a database
- Creating the schema
- Handling migrations
- Querying the database
- CRUD operations
But there are other things you can’t do at all.
Things that I had to work around when using Prisma
Math
Prisma embeds its own binary of SQLite and it does not have SQLite’s math functions enabled. Places is a location-based app and location-based apps rely on the Haversine formula to calculate the geographical distance between points. It was possible to work around this by calculating the straight-line distance between points, but straight-line distance gets less accurate as points get farther apart.
Polymorphic associations
In Places you can add media to a location and you can add media to a check-in. It’s common to use a single table to store these types of associations in a clean and extensible manner. Prisma prefers to create a new table for each type of association. This results in a more complex schema, more complex queries, and more cumbersome maintenance over time. There has been an issue tracking polymorphic associations and unions for four years, but neither are on Prisma’s roadmap as of this writing.
Querying recursive relationships
Places are assigned categories and those categories are organized in a tree structure (e.g. filtering by Asian restaurants will show all Chinese, Thai, and Sushi restaurants). With Prisma, it’s possible to create those relationships, but you have to know the maximum level of nested categories to query them because it doesn’t support recursive queries.
Search
Primsa added support for full-text search for PostgreSQL in 2021. As of February 2024, they have not added full-text search support for SQLite. This means search in Places has been limited to simple pattern matching searches, such as finding saved places with names containing certain letters or words.
Compiling SQLite
Prisma includes its own SQLite binary and I’m not sure if it can be compiled with custom options. I’ve seen mention of compiling your own, but I haven’t seen any examples or docs for doing that. While it’s possible to drop down to raw queries in Prisma, raw queries won’t help if you need functions that aren’t enabled.
Removing Prisma
Because of all the challenges above, I decided to remove Prisma from the project and refactor all the CRUD operations to raw SQL. I’m using
better-sqlite3
to interact with database. It was pretty painless to make the switch and now (for better or worse) I am in control of the database schema and queries.better-sqlite3
also bundles its own SQLite binary, but the bundled version is compiled with support for math functions and full-text search. The docs also include information for compiling a custom configuration should I need to in the future.Prisma was helpful for getting started, but I think replacing its abstraction with direct interaction with SQLite will be more productive and flexible in the long term.
Interested in trying Places?
If you are interested in trying Places, sign up for the waitlist and be among the first to know when it’s ready.
Other Posts In This Series
-
Places Diary #1: An Introduction
On building Places, a web app to keep track of places I’ve been and places I want to go.
Places is a web app I’m building to keep track of places I’ve been and places I want to go. I’ve been working on it on and off for about 18 months in my spare time and have over 300 code commits in the repo.
What is it?
It’s essentially a places organizer. You can search for places (restaurants, breweries, museums, venues, or anything else that interests you), save them, and add notes — like why you are saving it or a recommendation from a friend who’s been there.
You can also create collections of places you save. A couple of my favorites are 50 Top Pizza Spots in the United Sates and Date Night. I also make a collection before each trip I take of the places I want to try in whatever city I’m visiting.
Some other niceties include:
- Filters: view all places of a certain category, whether or not you’ve visited before, and your favorites
- Check-ins: add photos and notes when you visit a place to remember what was good or what to avoid next time
For the most part, Places is meant to be personal, but you can create a public link for any of your collections and share with anyone to view. It’s useful for sharing places with people you are traveling with or recommendations for a friend who is visiting.
The technology
I’m trying to keep things relatively simple by avoiding too many NPM packages and limiting third party services. The app is deployed on a traditional server running Linux and Node.js and built using:
- Remix: web framework
- Tailwind: css framework
- SQLite: database
- Prisma: ORM (but I’m working on removing it due to it’s limitations with math, unions/polymorphism, search, etc.)
- MapKit JS: maps, points of interest
The app has been functional enough for me to use as my main “place-saver” for over a year, but there is still plenty to add and polish.
Interested in trying Places?
If you are interested in trying Places, sign up for the waitlist and be among the first to know when it’s ready.
subscribe via RSS