Ver Fonte

feat: ✨ feat (eventos) criado eventos na landingpage e web

foi criado o cadastro de eventos no layout adm, e a exibicao dos eventos na landingpage, de forma dinamica

fase:dev | origin:escopo
Gustavo Zanatta há 4 dias atrás
pai
commit
8bdfc1e9ed

+ 69 - 0
src/api/event.js

@@ -0,0 +1,69 @@
+import api from "src/api";
+import { createCachedApi } from "./cacheService";
+
+const cachedEvents = createCachedApi("company-events", 300);
+
+export const getEvents = async () => {
+  const { data } = await cachedEvents.get("/company-events");
+  return data.payload;
+};
+
+export const getEvent = async (id) => {
+  const { data } = await api.get(`/company-events/${id}`);
+  return data.payload;
+};
+
+export const createEvent = async (payload, config = {}) => {
+  const { data } = await api.post("/company-events", payload, config);
+  await cachedEvents.invalidateAll("/company-events");
+  return data.payload;
+};
+
+export const updateEvent = async (id, payload, config = {}) => {
+  const { data } = await api.put(`/company-events/${id}`, payload, config);
+  await cachedEvents.invalidateAll("/company-events");
+  return data.payload;
+};
+
+export const reorderEvents = async (items) => {
+  await api.put("/company-events/reorder", items);
+  await cachedEvents.invalidateAll("/company-events");
+};
+
+export const deleteEvent = async (id) => {
+  await api.delete(`/company-events/${id}`);
+  await cachedEvents.invalidateAll("/company-events");
+};
+
+export const uploadEventCover = async (id, file) => {
+  const form = new FormData();
+  form.append("image", file);
+  const { data } = await api.post(`/company-events/${id}/cover`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+    skipSuccessNotify: true,
+  });
+  await cachedEvents.invalidateAll("/company-events");
+  return data.payload;
+};
+
+export const removeEventCover = async (id) => {
+  const { data } = await api.delete(`/company-events/${id}/cover`);
+  await cachedEvents.invalidateAll("/company-events");
+  return data.payload;
+};
+
+export const uploadEventMedia = async (id, file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post(`/company-events/${id}/media`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+    skipSuccessNotify: true,
+  });
+  await cachedEvents.invalidateAll("/company-events");
+  return data.payload;
+};
+
+export const deleteEventMedia = async (id, mediaId) => {
+  await api.delete(`/company-events/${id}/media/${mediaId}`);
+  await cachedEvents.invalidateAll("/company-events");
+};

+ 31 - 8
src/components/defaults/DefaultFilePicker.vue

@@ -73,6 +73,7 @@
         ref="fileInputRef"
         v-model="internalFile"
         :accept="accept"
+        :multiple="multiple"
       />
     </q-field>
   </div>
@@ -102,6 +103,10 @@ const props = defineProps({
     type: String,
     default: "image",
   },
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
   initialImage: {
     type: String,
     default: null,
@@ -116,6 +121,8 @@ const props = defineProps({
   },
 });
 
+const emit = defineEmits(["files"]);
+
 const attrs = useAttrs();
 const fileInputRef = useTemplateRef("fileInputRef");
 
@@ -162,23 +169,30 @@ function handleDragLeave() {
   isDragging.value = false;
 }
 
-function handleDrop(event) {
-  event.preventDefault();
-  isDragging.value = false;
-  const file = event.dataTransfer?.files?.[0];
-  if (!file) return;
+function matchesAccept(file) {
   const acceptedMimes = props.accept
     .split(",")
     .map((m) => m.trim())
     .filter(Boolean);
 
-  const matches = acceptedMimes.some((mime) =>
+  return acceptedMimes.some((mime) =>
     mime.endsWith("/*") ? file.type.startsWith(mime.slice(0, -1)) : mime === file.type,
   );
+}
 
-  if (matches) {
-    internalFile.value = file;
+function handleDrop(event) {
+  event.preventDefault();
+  isDragging.value = false;
+
+  const dropped = Array.from(event.dataTransfer?.files ?? []).filter(matchesAccept);
+  if (!dropped.length) return;
+
+  if (props.multiple) {
+    emit("files", dropped);
+    return;
   }
+
+  internalFile.value = dropped[0];
 }
 
 function generateBase64(file) {
@@ -190,6 +204,15 @@ function generateBase64(file) {
 }
 
 watch(internalFile, (newFile) => {
+  if (props.multiple) {
+    const selected = Array.isArray(newFile) ? newFile : [];
+    if (selected.length) {
+      emit("files", selected);
+      internalFile.value = null;
+    }
+    return;
+  }
+
   if (objectUrl.value) {
     URL.revokeObjectURL(objectUrl.value);
     objectUrl.value = null;

+ 18 - 1
src/i18n/locales/en.json

@@ -467,7 +467,8 @@
       "stats": "Statistics",
       "about": "About Us",
       "contacts": "Contacts",
-      "benefits": "Benefits"
+      "benefits": "Benefits",
+      "events": "Events"
     },
     "about": {
       "vision": "Vision",
@@ -500,6 +501,22 @@
       "icon": "Icon",
       "description": "Description",
       "dialog_title": "Benefit"
+    },
+    "event": {
+      "title": "Title",
+      "title_singular": "Event",
+      "dialog_title": "Event",
+      "type": "Type",
+      "type_cultura": "Culture",
+      "type_esporte": "Sports",
+      "date": "Date",
+      "time": "Time",
+      "cover": "Main photo",
+      "photo": "Photo",
+      "remove_cover": "Remove main photo",
+      "gallery": "Event photos",
+      "uploading": "Uploading image...",
+      "uploading_count": "Uploading {done} of {total}..."
     }
   },
   "associado": {

+ 18 - 1
src/i18n/locales/es.json

@@ -468,7 +468,8 @@
       "stats": "Estadísticas",
       "about": "Sobre Nosotros",
       "contacts": "Contactos",
-      "benefits": "Beneficios"
+      "benefits": "Beneficios",
+      "events": "Eventos"
     },
     "about": {
       "vision": "Visión",
@@ -501,6 +502,22 @@
       "icon": "Ícono",
       "description": "Descripción",
       "dialog_title": "Beneficio"
+    },
+    "event": {
+      "title": "Título",
+      "title_singular": "Evento",
+      "dialog_title": "Evento",
+      "type": "Tipo",
+      "type_cultura": "Cultura",
+      "type_esporte": "Deporte",
+      "date": "Fecha",
+      "time": "Hora",
+      "cover": "Foto principal",
+      "photo": "Foto",
+      "remove_cover": "Eliminar foto principal",
+      "gallery": "Fotos del evento",
+      "uploading": "Subiendo imagen...",
+      "uploading_count": "Subiendo {done} de {total}..."
     }
   },
   "associado": {

+ 18 - 1
src/i18n/locales/pt.json

@@ -468,7 +468,8 @@
       "stats": "Estatísticas",
       "about": "Sobre Nós",
       "contacts": "Contatos",
-      "benefits": "Benefícios"
+      "benefits": "Benefícios",
+      "events": "Eventos"
     },
     "about": {
       "vision": "Visão",
@@ -501,6 +502,22 @@
       "icon": "Ícone",
       "description": "Descrição",
       "dialog_title": "Benefício"
+    },
+    "event": {
+      "title": "Título",
+      "title_singular": "Evento",
+      "dialog_title": "Evento",
+      "type": "Tipo",
+      "type_cultura": "Cultura",
+      "type_esporte": "Esporte",
+      "date": "Data",
+      "time": "Hora",
+      "cover": "Foto principal",
+      "photo": "Foto",
+      "remove_cover": "Remover foto principal",
+      "gallery": "Fotos do evento",
+      "uploading": "Enviando imagem...",
+      "uploading_count": "Enviando {done} de {total}..."
     }
   },
   "associado": {

+ 167 - 0
src/pages/company-settings/CompanySettingsPage.vue

@@ -266,6 +266,89 @@
         </q-card-section>
       </q-card>
 
+      <q-card flat bordered>
+        <q-card-section class="q-pb-none">
+          <div class="row items-center">
+            <div class="text-h6 text-weight-medium col">
+              {{ $t("company_settings.section.events") }}
+            </div>
+            <q-btn
+              dense
+              color="primary"
+              icon="mdi-plus"
+              padding="8px 8px"
+              @click="onAddEvent"
+            />
+          </div>
+        </q-card-section>
+        <q-card-section>
+          <div v-if="eventsLoading" class="flex flex-center q-py-lg">
+            <q-spinner color="primary" size="md" />
+          </div>
+
+          <template v-else>
+            <div class="row items-center q-px-sm q-py-xs text-caption text-grey-7 text-weight-medium q-mb-xs">
+              <div style="width: 28px" />
+              <div style="width: 76px">{{ $t("company_settings.event.photo") }}</div>
+              <div class="col">{{ $t("company_settings.event.title") }}</div>
+              <div style="width: 120px">{{ $t("company_settings.event.date") }}</div>
+              <div style="width: 84px">{{ $t("common.terms.actions") }}</div>
+            </div>
+
+            <draggable
+              v-model="events"
+              item-key="id"
+              handle=".drag-handle"
+              :animation="180"
+              ghost-class="benefit-row--ghost"
+              @end="onEventDragEnd"
+            >
+              <template #item="{ element }">
+                <div class="benefit-row row items-center q-px-sm q-mb-xs rounded-borders">
+                  <div class="drag-handle q-mr-xs">
+                    <q-icon name="mdi-drag-vertical" size="sm" color="grey-4"/>
+                  </div>
+                  <div style="width: 76px">
+                    <img
+                      v-if="element.cover_url"
+                      :src="element.cover_url"
+                      class="event-thumb"
+                    />
+                    <div v-else class="event-thumb event-thumb--empty flex flex-center">
+                      <q-icon name="mdi-image-off-outline" size="xs" color="grey-5" />
+                    </div>
+                  </div>
+                  <div class="col text-body2 q-pr-sm">{{ element.title }}</div>
+                  <div style="width: 120px" class="text-caption text-grey-7">
+                    {{ formatEventDate(element) }}
+                  </div>
+                  <div style="width: 84px" class="row no-wrap q-gutter-xs">
+                    <q-btn
+                      outline
+                      style="width: 36px"
+                      @click="onEditEvent(element)"
+                    >
+                      <q-icon name="mdi-file-edit-outline" />
+                    </q-btn>
+                    <q-btn
+                      outline
+                      style="width: 36px"
+                      @click="onDeleteEvent(element.id)"
+                    >
+                      <q-icon name="mdi-trash-can-outline" />
+                    </q-btn>
+                  </div>
+                </div>
+              </template>
+            </draggable>
+
+            <div v-if="!events.length" class="text-center text-grey q-py-md text-body2">
+              {{ $t("http.errors.no_records_found") }}
+            </div>
+          </template>
+        </q-card-section>
+      </q-card>
+
     </div>
   </div>
 </template>
@@ -284,6 +367,7 @@ import {
   deleteCompanyBenefit,
   reorderCompanyBenefits,
 } from "src/api/companySettings";
+import { getEvents, deleteEvent, reorderEvents } from "src/api/event";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
@@ -294,6 +378,10 @@ const AddEditBenefitDialog = defineAsyncComponent(
   () => import("./components/AddEditBenefitDialog.vue"),
 );
 
+const AddEditEventDialog = defineAsyncComponent(
+  () => import("./components/AddEditEventDialog.vue"),
+);
+
 const $q = useQuasar();
 const { t } = useI18n();
 const heroFormRef = useTemplateRef("heroFormRef");
@@ -306,6 +394,8 @@ const uploadingImage = ref(false);
 const removingImage = ref(false);
 const benefits = ref([]);
 const benefitsLoading = ref(true);
+const events = ref([]);
+const eventsLoading = ref(true);
 
 const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
   hero_title: "",
@@ -441,9 +531,73 @@ const onDeleteBenefit = (id) => {
   });
 };
 
+const loadEvents = async () => {
+  eventsLoading.value = true;
+  try {
+    events.value = await getEvents() ?? [];
+  } catch {
+    $q.notify({ type: "negative", message: t("common.ui.messages.error") });
+  } finally {
+    eventsLoading.value = false;
+  }
+};
+
+const formatEventDate = (event) => {
+  if (!event.date) return event.time || "—";
+
+  const formattedDate = event.date.split("-").reverse().join("/");
+  return event.time ? `${formattedDate} ${event.time}` : formattedDate;
+};
+
+const onEventDragEnd = async () => {
+  const reordered = events.value.map((e, index) => ({ id: e.id, order: index + 1 }));
+  try {
+    await reorderEvents(reordered);
+  } catch {
+    $q.notify({ type: "negative", message: t("common.ui.messages.error") });
+    await loadEvents();
+  }
+};
+
+const onAddEvent = () => {
+  $q.dialog({
+    component: AddEditEventDialog,
+    componentProps: {
+      title: () => `${t("common.actions.add")} ${t("company_settings.event.title_singular")}`,
+    },
+  }).onOk(() => loadEvents());
+};
+
+const onEditEvent = (row) => {
+  $q.dialog({
+    component: AddEditEventDialog,
+    componentProps: {
+      event: row,
+      title: () => `${t("common.actions.edit")} ${t("company_settings.event.title_singular")}`,
+    },
+  }).onDismiss(() => loadEvents());
+};
+
+const onDeleteEvent = (id) => {
+  $q.dialog({
+    title: t("common.ui.messages.confirm_action"),
+    message: t("common.ui.messages.are_you_sure_delete"),
+    ok: { color: "negative", label: t("common.actions.delete") },
+    cancel: { color: "primary", outline: true, label: t("common.actions.cancel") },
+  }).onOk(async () => {
+    try {
+      await deleteEvent(id);
+      await loadEvents();
+    } catch {
+      $q.notify({ type: "negative", message: t("common.ui.messages.error") });
+    }
+  });
+};
+
 onMounted(() => {
   loadSettings();
   loadBenefits();
+  loadEvents();
 });
 </script>
 
@@ -468,6 +622,19 @@ onMounted(() => {
   background: var(--q-primary);
 }
 
+.event-thumb {
+  width: 64px;
+  height: 40px;
+  border-radius: 4px;
+  object-fit: cover;
+  display: block;
+}
+
+.event-thumb--empty {
+  border: 1px dashed rgba(0, 0, 0, 0.18);
+  background-color: rgba(0, 0, 0, 0.02);
+}
+
 .drag-handle {
   touch-action: none;
   cursor: grab;

+ 379 - 0
src/pages/company-settings/components/AddEditEventDialog.vue

@@ -0,0 +1,379 @@
+<template>
+  <q-dialog ref="dialogRef" :persistent="loading" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 820px; max-width: 95vw">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="scroll q-pt-none" style="max-height: 70vh">
+          <div class="row q-col-gutter-sm">
+            <DefaultInput
+              v-model="form.title"
+              v-model:error="validationErrors.title"
+              :label="$t('company_settings.event.title')"
+              :rules="[inputRules.required]"
+              class="col-12 col-md-8"
+            />
+            <DefaultSelect
+              v-model="form.type"
+              v-model:error="validationErrors.type"
+              :options="typeOptions"
+              :label="$t('company_settings.event.type')"
+              emit-value
+              map-options
+              class="col-12 col-md-4"
+            />
+            <DefaultInputDatePicker
+              v-model:untreated-date="form.date"
+              v-model:error="validationErrors.date"
+              :label="$t('company_settings.event.date')"
+              class="col-12 col-md-4"
+            />
+            <DefaultInput
+              v-model="form.time"
+              v-model:error="validationErrors.time"
+              :label="$t('company_settings.event.time')"
+              mask="##:##"
+              placeholder="00:00"
+              clearable
+              class="col-12 col-md-4"
+            >
+              <template #append>
+                <q-icon name="mdi-clock-outline" class="cursor-pointer">
+                  <q-popup-proxy cover transition-show="scale" transition-hide="scale">
+                    <q-time v-model="form.time" format24h color="violet-normal">
+                      <div class="row items-center justify-end">
+                        <q-btn v-close-popup label="OK" color="violet-normal" flat />
+                      </div>
+                    </q-time>
+                  </q-popup-proxy>
+                </q-icon>
+              </template>
+            </DefaultInput>
+            <DefaultInput
+              v-model="form.description"
+              v-model:error="validationErrors.description"
+              :label="$t('common.terms.description')"
+              type="textarea"
+              :rows="4"
+              class="col-12"
+            />
+
+            <div class="col-12 q-mt-sm">
+              <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.event.cover") }}</div>
+              <div class="row items-start q-col-gutter-sm">
+                <div class="col-6 col-sm-4">
+                  <DefaultFilePicker
+                    v-model="coverFile"
+                    type="image"
+                    :label="null"
+                    :initial-image="coverPreview"
+                    @update:model-value="onCoverSelected"
+                  />
+                </div>
+                <div v-if="coverPreview || coverFile" class="col-auto">
+                  <q-btn
+                    flat
+                    dense
+                    color="negative"
+                    icon="mdi-delete"
+                    padding="8px 10px"
+                    :loading="removingCover"
+                    :title="$t('company_settings.event.remove_cover')"
+                    @click="onRemoveCover"
+                  />
+                </div>
+              </div>
+              <div v-if="uploadingCover" class="text-caption text-grey-6 q-mt-xs">
+                <q-spinner size="16px" class="q-mr-xs" />{{ $t("company_settings.event.uploading") }}
+              </div>
+            </div>
+
+            <div class="col-12 q-mt-sm">
+              <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.event.gallery") }}</div>
+              <div class="row q-col-gutter-sm">
+                <div class="col-6 col-sm-3">
+                  <DefaultFilePicker
+                    type="image"
+                    :label="null"
+                    multiple
+                    @files="onNewMediaSelected"
+                  />
+                </div>
+
+                <div v-for="media in existingMedia" :key="media.id" class="col-6 col-sm-3">
+                  <div class="media-thumb">
+                    <img :src="media.url" :alt="media.name" class="media-thumb-img" />
+                    <q-btn
+                      round unelevated dense size="xs"
+                      color="negative"
+                      icon="mdi-close"
+                      class="media-thumb-delete"
+                      :loading="deletingMediaId === media.id"
+                      @click="onDeleteMedia(media)"
+                    />
+                  </div>
+                </div>
+
+                <div v-for="(pending, index) in pendingMedia" :key="`pending-${index}`" class="col-6 col-sm-3">
+                  <div class="media-thumb">
+                    <img :src="pending.preview" :alt="pending.file.name" class="media-thumb-img" />
+                    <q-btn
+                      round unelevated dense size="xs"
+                      color="negative"
+                      icon="mdi-close"
+                      class="media-thumb-delete"
+                      @click="removePendingMedia(index)"
+                    />
+                  </div>
+                </div>
+              </div>
+
+              <div v-if="uploadingMedia" class="text-caption text-grey-6 q-mt-sm">
+                <q-spinner size="16px" class="q-mr-xs" />
+                {{ uploadTotal > 1
+                  ? $t("company_settings.event.uploading_count", { done: uploadDone + 1, total: uploadTotal })
+                  : $t("company_settings.event.uploading") }}
+              </div>
+            </div>
+          </div>
+        </q-card-section>
+        <q-card-actions>
+          <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            :disable="loading"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            color="primary"
+            :label="event ? $t('common.actions.save') : $t('common.actions.add')"
+            type="submit"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onUnmounted, useTemplateRef } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useInputRules } from "src/composables/useInputRules";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  createEvent,
+  updateEvent,
+  uploadEventCover,
+  removeEventCover,
+  uploadEventMedia,
+  deleteEventMedia,
+} from "src/api/event";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultFilePicker from "src/components/defaults/DefaultFilePicker.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { event, title } = defineProps({
+  event: {
+    type: Object,
+    default: null,
+  },
+  title: {
+    type: Function,
+    default: () => useI18n().t("company_settings.event.dialog_title"),
+  },
+});
+
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const formRef = useTemplateRef("formRef");
+
+const typeOptions = computed(() => [
+  { label: t("company_settings.event.type_cultura"), value: "cultura" },
+  { label: t("company_settings.event.type_esporte"), value: "esporte" },
+]);
+
+const { form, getUpdatedFields } = useFormUpdateTracker({
+  title: event?.title ?? "",
+  description: event?.description ?? "",
+  date: event?.date ?? null,
+  time: event?.time ?? null,
+  type: event?.type ?? "cultura",
+});
+
+const coverFile = ref(null);
+const coverPreview = ref(event?.cover_url ?? null);
+const uploadingCover = ref(false);
+const removingCover = ref(false);
+
+const existingMedia = ref([...(event?.media ?? [])]);
+const pendingMedia = ref([]);
+const uploadingMedia = ref(false);
+const uploadDone = ref(0);
+const uploadTotal = ref(0);
+const deletingMediaId = ref(null);
+
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
+  onSuccess: () => {
+    $q.notify({ type: "positive", message: t("common.ui.messages.saved") });
+    onDialogOK(true);
+  },
+  formRef,
+});
+
+const onCoverSelected = async (file) => {
+  if (!(file instanceof File)) return;
+
+  if (!event) {
+    coverPreview.value = null;
+    return;
+  }
+
+  uploadingCover.value = true;
+  try {
+    const updated = await uploadEventCover(event.id, file);
+    coverPreview.value = updated.cover_url;
+    coverFile.value = null;
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    uploadingCover.value = false;
+  }
+};
+
+const onRemoveCover = async () => {
+  if (!event || !coverPreview.value) {
+    coverFile.value = null;
+    coverPreview.value = null;
+    return;
+  }
+
+  removingCover.value = true;
+  try {
+    await removeEventCover(event.id);
+    coverFile.value = null;
+    coverPreview.value = null;
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    removingCover.value = false;
+  }
+};
+
+const onNewMediaSelected = async (files) => {
+  const selected = files.filter((file) => file instanceof File);
+  if (!selected.length) return;
+
+  if (!event) {
+    selected.forEach((file) => {
+      pendingMedia.value.push({ file, preview: URL.createObjectURL(file) });
+    });
+    return;
+  }
+
+  uploadTotal.value = selected.length;
+  uploadDone.value = 0;
+  uploadingMedia.value = true;
+
+  try {
+    for (const file of selected) {
+      const media = await uploadEventMedia(event.id, file);
+      existingMedia.value.push(media);
+      uploadDone.value += 1;
+    }
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    uploadingMedia.value = false;
+  }
+};
+
+const removePendingMedia = (index) => {
+  URL.revokeObjectURL(pendingMedia.value[index].preview);
+  pendingMedia.value.splice(index, 1);
+};
+
+const onDeleteMedia = async (media) => {
+  deletingMediaId.value = media.id;
+  try {
+    await deleteEventMedia(event.id, media.id);
+    existingMedia.value = existingMedia.value.filter((m) => m.id !== media.id);
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    deletingMediaId.value = null;
+  }
+};
+
+const onOKClick = async () => {
+  if (event) {
+    await submitForm(() =>
+      updateEvent(event.id, getUpdatedFields.value, { skipSuccessNotify: true }),
+    );
+    return;
+  }
+
+  await submitForm(async () => {
+    const created = await createEvent({ ...form }, { skipSuccessNotify: true });
+
+    const uploads = [
+      ...(coverFile.value instanceof File
+        ? [() => uploadEventCover(created.id, coverFile.value)]
+        : []),
+      ...pendingMedia.value.map((pending) => () => uploadEventMedia(created.id, pending.file)),
+    ];
+
+    uploadDone.value = 0;
+    uploadTotal.value = uploads.length;
+    uploadingMedia.value = uploads.length > 0;
+
+    try {
+      for (const upload of uploads) {
+        await upload();
+        uploadDone.value += 1;
+      }
+    } finally {
+      uploadingMedia.value = false;
+    }
+
+    return created;
+  });
+};
+
+onUnmounted(() => {
+  pendingMedia.value.forEach((pending) => URL.revokeObjectURL(pending.preview));
+});
+</script>
+
+<style scoped>
+.media-thumb {
+  position: relative;
+  height: 140px;
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid rgba(102, 29, 117, 0.2);
+  background: #f5f0f7;
+}
+
+.media-thumb-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.media-thumb-delete {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+}
+</style>