refactor: replace legacy image upload components with new MediaDropzone component across various admin forms
This commit is contained in:
parent
66bdd86b4f
commit
5986ec75a3
26
package-lock.json
generated
26
package-lock.json
generated
@ -25,6 +25,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"dropzone-vue": "^0.1.11",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"reka-ui": "^2.9.10",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@ -6176,6 +6177,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
|
||||
@ -6954,6 +6967,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dropzone-vue": {
|
||||
"version": "0.1.11",
|
||||
"resolved": "https://registry.npmjs.org/dropzone-vue/-/dropzone-vue-0.1.11.tgz",
|
||||
"integrity": "sha512-+XZfKbE2n8FpEoxSTYl9Us4z4qRcRrX24+/uMQxhh4mQuEV1OFAD7v3X2/10ulrRab6wYf5Nv/8pBYchc1ZQyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"core-js": "^3.9.1",
|
||||
"vue": "^3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"dropzone-vue": "^0.1.11",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"reka-ui": "^2.9.10",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
||||
633
resources/js/components/media/MediaDropzone.vue
Normal file
633
resources/js/components/media/MediaDropzone.vue
Normal file
@ -0,0 +1,633 @@
|
||||
<script setup lang="ts">
|
||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import { createMediaUploadState } from '@/types/media';
|
||||
import DropZone from 'dropzone-vue';
|
||||
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number;
|
||||
required?: boolean;
|
||||
errors?: string[];
|
||||
acceptedFiles?: string[];
|
||||
}>(),
|
||||
{
|
||||
maxFiles: 5,
|
||||
maxFileSize: 5_000_000,
|
||||
required: false,
|
||||
errors: () => [],
|
||||
acceptedFiles: () => ['image'],
|
||||
},
|
||||
);
|
||||
|
||||
const state = defineModel<MediaUploadState>({
|
||||
default: () => createMediaUploadState(),
|
||||
});
|
||||
|
||||
// ─── Dialog ──────────────────────────────────────────────────────────────────
|
||||
const previewOpen = ref(false);
|
||||
const previewUrl = ref<string | null>(null);
|
||||
|
||||
function openPreview(url: string) {
|
||||
previewUrl.value = url;
|
||||
previewOpen.value = true;
|
||||
}
|
||||
|
||||
// ─── New file tracking (dropzone) ────────────────────────────────────────────
|
||||
type FilePreview = { id: string; file: File; objectUrl: string };
|
||||
|
||||
const filePreviews = ref<FilePreview[]>([]);
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function onAddedFile(item: { id: string; file: File }) {
|
||||
state.value.newFiles.push(item.file);
|
||||
filePreviews.value.push({
|
||||
id: item.id,
|
||||
file: item.file,
|
||||
objectUrl: URL.createObjectURL(item.file),
|
||||
});
|
||||
}
|
||||
|
||||
function onRemovedFile(item: { id: string; file: File }) {
|
||||
const idx = state.value.newFiles.indexOf(item.file);
|
||||
if (idx !== -1) state.value.newFiles.splice(idx, 1);
|
||||
|
||||
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
|
||||
if (pi !== -1) {
|
||||
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
|
||||
filePreviews.value.splice(pi, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Unified normalized entries ───────────────────────────────────────────────
|
||||
type NormalizedEntry = {
|
||||
key: string;
|
||||
thumbSrc: string; // src for thumbnail img
|
||||
isImage: boolean; // false → show file icon
|
||||
previewSrc: string; // src for fullscreen preview
|
||||
name: string;
|
||||
sizeLabel: string;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
const dropzoneRef = ref<InstanceType<typeof DropZone> | null>(null);
|
||||
|
||||
const allPreviews = computed<NormalizedEntry[]>(() => {
|
||||
const existing: NormalizedEntry[] = state.value.existing.map((item) => ({
|
||||
key: `existing-${item.id}`,
|
||||
thumbSrc: item.url, // thumb_url terlalu kecil → pixelated; pakai url full
|
||||
isImage: true,
|
||||
previewSrc: item.url,
|
||||
name: item.url.split('/').pop() ?? `image-${item.id}`,
|
||||
sizeLabel: 'Tersimpan',
|
||||
onRemove: () => {
|
||||
const idx = state.value.existing.findIndex((e) => e.id === item.id);
|
||||
if (idx !== -1) {
|
||||
state.value.existing.splice(idx, 1);
|
||||
if (!state.value.removeIds.includes(item.id)) {
|
||||
state.value.removeIds.push(item.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const newFiles: NormalizedEntry[] = filePreviews.value.map((p) => ({
|
||||
key: `new-${p.id}`,
|
||||
thumbSrc: p.file.type.startsWith('image/') ? p.objectUrl : '',
|
||||
isImage: p.file.type.startsWith('image/'),
|
||||
previewSrc: p.objectUrl,
|
||||
name: p.file.name,
|
||||
sizeLabel: formatBytes(p.file.size),
|
||||
onRemove: () => dropzoneRef.value?.removeFile(p.id),
|
||||
}));
|
||||
|
||||
return [...existing, ...newFiles];
|
||||
});
|
||||
|
||||
// ─── Dropzone picker trigger ──────────────────────────────────────────────────
|
||||
function triggerDropzonePicker() {
|
||||
const form = dropzoneRef.value?.$el as HTMLFormElement | null;
|
||||
|
||||
// 1. Try inside the form
|
||||
let input =
|
||||
form?.querySelector<HTMLInputElement>('input[type="file"]') ?? null;
|
||||
|
||||
// 2. dropzone-vue appends hidden input to <body>
|
||||
if (!input) {
|
||||
const all = Array.from(
|
||||
document.body.querySelectorAll<HTMLInputElement>(
|
||||
'input[type="file"]',
|
||||
),
|
||||
);
|
||||
input =
|
||||
all.find(
|
||||
(el) =>
|
||||
el.style.display === 'none' || el.hasAttribute('hidden'),
|
||||
) ??
|
||||
all[0] ??
|
||||
null;
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.click();
|
||||
} else {
|
||||
form?.click();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-dropzone-root">
|
||||
<Field>
|
||||
<FieldLabel :for="id" :required="required">
|
||||
{{ label }}
|
||||
</FieldLabel>
|
||||
<FieldDescription v-if="description">
|
||||
{{ description }}
|
||||
</FieldDescription>
|
||||
<FieldDescription v-else-if="maxFiles > 1">
|
||||
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan
|
||||
thumbnail.
|
||||
</FieldDescription>
|
||||
|
||||
<div class="dropzone-wrapper">
|
||||
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
||||
<div
|
||||
:class="[
|
||||
'dz-form-host',
|
||||
{ 'dz-form-hidden': allPreviews.length > 0 },
|
||||
]"
|
||||
>
|
||||
<DropZone
|
||||
ref="dropzoneRef"
|
||||
:max-files="maxFiles"
|
||||
:max-file-size="maxFileSize"
|
||||
:accepted-files="acceptedFiles"
|
||||
:upload-on-drop="false"
|
||||
:clickable="true"
|
||||
dropzone-class-name="dz-box"
|
||||
dropzone-message-class-name="dz-message"
|
||||
@added-file="onAddedFile"
|
||||
@removed-file="onRemovedFile"
|
||||
>
|
||||
<template #message>
|
||||
<div class="dz-placeholder">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
|
||||
/>
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span class="dz-placeholder-primary"
|
||||
>Klik atau seret file ke sini</span
|
||||
>
|
||||
<span class="dz-placeholder-secondary">
|
||||
{{ acceptedFiles.join(', ') }} — Maks.
|
||||
{{ maxFiles }} file,
|
||||
{{ formatBytes(maxFileSize) }}/file
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DropZone>
|
||||
</div>
|
||||
|
||||
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
||||
<div
|
||||
v-if="allPreviews.length > 0"
|
||||
class="dz-overlay"
|
||||
@click="triggerDropzonePicker"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span class="dz-placeholder-primary"
|
||||
>Klik atau seret file ke sini</span
|
||||
>
|
||||
<span class="dz-placeholder-secondary">
|
||||
{{ acceptedFiles.join(', ') }} — Maks.
|
||||
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview grid (existing + new) -->
|
||||
<div v-if="allPreviews.length > 0" :class="['preview-grid', { 'preview-grid--single': maxFiles === 1 }]">
|
||||
<div
|
||||
v-for="entry in allPreviews"
|
||||
:key="entry.key"
|
||||
class="preview-card"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div
|
||||
class="preview-thumb"
|
||||
@click="openPreview(entry.previewSrc)"
|
||||
>
|
||||
<img
|
||||
v-if="entry.isImage"
|
||||
:src="entry.thumbSrc"
|
||||
:alt="entry.name"
|
||||
/>
|
||||
<div v-else class="preview-thumb-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info + Remove -->
|
||||
<div class="preview-footer">
|
||||
<div class="preview-info">
|
||||
<p class="preview-name" :title="entry.name">
|
||||
{{ entry.name }}
|
||||
</p>
|
||||
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="preview-remove"
|
||||
title="Hapus"
|
||||
@click="entry.onRemove()"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldError :errors="errors" />
|
||||
|
||||
<MediaPreviewDialog
|
||||
v-model:open="previewOpen"
|
||||
:url="previewUrl"
|
||||
:title="label"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* === Root containment: prevents MediaDropzone from expanding parent grid/flex === */
|
||||
.media-dropzone-root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Wrapper === */
|
||||
.dropzone-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Host div that wraps the dropzone <form> */
|
||||
.dz-form-host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* When previews exist: collapse form out of view (keep in DOM so dropzone JS stays alive) */
|
||||
.dz-form-hidden {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Dropzone box (the <form> element) === */
|
||||
/* Target both our custom class and dropzone-vue's own .dropzone class */
|
||||
.dropzone-wrapper :deep(.dz-box),
|
||||
.dropzone-wrapper :deep(.dropzone) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.dropzone-wrapper :deep(.dz-box) {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklch, var(--muted) 50%, transparent);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background-color 0.2s;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.dropzone-wrapper :deep(.dz-box:hover) {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in oklch, var(--muted) 80%, transparent);
|
||||
}
|
||||
|
||||
/* Hide built-in item previews */
|
||||
.dropzone-wrapper :deep(.dropzone__item--style),
|
||||
.dropzone-wrapper :deep(.dropzone__item),
|
||||
.dropzone-wrapper :deep(.dropzone__container),
|
||||
.dropzone-wrapper :deep(.dropzone__items) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Message slot wrapper */
|
||||
.dropzone-wrapper :deep(.dz-message) {
|
||||
flex: 1 !important;
|
||||
display: flex !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* === Placeholder inside slot === */
|
||||
.dz-placeholder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Overlay (shown when previews exist) === */
|
||||
.dz-overlay {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklch, var(--muted) 50%, transparent);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dz-overlay:hover {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in oklch, var(--muted) 80%, transparent);
|
||||
}
|
||||
|
||||
.dz-placeholder svg,
|
||||
.dz-overlay svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.dz-placeholder-primary {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dz-placeholder-secondary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---- Preview grid — multiple mode (default) ---- */
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 400px) {
|
||||
.preview-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 560px) {
|
||||
.preview-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.preview-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklch, var(--card) 90%, transparent);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.preview-card:hover {
|
||||
box-shadow: 0 2px 8px color-mix(in oklch, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Preview grid — single mode (maxFiles === 1) ---- */
|
||||
.preview-grid--single {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
/* Single: card is horizontal — thumbnail left, info right */
|
||||
.preview-grid--single .preview-card {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-grid--single .preview-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
aspect-ratio: unset;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.preview-grid--single .preview-footer {
|
||||
flex: 1;
|
||||
border-top: none;
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 0.4rem 0.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Thumbnail: square, full-width */
|
||||
.preview-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
background: var(--muted);
|
||||
cursor: zoom-in;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.preview-thumb:hover img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.preview-thumb-icon {
|
||||
color: var(--muted-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Footer row: info + remove button */
|
||||
.preview-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem 0.35rem 0.3rem 0.45rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Info */
|
||||
.preview-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.preview-size {
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Remove button */
|
||||
.preview-remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preview-remove:hover {
|
||||
background: color-mix(in oklch, var(--destructive) 15%, transparent);
|
||||
color: var(--destructive);
|
||||
}
|
||||
</style>
|
||||
@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
|
||||
import { Save } from '@lucide/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -26,8 +26,9 @@ import { CashTransactionType } from '@/constants/cash-transaction-type';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import type { CashTransactionFormData, CashTransactionListItem } from '@/types/cash';
|
||||
import { appendPhotosToFormData } from '@/types/media';
|
||||
import type { CashTransactionListItem } from '@/types/cash';
|
||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -46,29 +47,16 @@ const currentMode = computed(() => {
|
||||
});
|
||||
const isWithdrawal = computed(() => currentMode.value === CashTransactionType.WITHDRAWAL);
|
||||
|
||||
const existingPhotoId = ref<number | null>(null);
|
||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
const currentPhotoUrl = computed(() => props.transaction?.photos?.[0]?.url ?? null);
|
||||
|
||||
const form = useForm<CashTransactionFormData>({
|
||||
const form = useForm({
|
||||
amount: '',
|
||||
description: '',
|
||||
photos: [],
|
||||
remove_media_ids: [],
|
||||
});
|
||||
|
||||
const photoFile = computed<File | null>({
|
||||
get: () => form.photos[0] ?? null,
|
||||
set: (file) => {
|
||||
form.photos = file ? [file] : [];
|
||||
},
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
form.photos = [];
|
||||
form.remove_media_ids = [];
|
||||
existingPhotoId.value = null;
|
||||
photoState.value = createMediaUploadState();
|
||||
form.clearErrors();
|
||||
}
|
||||
|
||||
@ -81,7 +69,7 @@ function populateForm(transaction: CashTransactionListItem | null | undefined) {
|
||||
|
||||
form.amount = String(transaction.amount);
|
||||
form.description = transaction.description;
|
||||
existingPhotoId.value = transaction.photos?.[0]?.id ?? null;
|
||||
photoState.value = createMediaUploadState(transaction.photos ?? []);
|
||||
}
|
||||
|
||||
useFormDialog({
|
||||
@ -101,13 +89,7 @@ function buildFormData(forUpdate: boolean): FormData {
|
||||
formData.append('amount', parseRupiah(form.amount));
|
||||
formData.append('description', form.description);
|
||||
|
||||
const removeMediaIds = [...form.remove_media_ids];
|
||||
|
||||
if (form.photos.length > 0 && existingPhotoId.value !== null) {
|
||||
removeMediaIds.push(existingPhotoId.value);
|
||||
}
|
||||
|
||||
appendPhotosToFormData(formData, form.photos, removeMediaIds);
|
||||
appendRootPhotosToFormData(formData, photoState.value);
|
||||
|
||||
return formData;
|
||||
}
|
||||
@ -173,8 +155,8 @@ const placeholder = computed(() =>
|
||||
rows="3" :maxlength="FIELD_LIMITS.description" />
|
||||
<FieldError :errors="formErrors(form, 'description')" />
|
||||
</Field>
|
||||
<ImageUploadField id="cash-photos" v-model="photoFile" label="Foto Bukti" required
|
||||
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
|
||||
<MediaDropzone id="cash-photos" v-model="photoState" label="Foto Bukti" required :max-files="1"
|
||||
:errors="formErrors(form, 'photos')" />
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
|
||||
import { Save } from '@lucide/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -25,8 +25,9 @@ import { useFormDialog } from '@/composables/useFormDialog';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import type { ExpenseFormData, ExpenseListItem } from '@/types/expense';
|
||||
import { appendPhotosToFormData } from '@/types/media';
|
||||
import type { ExpenseListItem } from '@/types/expense';
|
||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
@ -36,29 +37,16 @@ const props = defineProps<{
|
||||
|
||||
const isEditing = computed(() => props.expense != null);
|
||||
|
||||
const existingPhotoId = ref<number | null>(null);
|
||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
const currentPhotoUrl = computed(() => props.expense?.photos?.[0]?.url ?? null);
|
||||
|
||||
const form = useForm<ExpenseFormData>({
|
||||
const form = useForm({
|
||||
amount: '',
|
||||
description: '',
|
||||
photos: [],
|
||||
remove_media_ids: [],
|
||||
});
|
||||
|
||||
const photoFile = computed<File | null>({
|
||||
get: () => form.photos[0] ?? null,
|
||||
set: (file) => {
|
||||
form.photos = file ? [file] : [];
|
||||
},
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
form.photos = [];
|
||||
form.remove_media_ids = [];
|
||||
existingPhotoId.value = null;
|
||||
photoState.value = createMediaUploadState();
|
||||
form.clearErrors();
|
||||
}
|
||||
|
||||
@ -71,7 +59,7 @@ function populateForm(expense: ExpenseListItem | null | undefined) {
|
||||
|
||||
form.amount = String(expense.amount);
|
||||
form.description = expense.description;
|
||||
existingPhotoId.value = expense.photos?.[0]?.id ?? null;
|
||||
photoState.value = createMediaUploadState(expense.photos ?? []);
|
||||
}
|
||||
|
||||
useFormDialog({
|
||||
@ -91,13 +79,7 @@ function buildFormData(forUpdate: boolean): FormData {
|
||||
formData.append('amount', parseRupiah(form.amount));
|
||||
formData.append('description', form.description);
|
||||
|
||||
const removeMediaIds = [...form.remove_media_ids];
|
||||
|
||||
if (form.photos.length > 0 && existingPhotoId.value !== null) {
|
||||
removeMediaIds.push(existingPhotoId.value);
|
||||
}
|
||||
|
||||
appendPhotosToFormData(formData, form.photos, removeMediaIds);
|
||||
appendRootPhotosToFormData(formData, photoState.value);
|
||||
|
||||
return formData;
|
||||
}
|
||||
@ -148,8 +130,8 @@ function submit() {
|
||||
:maxlength="FIELD_LIMITS.description" />
|
||||
<FieldError :errors="formErrors(form, 'description')" />
|
||||
</Field>
|
||||
<ImageUploadField id="expense-photos" v-model="photoFile" label="Foto Bukti" required
|
||||
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
|
||||
<MediaDropzone id="expense-photos" v-model="photoState" label="Foto Bukti" required
|
||||
:max-files="1" :errors="formErrors(form, 'photos')" />
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
|
||||
@ -3,11 +3,9 @@ import { useForm } from '@inertiajs/vue3';
|
||||
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
|
||||
import { DecimalInput } from '@/components/form/decimal-input';
|
||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -46,9 +44,11 @@ import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
||||
import { appendPhotosToFormData } from '@/types/media';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
|
||||
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
|
||||
const props = defineProps<{
|
||||
suppliers: SelectOption[];
|
||||
catalog: PurchaseCatalogItem[];
|
||||
@ -70,24 +70,13 @@ const isCreateMode = computed(() => props.method === 'post');
|
||||
const search = ref('');
|
||||
const cart = ref<PurchaseCartItem[]>([]);
|
||||
const cartDetailOpen = ref(false);
|
||||
const existingPhotoId = ref<number | null>(null);
|
||||
|
||||
const currentPhotoUrl = computed(() => props.initialData?.photos?.url ?? null);
|
||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
const form = useForm({
|
||||
supplier_id: '',
|
||||
discount: '',
|
||||
shipping_cost: '',
|
||||
notes: '',
|
||||
photos: [] as File[],
|
||||
remove_media_ids: [] as number[],
|
||||
});
|
||||
|
||||
const photoFile = computed<File | null>({
|
||||
get: () => form.photos[0] ?? null,
|
||||
set: (file) => {
|
||||
form.photos = file ? [file] : [];
|
||||
},
|
||||
});
|
||||
|
||||
function populateForm() {
|
||||
@ -99,9 +88,9 @@ function populateForm() {
|
||||
form.discount = props.initialData.discount;
|
||||
form.shipping_cost = props.initialData.shipping_cost ?? '0';
|
||||
form.notes = props.initialData.notes;
|
||||
form.photos = [];
|
||||
form.remove_media_ids = [];
|
||||
existingPhotoId.value = props.initialData.photos?.id ?? null;
|
||||
photoState.value = createMediaUploadState(
|
||||
props.initialData.photos ? [props.initialData.photos] : [],
|
||||
);
|
||||
cart.value = props.initialData.items.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
@ -311,13 +300,7 @@ function buildFormData(): FormData {
|
||||
});
|
||||
}
|
||||
|
||||
const removeMediaIds = [...form.remove_media_ids];
|
||||
|
||||
if (form.photos.length > 0 && existingPhotoId.value !== null) {
|
||||
removeMediaIds.push(existingPhotoId.value);
|
||||
}
|
||||
|
||||
appendPhotosToFormData(formData, form.photos, removeMediaIds);
|
||||
appendRootPhotosToFormData(formData, photoState.value);
|
||||
|
||||
return formData;
|
||||
}
|
||||
@ -382,12 +365,10 @@ function submit() {
|
||||
Belum ada varian
|
||||
</p>
|
||||
<div v-for="price in rawMaterial.prices" :key="price.id"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
|
||||
:class="[
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
|
||||
'cursor-pointer hover:bg-muted/30',
|
||||
getCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
|
||||
]"
|
||||
@click="!getCartItem(price.id) && addToCart(rawMaterial, price)">
|
||||
]" @click="!getCartItem(price.id) && addToCart(rawMaterial, price)">
|
||||
<PosCatalogVariantThumb :items="price.images" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
@ -427,12 +408,9 @@ function submit() {
|
||||
<ShoppingCart class="size-4" />
|
||||
Ringkasan Belanja
|
||||
</span>
|
||||
<button
|
||||
v-if="cart.length > 0"
|
||||
type="button"
|
||||
<button v-if="cart.length > 0" type="button"
|
||||
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
@click="cartDetailOpen = true"
|
||||
>
|
||||
@click="cartDetailOpen = true">
|
||||
Lihat Detail
|
||||
</button>
|
||||
</CardTitle>
|
||||
@ -551,8 +529,8 @@ function submit() {
|
||||
<FieldError :errors="formErrors(form, 'notes')" />
|
||||
</Field>
|
||||
|
||||
<ImageUploadField id="purchase-photos" v-model="photoFile" label="Bukti Transaksi"
|
||||
:current-url="currentPhotoUrl" :errors="formErrors(form, 'photos')" />
|
||||
<MediaDropzone id="purchase-photos" v-model="photoState" label="Bukti Transaksi"
|
||||
:max-files="1" :errors="formErrors(form, 'photos')" />
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
|
||||
<Save class="size-4" />
|
||||
@ -571,11 +549,7 @@ function submit() {
|
||||
<DialogTitle>Detail Keranjang</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="max-h-96 space-y-3 overflow-y-auto overscroll-y-contain scrollbar-thin">
|
||||
<div
|
||||
v-for="item in cart"
|
||||
:key="`detail-${item.raw_material_price_id}`"
|
||||
class="rounded-lg border p-3"
|
||||
>
|
||||
<div v-for="item in cart" :key="`detail-${item.raw_material_price_id}`" class="rounded-lg border p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
|
||||
|
||||
@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
|
||||
import { Plus, Save, Trash2 } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@ -223,8 +223,8 @@ function submit() {
|
||||
</FieldSet>
|
||||
|
||||
<div>
|
||||
<MultipleImageUploadField :id="`variant_images_${variant.client_id}`"
|
||||
v-model="variant.media" label="Foto Varian" :max-files="5" required
|
||||
<MediaDropzone :id="`variant_images_${variant.client_id}`" v-model="variant.media"
|
||||
label="Foto Varian" :max-files="5" required
|
||||
:errors="variantErrors(form, variant.client_id, 'images')" />
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
@ -4,8 +4,8 @@ import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { DecimalInput } from '@/components/form/decimal-input';
|
||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@ -252,8 +252,7 @@ function submit() {
|
||||
</FieldLabel>
|
||||
<RupiahInput :id="`shared_price_${prices[0].client_id}`" :model-value="prices[0].price"
|
||||
@update:model-value="setSharedPrice" />
|
||||
<FieldError
|
||||
:errors="formErrors(form, 'prices.0.price')" />
|
||||
<FieldError :errors="formErrors(form, 'prices.0.price')" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
@ -285,8 +284,7 @@ function submit() {
|
||||
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
|
||||
placeholder="Contoh: Premium / 40s" :maxlength="FIELD_LIMITS.variantName"
|
||||
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
|
||||
<FieldError
|
||||
:errors="priceErrors(form, price.client_id, 'variant')" />
|
||||
<FieldError :errors="priceErrors(form, price.client_id, 'variant')" />
|
||||
</Field>
|
||||
<Field v-if="method !== 'put'">
|
||||
<FieldLabel :for="`stock_${price.client_id}`" required>
|
||||
@ -294,8 +292,7 @@ function submit() {
|
||||
</FieldLabel>
|
||||
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
|
||||
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
|
||||
<FieldError
|
||||
:errors="priceErrors(form, price.client_id, 'stock')" />
|
||||
<FieldError :errors="priceErrors(form, price.client_id, 'stock')" />
|
||||
</Field>
|
||||
<Field v-if="!useSamePrice || prices.length === 1">
|
||||
<FieldLabel :for="`price_${price.client_id}`" required>
|
||||
@ -303,13 +300,12 @@ function submit() {
|
||||
</FieldLabel>
|
||||
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
|
||||
@update:model-value="setPriceValue(price.client_id, $event)" />
|
||||
<FieldError
|
||||
:errors="priceErrors(form, price.client_id, 'price')" />
|
||||
<FieldError :errors="priceErrors(form, price.client_id, 'price')" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
|
||||
<div class="mt-4">
|
||||
<MultipleImageUploadField :id="`price_images_${price.client_id}`" v-model="price.media"
|
||||
<MediaDropzone :id="`price_images_${price.client_id}`" v-model="price.media"
|
||||
label="Foto Varian" :max-files="5" required
|
||||
:errors="priceErrors(form, price.client_id, 'images')" />
|
||||
</div>
|
||||
|
||||
@ -3,8 +3,7 @@ import { router } from '@inertiajs/vue3';
|
||||
import { Save } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import ImageUploadField from '@/components/form/image-upload-field/ImageUploadField.vue';
|
||||
import MultipleImageUploadField from '@/components/form/image-upload-field/MultipleImageUploadField.vue';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { createMediaUploadState } from '@/types/media';
|
||||
@ -15,8 +14,8 @@ const props = defineProps<{
|
||||
data: HomepageSettingsData;
|
||||
}>();
|
||||
|
||||
const heroImage = ref<File | null>(null);
|
||||
const aboutImage = ref<File | null>(null);
|
||||
const heroImage = ref<MediaUploadState>(createMediaUploadState());
|
||||
const aboutImage = ref<MediaUploadState>(createMediaUploadState());
|
||||
const galleryState = ref<MediaUploadState>(
|
||||
createMediaUploadState(props.data.gallery_images),
|
||||
);
|
||||
@ -27,13 +26,13 @@ function submit() {
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (heroImage.value) {
|
||||
formData.append('hero_image', heroImage.value);
|
||||
}
|
||||
heroImage.value.newFiles.forEach((file) => {
|
||||
formData.append('hero_image', file);
|
||||
});
|
||||
|
||||
if (aboutImage.value) {
|
||||
formData.append('about_image', aboutImage.value);
|
||||
}
|
||||
aboutImage.value.newFiles.forEach((file) => {
|
||||
formData.append('about_image', file);
|
||||
});
|
||||
|
||||
galleryState.value.newFiles.forEach((file) => {
|
||||
formData.append('gallery_images[]', file);
|
||||
@ -46,8 +45,8 @@ function submit() {
|
||||
router.put('/admin/system/settings/homepage', formData, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
heroImage.value = null;
|
||||
aboutImage.value = null;
|
||||
heroImage.value = createMediaUploadState();
|
||||
aboutImage.value = createMediaUploadState();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan pengaturan homepage.');
|
||||
@ -65,27 +64,17 @@ function submit() {
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<ImageUploadField id="hero_image" label="Foto Hero"
|
||||
description="Foto utama yang ditampilkan di bagian hero homepage."
|
||||
:current-url="props.data.hero_image_url" v-model="heroImage"
|
||||
preview-class="aspect-[3/4] max-w-xs" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<MediaDropzone id="hero_image" label="Foto Hero" :max-files="1" v-model="heroImage" />
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<MultipleImageUploadField id="gallery_images" label="Koleksi Lookbook"
|
||||
description="Foto-foto yang ditampilkan di galeri lookbook." :max-files="10"
|
||||
v-model="galleryState" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MediaDropzone id="about_image" label="Foto Tentang Kami" :max-files="1"
|
||||
v-model="aboutImage" />
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<ImageUploadField id="about_image" label="Foto Tentang Kami"
|
||||
description="Foto yang ditampilkan di bagian tentang kami."
|
||||
:current-url="props.data.about_image_url" v-model="aboutImage"
|
||||
preview-class="aspect-video max-w-md" />
|
||||
<div class="sm:col-span-2">
|
||||
<MediaDropzone id="gallery_images" label="Koleksi Lookbook" :max-files="10"
|
||||
v-model="galleryState" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Save } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { ImageUploadField } from '@/components/form/image-upload-field';
|
||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { createMediaUploadState } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import type { SystemSettingsData } from '@/types/setting';
|
||||
|
||||
const props = defineProps<{
|
||||
@ -22,11 +25,12 @@ const form = useForm({
|
||||
email: props.data.email ?? '',
|
||||
phone: props.data.phone ?? '',
|
||||
address: props.data.address ?? '',
|
||||
logo: null as File | null,
|
||||
favicon: null as File | null,
|
||||
login_cover: null as File | null,
|
||||
});
|
||||
|
||||
const logoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
const faviconState = ref<MediaUploadState>(createMediaUploadState());
|
||||
const loginCoverState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
function buildFormData(): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
@ -36,17 +40,17 @@ function buildFormData(): FormData {
|
||||
formData.append('phone', form.phone);
|
||||
formData.append('address', form.address);
|
||||
|
||||
if (form.logo) {
|
||||
formData.append('logo', form.logo);
|
||||
}
|
||||
logoState.value.newFiles.forEach((file) => {
|
||||
formData.append('logo', file);
|
||||
});
|
||||
|
||||
if (form.favicon) {
|
||||
formData.append('favicon', form.favicon);
|
||||
}
|
||||
faviconState.value.newFiles.forEach((file) => {
|
||||
formData.append('favicon', file);
|
||||
});
|
||||
|
||||
if (form.login_cover) {
|
||||
formData.append('login_cover', form.login_cover);
|
||||
}
|
||||
loginCoverState.value.newFiles.forEach((file) => {
|
||||
formData.append('login_cover', file);
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
@ -114,17 +118,17 @@ function submit() {
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup class="grid gap-6 sm:grid-cols-3">
|
||||
<ImageUploadField id="logo" v-model="form.logo" label="Logo" required
|
||||
description="Disarankan PNG transparan, maks. 2 MB." :current-url="data.logo_url"
|
||||
<MediaDropzone id="logo" v-model="logoState" label="Logo" required
|
||||
description="Disarankan PNG transparan, maks. 2 MB." :max-files="1"
|
||||
:errors="formErrors(form, 'logo')" />
|
||||
|
||||
<ImageUploadField id="favicon" v-model="form.favicon" label="Favicon"
|
||||
description="PNG/SVG, maks. 1 MB." :current-url="data.favicon_url"
|
||||
<MediaDropzone id="favicon" v-model="faviconState" label="Favicon"
|
||||
description="PNG/SVG, maks. 1 MB." :max-files="1"
|
||||
:errors="formErrors(form, 'favicon')" />
|
||||
|
||||
<ImageUploadField id="login_cover" v-model="form.login_cover" label="Cover Login" required
|
||||
description="Gambar latar halaman login, maks. 5 MB."
|
||||
:current-url="data.login_cover_url" :errors="formErrors(form, 'login_cover')" />
|
||||
<MediaDropzone id="login_cover" v-model="loginCoverState" label="Cover Login" required
|
||||
description="Gambar latar halaman login, maks. 5 MB." :max-files="1"
|
||||
:errors="formErrors(form, 'login_cover')" />
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</CardContent>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user