This commit is contained in:
Irwan Cahyono 2025-07-28 10:53:16 +07:00
parent 9293c4cde5
commit f49a7e9fc7
5 changed files with 158 additions and 105 deletions

View File

@ -1,74 +1,85 @@
export default defineNuxtConfig({
app: {
head: {
title: 'Freekake Admin',
titleTemplate: '%s | Freekake Admin',
htmlAttrs: {
lang: 'en',
},
meta: [
{ charset: 'utf-8' },
{
name: 'viewport',
content: 'width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no',
},
{ hid: 'description', name: 'description', content: '' },
{ name: 'format-detection', content: 'telephone=no' },
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.png' },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap',
},
],
},
},
app: {
head: {
title: 'Freekake Admin',
titleTemplate: '%s | Freekake Admin',
htmlAttrs: {
lang: 'en',
},
meta: [
{ charset: 'utf-8' },
{
name: 'viewport',
content: 'width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no',
},
{ hid: 'description', name: 'description', content: '' },
{ name: 'format-detection', content: 'telephone=no' },
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.png' },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap',
},
],
},
},
css: ['~/assets/css/app.css'],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
modules: ['@pinia/nuxt', '@nuxtjs/i18n'],
css: ['~/assets/css/app.css'],
i18n: {
locales: [
{ code: 'da', file: 'da.json' },
{ code: 'de', file: 'de.json' },
{ code: 'el', file: 'fr.json' },
{ code: 'en', file: 'en.json' },
{ code: 'es', file: 'es.json' },
{ code: 'fr', file: 'fr.json' },
{ code: 'hu', file: 'hu.json' },
{ code: 'it', file: 'it.json' },
{ code: 'ja', file: 'ja.json' },
{ code: 'pl', file: 'pl.json' },
{ code: 'pt', file: 'pt.json' },
{ code: 'ru', file: 'ru.json' },
{ code: 'sv', file: 'sv.json' },
{ code: 'tr', file: 'tr.json' },
{ code: 'zh', file: 'zh.json' },
{ code: 'ae', file: 'ae.json' },
],
lazy: true,
defaultLocale: 'en',
strategy: 'no_prefix',
langDir: 'locales/',
},
vite: {
optimizeDeps: { include: ['quill'] },
},
router: {
options: { linkExactActiveClass: 'active' },
},
compatibilityDate: '2024-09-21',
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
runtimeConfig: {
public: {
apiBase: process.env.API_BASE_URL,
},
modules: ['@pinia/nuxt', '@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'da', file: 'da.json' },
{ code: 'de', file: 'de.json' },
{ code: 'el', file: 'fr.json' },
{ code: 'en', file: 'en.json' },
{ code: 'es', file: 'es.json' },
{ code: 'fr', file: 'fr.json' },
{ code: 'hu', file: 'hu.json' },
{ code: 'it', file: 'it.json' },
{ code: 'ja', file: 'ja.json' },
{ code: 'pl', file: 'pl.json' },
{ code: 'pt', file: 'pt.json' },
{ code: 'ru', file: 'ru.json' },
{ code: 'sv', file: 'sv.json' },
{ code: 'tr', file: 'tr.json' },
{ code: 'zh', file: 'zh.json' },
{ code: 'ae', file: 'ae.json' },
],
lazy: true,
defaultLocale: 'en',
strategy: 'no_prefix',
langDir: 'locales/',
},
vite: {
optimizeDeps: { include: ['quill'] },
},
router: {
options: { linkExactActiveClass: 'active' },
},
compatibilityDate: '2024-09-21',
runtimeConfig: {
public: {
apiBase: process.env.API_BASE_URL,
},
},
devtools: {
timeline: {
enabled: true,
},
});
},
});

View File

@ -22,7 +22,7 @@
</NuxtLink>
</div>
<div class="mb-5">
<form class="space-y-5" @submit.prevent="submitForm()">
<form v-if="form" class="space-y-5" @submit.prevent="submitForm()">
<div class="grid grid-cols-1 gap-4 md:grid-cols-4 gap-2">
<div :class="{ 'has-error': $validate.form.name.$error, 'has-success': isSubmitted && !$validate.form.name.$error }">
<label for="name">Nama Karakter</label>
@ -33,7 +33,7 @@
</div>
<div :class="{ 'has-error': $validate.form.type.$error, 'has-success': isSubmitted && !$validate.form.type.$error }">
<label for="type">Jenis</label>
<multiselect id="format"
<multiselect id="type"
v-model="form.type"
:options="typeOptions"
class="custom-multiselect"
@ -53,11 +53,11 @@
<label for="grade">Jenis Kelamin</label>
<div class="grid grid-cols-1 gap-2">
<label class="inline-flex">
<input type="radio" v-model="form.sex" class="form-radio" value="true" />
<input type="radio" v-model="form.sex" class="form-radio" :value="true" />
<span>Laki-laki</span>
</label>
<label class="inline-flex">
<input type="radio" v-model="form.sex" class="form-radio" value="false" />
<input type="radio" v-model="form.sex" class="form-radio" :value="false" />
<span>Perempuan</span>
</label>
</div>
@ -88,7 +88,7 @@
<span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span>
</label>
<template v-if="isSubmitted && $validate.form.featured_image.$error">
<p class="text-danger mt-1">Gambar karakter harus diisi</p>
<p class="text-danger mt-1">Gambar karakter harus diupload dengan file berjenis PNG atau JPEG dengan ukuran maksimal 1 MB</p>
</template>
<div class="custom-file-container__image-preview"></div>
</div>
@ -104,14 +104,14 @@
<span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span>
</label>
<template v-if="isSubmitted && $validate.form.featured_icon.$error">
<p class="text-danger mt-1">Gambar ikon karakter harus diisi</p>
<p class="text-danger mt-1">Gambar ikon karakter harus diupload dengan file berjenis PNG atau JPEG dengan ukuran maksimal 1 MB</p>
</template>
<div class="custom-file-container__image-preview"></div>
</div>
</div>
<div class="flex items-center ltr:ml-auto mt-8">
<button type="submit" class="btn btn-success !py-1"><icon-save class="me-1" /> Simpan</button>
<button type="reset" class="btn btn-dark !py-1 ml-1"><icon-restore class="me-1" />Reset</button>
<button type="submit" class="btn btn-success !py-1" :disabled="isLoading"><icon-save class="me-1" /> Simpan</button>
<button type="reset" class="btn btn-dark !py-1 ml-1" @click="resetForm"><icon-restore class="me-1" />Reset</button>
</div>
</form>
</div>
@ -122,22 +122,34 @@
<script lang="ts" setup>
import { useVuelidate } from '@vuelidate/core';
import { integer, minValue,required } from '@vuelidate/validators';
import { required, helpers } from '@vuelidate/validators';
import Multiselect from '@suadelabs/vue3-multiselect';
import '@suadelabs/vue3-multiselect/dist/vue3-multiselect.css';
import '@/assets/css/file-upload-preview.css';
useHead({ title: 'Edit Konten' });
useHead({ title: 'Edit Karakter' });
const isSubmitted = ref(false);
const imageType = helpers.withMessage('Format gambar harus PNG atau JPG', (value) => {
if (!value) return true;
if (typeof value === 'string') return true;
return ['image/png', 'image/jpeg', 'image/jpg'].includes(value.type);
});
const maxFileSize = helpers.withMessage('Ukuran gambar tidak boleh lebih dari 1MB', (value) => {
if (!value) return true;
if (typeof value === 'string') return true;
return value.size <= 1048576; // 1MB dalam byte
});
const rules = {
form: {
name: { required },
description: { required },
sex: { required },
type: { required },
featured_image: { required },
featured_icon: { required },
featured_image: { imageType, maxFileSize },
featured_icon: { imageType, maxFileSize},
}
};
const router = useRouter();
@ -145,27 +157,34 @@
const config = useRuntimeConfig();
const typeOptions = [{ "value": "manusia", "label": "Manusia" }, { "value": "hewan", "label": "Hewan" } ]
const { data: form } = await useAsyncData('characters',
() => $fetch(`${config.public.apiBase}character/characters/${route.params.id}`, {})
const originalData = ref<any>(null);
const isLoading = ref(false);
const featuredImageUploader = ref(null);
const featuredIconUploader = ref(null);
const { data: form, refresh } = await useAsyncData('characters',
() => $fetch(`${config.public.apiBase}/character/characters/${route.params.id}/`, {}),
);
const $validate = useVuelidate(rules, { form });
onMounted(async () => {
if (!form.value) return;
const fileupload = await import('file-upload-with-preview');
let FileUploadWithPreview = fileupload.default;
// single image upload
new FileUploadWithPreview('featuredImage', {
featuredImageUploader.value =new FileUploadWithPreview('featuredImage', {
images: {
baseImage: form.value.featured_image || '/assets/images/file-preview.svg',
backgroundImage: '',
},
});
// single image upload
new FileUploadWithPreview('featuredIcon', {
featuredIconUploader.value = new FileUploadWithPreview('featuredIcon', {
images: {
baseImage: form.value.featured_icon || '/assets/images/file-preview.svg',
backgroundImage: '',
@ -173,6 +192,10 @@
});
form.value.type = typeOptions.find(option => option.value === form.value.type);
originalData.value = {
...form.value,
type: typeOptions.find(option => option.value === form.value.type),
};
});
const submitForm = async () => {
@ -189,14 +212,14 @@
formData.append('type', form.value.type.value);
formData.append('description', form.value.description);
if (form.value.featured_image) {
if (form.value.featured_image && typeof form.value.featured_image !== 'string') {
formData.append('featured_image', form.value.featured_image);
}
if (form.value.featured_icon) {
if (form.value.featured_icon && typeof form.value.featured_icon !== 'string') {
formData.append('featured_icon', form.value.featured_icon);
}
await $fetch(`${config.public.apiBase}character/characters/${route.params.id}/`, {
await $fetch(`${config.public.apiBase}/character/characters/${route.params.id}/`, {
method: 'PUT',
body: formData
}).then(() => {
@ -230,10 +253,21 @@
const allowed = ['image/png', 'image/jpeg', 'image/jpg']
if (!allowed.includes(file.type)) {
alert('Hanya PNG yang diizinkan')
alert('Hanya PNG atau JPG yang diizinkan')
return
}
form.value.featured_icon = file
}
const resetForm = () => {
refresh()
isSubmitted.value = false;
$validate.value.form.$reset();
featuredImageUploader?.value?.clearPreviewPanel();
featuredIconUploader?.value?.clearPreviewPanel();
};
</script>

View File

@ -50,7 +50,7 @@
</template>
</div>
<div :class="{ 'has-error': $validate.form.sex.$error, 'has-success': isSubmitted && !$validate.form.sex.$error }">
<label for="grade">Jenis Kelamin</label>
<label for="sex">Jenis Kelamin</label>
<div class="grid grid-cols-1 gap-2">
<label class="inline-flex">
<input type="radio" v-model="form.sex" class="form-radio" value="1" />
@ -88,7 +88,7 @@
<span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span>
</label>
<template v-if="isSubmitted && $validate.form.featured_image.$error">
<p class="text-danger mt-1">Gambar karakter harus diisi</p>
<p class="text-danger mt-1">Gambar karakter harus diupload dengan file berjenis PNG atau JPEG dengan ukuran maksimal 1 MB</p>
</template>
<div class="custom-file-container__image-preview"></div>
</div>
@ -104,7 +104,7 @@
<span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span>
</label>
<template v-if="isSubmitted && $validate.form.featured_icon.$error">
<p class="text-danger mt-1">Gambar ikon karakter harus diisi</p>
<p class="text-danger mt-1">Gambar ikon karakter harus diupload dengan file berjenis PNG atau JPEG dengan ukuran maksimal 1 MB</p>
</template>
<div class="custom-file-container__image-preview"></div>
</div>
@ -123,30 +123,40 @@
<script lang="ts" setup>
import { reactive } from 'vue'
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { required, helpers } from '@vuelidate/validators';
import Multiselect from '@suadelabs/vue3-multiselect';
import '@suadelabs/vue3-multiselect/dist/vue3-multiselect.css';
import '@/assets/css/file-upload-preview.css';
useHead({ title: 'Tambah Konten' });
useHead({ title: 'Tambah Karakter' });
const form = ref({
name: '',
description: '',
sex: null,
type: '',
type: null,
featured_image: '',
featured_icon: '',
});
const isSubmitted = ref(false);
const imageType = helpers.withMessage('Format gambar harus PNG atau JPG', (value) => {
if (!value) return false;
return ['image/png', 'image/jpeg', 'image/jpg'].includes(value.type);
});
const maxFileSize = helpers.withMessage('Ukuran gambar tidak boleh lebih dari 1MB', (value) => {
if (!value) return false;
return value.size <= 1048576; // 1MB dalam byte
});
const rules = {
form: {
name: { required },
description: { required },
sex: { required },
type: { required },
featured_image: { required },
featured_icon: { required },
featured_image: { required, imageType, maxFileSize },
featured_icon: { required, imageType, maxFileSize },
}
};
const $validate = useVuelidate(rules, { form });
@ -200,7 +210,7 @@
}
isLoading.value = true;
await $fetch(`${config.public.apiBase}character/characters/`, {
await $fetch(`${config.public.apiBase}/character/characters/`, {
method: 'POST',
body: formData
}).then(() => {
@ -247,7 +257,7 @@
name: '',
description: '',
sex: null,
type: '',
type: null,
featured_image: '',
featured_icon: '',
};

View File

@ -130,7 +130,7 @@
const params = reactive({
search: null,
current_page: 1,
pagesize: 10,
pagesize: 50,
sort_column: 'name',
sort_direction: 'asc',
});
@ -141,8 +141,6 @@
params: {
page: params.current_page,
page_size: params.pagesize,
ordering: (params.sort_direction == 'desc' ? '-' : '') + params.sort_column,
search: params.search
}})
}, {
watch: [params]

View File

@ -207,7 +207,7 @@
const config = useRuntimeConfig();
const { data: formats } = await useAsyncData('formats',
() => $fetch(`${config.public.apiBase}content/formats/`)
() => $fetch(`${config.public.apiBase}/content/formats/`)
);
const { data: form } = await useAsyncData('contents',