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:
@directus/sdkdependency, already used the same way for project data (app/lib/projects.server.ts)directusAsUser/toDirectusFailurein app/lib/directus.server.tsactiveAccessToken(context)in app/lib/session.server.ts, and every route this task touches is already nested underroutes/protected-layout.tsxin app/routes.ts, so a signed-in session is already guaranteed- The
local/directussplit precedent in app/lib/env.server.ts and directus-project-storage.md — this task does not repeat that split (assets are Directus-only per the decision below), but reuses the same env-validation and per-user-token patterns
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 ondirectus_folders) — not verified here, out of scope per Decisions. A missing permission surfaces as a generictoDirectusFailuremessage with no pointer to "check Directus permissions." - This plan assumes the
@directus/sdkv25 REST composables expose file upload (multipart/form-datato/files) and raw asset read (/assets/:id) functions with a shape close touploadFiles/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 itsResponsebody; pin this down in T2.