Skip to content

Asset Management via Directus: Implementation Plan

Implementation plan for the refined task in asset-management-via-directus.md. Covers all three affected areas end to end: user uploads (preview images, map source images), POI marker icons, and image-overlay layers.

Goal

Every image the app serves — user-uploaded previews/map sources, the POI icon set, and image-overlay layers — is stored in and served through Directus, so nothing depends on the app container's local filesystem. Existing files under public/uploads, public/images/map-icons, and public/images/overlay-maps are migrated in; the app never links a client directly to the Directus host.

Prerequisites

Already in place, no changes needed:

Not yet in place, provisioned manually by ops before T1 (same precedent as the map_projects collection): four Directus folders, one per asset category (previews, map source uploads, POI icons, image overlays), each with an id to hand to the app via an env var.

Decisions

Decision Choice Why
Scope Previews, map-source uploads, POI icons, and image overlays all migrate in this task Confirmed with user
Storage mode Directus-only, no local/directus toggle for assets Confirmed with user — hard cutover
Serving A new authenticated app route streams file bytes back, using the signed-in user's own token; no client code ever links to DIRECTUS_BASE_URL directly Confirmed with user
Token for writes and reads directusAsUser(accessToken), the same per-user token pattern projects.server.ts already uses Confirmed with user
Folder provisioning Manual — ops creates the four Directus folders, ids passed in as env vars Confirmed with user, mirrors how map_projects was provisioned
Existing local files Migrated into Directus as part of this task Confirmed with user
Original files under public/ Left on disk, untouched, as a rollback backup — not deleted, not read by the running app after migration Confirmed with user
Stored reference shape Existing fields (MapProject.previewImage, MapSource's upload.path, ImageOverlayLayer.path) keep their current name and string type, but now hold a Directus file id instead of a public path — avoids renaming a field across every caller for a change TypeScript would just re-flag as a type mismatch anyway, not a behavior change Smallest change
URL construction One pure helper builds /assets/<fileId> from a file id; every render call site wraps its stored reference in it One place to change if the route path ever moves
POI icon files Their filename lookup table becomes a Directus-file-id lookup table, keeping the same shape — the 3 existing consumers of getPoiImageIconPath need no changes Smallest change; no consumer touches the storage detail today, so none should have to for this task either

Scope

In: - app/lib/env.server.ts — four new required folder-id variables - A new app/lib/assets.ts (client-safe) — the /assets/<fileId> URL builder - A new app/lib/assets.server.ts (server-only) — Directus file upload/read primitives, shared by uploads and the serving route - A new authenticated route (e.g. app/routes/asset.tsx at assets/:fileId, added to app/routes.ts) that streams a file back - app/features/uploads/uploads.server.ts — saveUpload writes to Directus instead of disk - Callers of saveUpload: app/routes/create.tsx, app/routes/add-map.tsx, app/routes/project.tsx - Every render call site of a stored image reference: app/routes/home.tsx (previewImage), app/routes/project.tsx (map list thumbnail), app/routes/project-map.tsx (passes source.path to UploadImageLayer), app/features/map-controls/image-overlay-layer.tsx (ImageOverlayLayerView's use of layer.path) - app/features/map-objects/poi/poi.ts — POI_IMAGE_ICON_FILES becomes a file-id table; getPoiImageIconPath resolves through the new URL builder - A one-time, manually-invoked migration script that uploads the files already under public/uploads, public/images/map-icons, and public/images/overlay-maps into their Directus folders and rewrites the matching references in data/projects.json and poi.ts - .env.example — document the four new variables

Out: - Creating the Directus folders themselves (ops, manual, prerequisite) - Any Directus role/permission configuration — this task assumes the signed-in user's role already has read/create access to directus_files/directus_folders (see Risks) - Deleting the original files under public/ (kept as backup, per decision) - Any change to upload validation rules (still 20MB max, PNG/JPEG/WebP only) - Any new UI for uploading/managing POI icons or overlay images — both stay hand-configured (icons in code, overlays in data/projects.json), only their storage backend changes - Tiles (public/tiles, build/client/tiles) — unaffected, as already scoped out in the source idea

Data model

Target shapes after this task (types/signatures only, bodies are implementation):

// app/lib/env.server.ts — four new required variables, alongside the existing REQUIRED list
DIRECTUS_PREVIEWS_FOLDER_ID: string
DIRECTUS_MAP_UPLOADS_FOLDER_ID: string
DIRECTUS_POI_ICONS_FOLDER_ID: string
DIRECTUS_IMAGE_OVERLAYS_FOLDER_ID: string
// app/lib/assets.ts — no Directus/fs imports, safe to import from any component
export function assetUrl(fileId: string): string // -> `/assets/${fileId}`
// app/lib/assets.server.ts
export async function uploadAssetFile(
  accessToken: string,
  file: File,
  folderId: string
): Promise<{ ok: true; fileId: string } | { ok: false; error: string }>

export async function readAssetFile(
  accessToken: string,
  fileId: string
): Promise<
  | { ok: true; body: ReadableStream | ArrayBuffer; contentType: string }
  | { ok: false; status: number }
>
// app/features/uploads/uploads.server.ts — same validation, new storage call
export async function saveUpload(
  accessToken: string,
  file: File,
  folderId: string
): Promise<{ ok: true; fileId: string } | { ok: false; error: string }>
// app/features/map-objects/poi/poi.ts — same shape, new value kind
const POI_IMAGE_ICON_FILE_IDS: Record<string, string> // was POI_IMAGE_ICON_FILES (name -> filename); now name -> Directus file id
export function getPoiImageIconPath(icon: string): string // unchanged signature, now returns assetUrl(fileId)

MapProject.previewImage?: string, MapSource's { kind: "upload"; path: string }, and ImageOverlayLayer.path: string (app/lib/projects.server.ts) keep their exact current shape — only their doc comments change to say "Directus file id" instead of "public path".

Tasks

Ordered so each leaves the repo type-checking.

T1 — env.server.ts / .env.example: add the four folder-id variables

Goal: The app can address the four Directus folders ops has already created.

Acceptance criteria: - env.server.ts's REQUIRED list gains DIRECTUS_PREVIEWS_FOLDER_ID, DIRECTUS_MAP_UPLOADS_FOLDER_ID, DIRECTUS_POI_ICONS_FOLDER_ID, DIRECTUS_IMAGE_OVERLAYS_FOLDER_ID; each is exported on env the same way DIRECTUS_BASE_URL is today. - .env.example documents all four with a one-line comment each. - Nothing else in env.server.ts changes. - pnpm typecheck passes (nothing yet reads the new exports).

T2 — assets.server.ts: Directus file read/write primitives

Goal: One shared, server-only module knows how to put a file into Directus and read one back, independent of which of the four categories it's for.

Acceptance criteria: - uploadAssetFile/readAssetFile exist with the signatures in the Data model above, using directusAsUser(accessToken) and the Directus Files API (folder id passed by the caller — this module has no per-category knowledge). - A Directus failure on either call is caught and returned as the ok: false branch (using toDirectusFailure for logging), never an unhandled rejection. - Nothing calls this module yet. - pnpm typecheck passes.

T3 — New authenticated route: stream a file back by id

Goal: A browser can load /assets/<fileId> and get the file's bytes back, but only while signed in.

Acceptance criteria: - A new route file (nested under routes/protected-layout.tsx in app/routes.ts, path assets/:fileId) whose loader calls readAssetFile(activeAccessToken(context), params.fileId) and returns a Response with the file's bytes and its Content-Type header set from Directus's own metadata. - A missing/inaccessible file returns a 404 Response, matching the app's existing not-found convention. - Requesting the URL without a valid session redirects to /login (the existing requireSessionMiddleware behavior) rather than serving bytes. - Nothing links to this route yet. - pnpm typecheck passes.

T4 — assets.ts, uploads.server.ts, and their callers: uploads go to Directus

Goal: New preview-image and map-source uploads are written to Directus and rendered back through the new route; nothing writes to public/uploads anymore.

Acceptance criteria: - assetUrl(fileId) exists in app/lib/assets.ts, per the Data model. - saveUpload no longer touches the filesystem; it validates as today (type, 20MB) then calls uploadAssetFile, returning { ok: true; fileId } on success. - create.tsx calls saveUpload with env.DIRECTUS_PREVIEWS_FOLDER_ID; add-map.tsx and the preview-replace branch of project.tsx call it with env.DIRECTUS_MAP_UPLOADS_FOLDER_ID / env.DIRECTUS_PREVIEWS_FOLDER_ID respectively — each already has activeAccessToken(context) available. - Every render call site wraps its stored reference in assetUrl(...) before use: home.tsx's project.previewImage, project.tsx's map-list <img src={map.source.path}>, and project-map.tsx's <UploadImageLayer path={source.path} />. - Submitting a new project preview image or a new map source image creates a file in Directus (visible in the Directus admin Files browser, in the correct folder) and renders correctly in the app. - A logged-out request for the same file id is rejected, not served. - pnpm typecheck and pnpm build pass.

T5 — Migrate existing public/uploads files and their references

Goal: Every preview image and map source image already referenced in data/projects.json keeps working, now served from Directus.

Acceptance criteria: - A manually-invoked migration script (not run automatically on server start) reads data/projects.json, uploads each distinct file under public/uploads/ it references into Directus (previews into DIRECTUS_PREVIEWS_FOLDER_ID, map sources into DIRECTUS_MAP_UPLOADS_FOLDER_ID), and rewrites previewImage/ source.path values in place to the resulting file ids. - After running it, every project/map that used to reference a public/uploads/... path renders identically, sourced from Directus. - The original files under public/uploads are left untouched on disk. - pnpm typecheck and pnpm build pass.

T6 — Migrate POI icon images

Goal: The POI marker icon set is served from Directus; no code outside poi.ts changes.

Acceptance criteria: - A manually-invoked script (can extend T5's, or a second one) uploads every file in public/images/map-icons referenced by POI_IMAGE_ICON_FILES into DIRECTUS_POI_ICONS_FOLDER_ID. - POI_IMAGE_ICON_FILES in poi.ts is replaced by POI_IMAGE_ICON_FILE_IDS (same keys, values are now Directus file ids); getPoiImageIconPath resolves a name to assetUrl(fileId). - poi-icons.tsx, map-object-type-form.tsx, and map-object-type-manager.tsx are unchanged — all three already call getPoiImageIconPath. - Every POI type using an image:<name> icon renders identically to before, sourced from Directus. - The original files under public/images/map-icons are left untouched on disk. - pnpm typecheck and pnpm build pass.

T7 — Migrate image-overlay layer images

Goal: Configured image-overlay layers are served from Directus.

Acceptance criteria: - A manually-invoked script uploads the overlay image(s) currently referenced by any imageOverlays[].path in data/projects.json (currently stadtplan_trallop.jpg) into DIRECTUS_IMAGE_OVERLAYS_FOLDER_ID, and rewrites those path values in place to the resulting file ids. - image-overlay-layer.tsx's ImageOverlayLayerView wraps layer.path in assetUrl(...) before passing it to L.imageOverlay/the rotated subclass. - The Kontinent-Karte overlay renders identically to before, sourced from Directus. - The original files under public/images/overlay-maps are left untouched on disk. - pnpm typecheck and pnpm build pass.

T8 — Final verification

Goal: All four categories work end to end, and the new access restriction actually holds.

Acceptance criteria: - pnpm typecheck, pnpm build, and pnpm format (no diff) all pass. - Manual check, signed in: create a project with a preview image, add a map with an uploaded source image, confirm a POI type using an image:<name> icon renders, confirm the Kontinent-Karte image overlay renders — all four sourced from Directus, none from public/. - Manual check, signed out (or with an expired/cleared session): a direct request to /assets/<any fileId used above> does not return the file (redirects to /login or 404s, never 200s with the bytes). - Manual check: confirm all four Directus folders now contain the expected files via the Directus admin UI.

Risks / open questions

  • The signed-in user's Directus role needs read/create permission on directus_files (and read on directus_folders) — not verified here, out of scope per Decisions. A missing permission surfaces as a generic toDirectusFailure message with no pointer to "check Directus permissions."
  • This plan assumes the @directus/sdk v25 REST composables expose file upload (multipart/form-data to /files) and raw asset read (/assets/:id) functions with a shape close to uploadFiles/ readAssetRaw. I could not reach the Directus docs to confirm exact function names/signatures from this session — verify against the installed SDK's type definitions at the start of T2, before committing to the exact primitive shape above.
  • readAssetFile's return type (ReadableStream | ArrayBuffer) is left loose deliberately — which one the SDK actually hands back determines how T3's route constructs its Response body; pin this down in T2.