How to upload a picture / send data from vue frontend to api

DISCLAIMER:

  1. A part of my code is AI generated since I really cant find any information for my problem
  2. I use Kirby 6.0.0-alpha.1 for this project

Hello,

I currently want to create a plugin for my newest Kirby project based on Kirby 6 as a field test for Vue 3 and Kirby.
I have problems uploading an image using a k-button, an input field and a JS based request.

For reference these are the relevant code parts:
The vue template:

<div class="field-card">
            <k-grid style="gap: var(--spacing-4);">
              <k-column width="1/2">
                <label class="k-label">{{ $t('embed_footer_control.icon_light') }}</label>
                <div class="upload-row">
                  <k-button icon="upload" @click="$refs.iconLight.click()">
                    {{ $t('embed_footer_control.choose_file') }}
                  </k-button>
                  <input ref="iconLight" type="file" accept="image/*" class="hidden-input"
                    @change="onIconUpload($event, 'light')" />
                  <span class="file-hint">
                    {{ settings.iconLightUrl ? $t('embed_footer_control.file_selected') :
                      $t('embed_footer_control.no_file') }}
                  </span>
                </div>
                <k-input type="text" :label="$t('embed_footer_control.public_url')" :value="settings.iconLightUrl"
                  readonly />
              </k-column>

              <k-column width="1/2">
                <label class="k-label">{{ $t('embed_footer_control.icon_dark') }}</label>
                <div class="upload-row">
                  <k-button icon="upload" @click="$refs.iconDark.click()">
                    {{ $t('embed_footer_control.choose_file') }}
                  </k-button>
                  <input ref="iconDark" type="file" accept="image/*" class="hidden-input"
                    @change="onIconUpload($event, 'dark')" />
                  <span class="file-hint">
                    {{ settings.iconDarkUrl ? $t('embed_footer_control.file_selected') :
                      $t('embed_footer_control.no_file') }}
                  </span>
                </div>
                <k-input type="text" :label="$t('embed_footer_control.public_url')" :value="settings.iconDarkUrl"
                  readonly />
              </k-column>
            </k-grid>
          </div>

This is the function that is used to upload the image:

async onIconUpload(event, mode) {
      const file = event?.target?.files?.[0];
      if (!file) return;
      try {
        const form = new FormData();
        form.append("file", file);
        form.append("mode", mode);
        const res = await this.$api.request("embed-footer-control/icon", {
          method: "POST",
          body: form,
        });
        const payload = await res?.json?.() ?? res?.data ?? res ?? {};
        const url = payload.url;
        const key = payload.key ?? (mode === "dark" ? "iconDarkUrl" : "iconLightUrl");
        if (!url) throw new Error("Upload succeeded, but no URL returned");

        const prev = this.iconUrlMap[mode];
        if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);

        this.settings[key] = url;
        this.initialSettings[key] = url;
        this.iconUrlMap[mode] = url;

        this.$panel?.notification?.success?.(
          this.$t("embed_footer_control.upload_success") || "Icon hochgeladen"
        );
      } catch (error) {
        console.error(error);
        this.$panel?.notification?.error?.(
          this.$t("embed_footer_control.upload_failed") || "Upload fehlgeschlagen"
        );
      }

and this is my plugin route

[
                'pattern' => 'embed-footer-control/icon',
                'method' => 'POST',
                'action' => function () {
                    $kirby = kirby();
                    error_log(json_encode($kirby->request()->get("mode")));
                    $mode = (string) ($kirby->request()->get('mode') ?? $kirby->request()->data('mode') ?? '');
                    if ($mode === '') {
                        $mode = 'light';
                    }
                    if (!in_array($mode, ['light', 'dark'], true)) {
                        throw new Exception('Invalid icon mode: ' . $mode);
                    }
                    $file = $kirby->request()->file('file');
                    if (!$file || empty($file['tmp_name'])) {
                        throw new Exception('No file uploaded');
                    }

                    $service = new EmbedService($kirby);
                    return $service->repository()->uploadIcon($file, $mode);
                },
            ],

My problem is that I cannot access the “mode” value. It is inside the data variable but I cannot access it.
I think I have an error inside my Vue Method (this part is completly AI generated since I cannot find any useful information on the kirby doc/cookbook and reference side - maybe I overlooked something?

This is the data Array returned by kirby()->request()->data() (printed as json!)

{
  "------geckoformboundary72d2665e32a21ee7ea293d0a3056c394\r\nContent-Disposition:_form-data;_name": "\"file\"; filename=\"logo_white.svg\"\nContent-Type: image/svg xml\n\n<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<!-- Created with Inkscape (http://www.inkscape.org/) -->\n\n<svg\n  width=\"1500\"\n  height=\"1500\"\n  viewBox=\"0 0 396.87499 396.875\"\n  version=\"1.1\"\n  id=\"svg1\"\n  xml:space=\"preserve\"\n  sodipodi:docname=\"logo_white.svg\"\n  inkscape:version=\"1.4.2 (ebf0e940, 2025-05-08)\"\n  xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\"\n  xmlns:sodipodi=\"http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  xmlns:svg=\"http://www.w3.org/2000/svg\">.\n------geckoformboundary72d2665e32a21ee7ea293d0a3056c394\nContent-Disposition: form-data; name=\"mod>\"\n\nlight\n------geckoformboundary72d2665e32a21ee7ea293d0a3056c394--\n"Disposition: form-data; name=\"mode\"\n\nlight\n------geckoformboundary72d2665e32a21ee7ea293d0a3056c394--\n"
}

It is clearly inside but I think I’m sending the request wrong…
Is there a page about the injected $api that explains how to send requests and how to handle them correctly?
And what is my error in the code above? :smiley:

Thank you for the help ^^

Okay - here is the solution if someone else needs it…

It turns out (just based on tests, I can’t find any documentation) - the helper function $api.post() only works with json content. Forms (and FormData) are not supported.

I am not sure if this is correct but this is my current explanation - feel free to correct me.
I helped myself using a base64 encoding on the client and sending it with json.

It now looks like this:

Vue:

try {
        const mimetype = file.type;
        const trusted_mimetype = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml']
        if (!trusted_mimetype.includes(mimetype)) {
          this.$panel?.notification?.error?.(
          this.$t("embed_footer_control.icon_upload_error_mimetype", { mimetype }));
          return;
      }
        // Datei als Base64 lesen
        const base64 = await new Promise((resolve, reject) => {
          const reader = new FileReader();
          reader.onload = () => resolve(reader.result);
          reader.onerror = reject;
          reader.readAsDataURL(file);
        });

        const res = await this.$api.post("/embed-footer-control/icon", {
          mode: mode,
          filename: file.name,
          mimetype: mimetype,
          data: base64
        });

        const payload = res?.data ?? res;
        const url = payload.url;
        const key = mode === "dark" ? "iconDarkUrl" : "iconLightUrl";

        if (!url) throw new Error("Upload succeeded, but no URL returned");

        this.settings[key] = url;
        this.initialSettings[key] = url;

        this.$panel?.notification?.success?.(
          this.$t("embed_footer_control.upload_success") || "Icon hochgeladen"
        );
      } catch (error) {
        console.error(error);
        this.$panel?.notification?.error?.(
          this.$t("embed_footer_control.upload_failed") || "Upload fehlgeschlagen"
        );
      }

Index.php of the plugin:

            [

                'pattern' => 'embed-footer-control/icon',
                'method' => 'POST',
                'action' => function () {
                    $data = kirby()->request()->data();
                    // We can now access the mode value as always...
                    $mode = strtolower(trim($data['mode'] ?? ''));
                    $base64 = $data['data'] ?? null;

                    if (!in_array($mode, ['light', 'dark'], true)) {
                        throw new Exception('Invalid icon mode');
                    }

                    if (!$base64) {
                        throw new Exception('No file data provided');
                    }

                    $service = new EmbedService(kirby());
                    return $service->repository()->uploadIcon($base64, $mode);
                },

and the repository controller - The controller takes the base64 encoded image / attachment and converts it back…

public function uploadIcon(string $base64Data, string $mode): array
    {
        if (!in_array($mode, ['light', 'dark'], true)) {
            throw new \InvalidArgumentException('Invalid icon mode');
        }

        // 1. Base64 Data extrahieren und dekodieren
        $mimetype = null;
        if (preg_match('/^data:([^;]+);base64,(.+)$/', $base64Data, $matches)) {
            $mimetype = $matches[1];
            $base64Clean = $matches[2];
        } else {
            throw new Exception('Error parsing the image!');
        }

        $fileContent = base64_decode($base64Clean);

        if ($fileContent === false) {
            throw new \Exception('Invalid base64 data');
        }

        // 2. Dateigröße prüfen
        $size = strlen($fileContent);
        $maxSize = 2 * 1024 * 1024; // 2MB
        if ($size > $maxSize) {
            throw new Exception(t("embed_footer_control.icon_upload_error_file_to_large"));
        }

        // 3. Extension aus MIME-Type bestimmen
        $mimeToExt = [
            'image/png' => 'png',
            'image/jpeg' => 'jpg',
            'image/jpg' => 'jpg',
            'image/webp' => 'webp',
            'image/svg+xml' => 'svg',
        ];
        //Check if the uploaded file has a valid extension / mimetype
        if (!array_key_exists($mimetype, $mimeToExt)) {
            throw new Exception(tt("embed_footer_control.icon_upload_error_mimetype", ["mimetype" => $mimetype]));
        }
        $ext = $mimeToExt[$mimetype];

        // 4. Ziel-Dateiname erstellen
        $newFilename = 'icon-' . $mode . '.' . $ext;

        // 5. Storage-Verzeichnis erstellen (falls nicht vorhanden)
        Dir::make($this->storageIconPath, true);

        // 6. Alte Icons des gleichen Modus löschen (optional)
        $oldFiles = glob($this->storageIconPath . '/icon-' . $mode . '.*');
        foreach ($oldFiles as $oldFile) {
            F::remove($oldFile);
        }

        // 7. Datei speichern
        $target = $this->storageIconPath . '/' . $newFilename;
        if (!F::write($target, $fileContent)) {
            throw new \Exception('Could not write file to storage');
        }

        // 8. Öffentliche URL generieren
        // Der Icon wird über die GET-Route bereitgestellt
        $url = $target;
        $fullUrl = kirby()->url() . "/" . $url;
        // 9. URL in Konfiguration speichern
        $key = $mode === 'light' ? 'iconLightUrl' : 'iconDarkUrl';
        $this->setUserConfigValue($key, $url);

        return [
            'url' => $fullUrl,
            'key' => $key,
            'filename' => $newFilename,
            'size' => $size,
            'mimetype' => $mimetype
        ];
    }

Maybe it will help someone…