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({ export default defineNuxtConfig({
app: { app: {
head: { head: {
title: 'Freekake Admin', title: 'Freekake Admin',
titleTemplate: '%s | Freekake Admin', titleTemplate: '%s | Freekake Admin',
htmlAttrs: { htmlAttrs: {
lang: 'en', lang: 'en',
}, },
meta: [ meta: [
{ charset: 'utf-8' }, { charset: 'utf-8' },
{ {
name: 'viewport', name: 'viewport',
content: 'width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no', content: 'width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no',
}, },
{ hid: 'description', name: 'description', content: '' }, { hid: 'description', name: 'description', content: '' },
{ name: 'format-detection', content: 'telephone=no' }, { name: 'format-detection', content: 'telephone=no' },
], ],
link: [ link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.png' }, { rel: 'icon', type: 'image/x-icon', href: '/favicon.png' },
{ {
rel: 'stylesheet', rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap', href: 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap',
}, },
], ],
}, },
}, },
css: ['~/assets/css/app.css'], css: ['~/assets/css/app.css'],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
modules: ['@pinia/nuxt', '@nuxtjs/i18n'],
i18n: { postcss: {
locales: [ plugins: {
{ code: 'da', file: 'da.json' }, tailwindcss: {},
{ code: 'de', file: 'de.json' }, autoprefixer: {},
{ 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: { modules: ['@pinia/nuxt', '@nuxtjs/i18n'],
public: {
apiBase: process.env.API_BASE_URL, 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> </NuxtLink>
</div> </div>
<div class="mb-5"> <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="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 }"> <div :class="{ 'has-error': $validate.form.name.$error, 'has-success': isSubmitted && !$validate.form.name.$error }">
<label for="name">Nama Karakter</label> <label for="name">Nama Karakter</label>
@ -33,7 +33,7 @@
</div> </div>
<div :class="{ 'has-error': $validate.form.type.$error, 'has-success': isSubmitted && !$validate.form.type.$error }"> <div :class="{ 'has-error': $validate.form.type.$error, 'has-success': isSubmitted && !$validate.form.type.$error }">
<label for="type">Jenis</label> <label for="type">Jenis</label>
<multiselect id="format" <multiselect id="type"
v-model="form.type" v-model="form.type"
:options="typeOptions" :options="typeOptions"
class="custom-multiselect" class="custom-multiselect"
@ -53,11 +53,11 @@
<label for="grade">Jenis Kelamin</label> <label for="grade">Jenis Kelamin</label>
<div class="grid grid-cols-1 gap-2"> <div class="grid grid-cols-1 gap-2">
<label class="inline-flex"> <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> <span>Laki-laki</span>
</label> </label>
<label class="inline-flex"> <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> <span>Perempuan</span>
</label> </label>
</div> </div>
@ -88,7 +88,7 @@
<span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span> <span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span>
</label> </label>
<template v-if="isSubmitted && $validate.form.featured_image.$error"> <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> </template>
<div class="custom-file-container__image-preview"></div> <div class="custom-file-container__image-preview"></div>
</div> </div>
@ -104,14 +104,14 @@
<span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span> <span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span>
</label> </label>
<template v-if="isSubmitted && $validate.form.featured_icon.$error"> <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> </template>
<div class="custom-file-container__image-preview"></div> <div class="custom-file-container__image-preview"></div>
</div> </div>
</div> </div>
<div class="flex items-center ltr:ml-auto mt-8"> <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="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"><icon-restore class="me-1" />Reset</button> <button type="reset" class="btn btn-dark !py-1 ml-1" @click="resetForm"><icon-restore class="me-1" />Reset</button>
</div> </div>
</form> </form>
</div> </div>
@ -122,22 +122,34 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useVuelidate } from '@vuelidate/core'; 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 Multiselect from '@suadelabs/vue3-multiselect';
import '@suadelabs/vue3-multiselect/dist/vue3-multiselect.css'; import '@suadelabs/vue3-multiselect/dist/vue3-multiselect.css';
import '@/assets/css/file-upload-preview.css'; import '@/assets/css/file-upload-preview.css';
useHead({ title: 'Edit Konten' }); useHead({ title: 'Edit Karakter' });
const isSubmitted = ref(false); 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 = { const rules = {
form: { form: {
name: { required }, name: { required },
description: { required }, description: { required },
sex: { required }, sex: { required },
type: { required }, type: { required },
featured_image: { required }, featured_image: { imageType, maxFileSize },
featured_icon: { required }, featured_icon: { imageType, maxFileSize},
} }
}; };
const router = useRouter(); const router = useRouter();
@ -145,27 +157,34 @@
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const typeOptions = [{ "value": "manusia", "label": "Manusia" }, { "value": "hewan", "label": "Hewan" } ] const typeOptions = [{ "value": "manusia", "label": "Manusia" }, { "value": "hewan", "label": "Hewan" } ]
const { data: form } = await useAsyncData('characters', const originalData = ref<any>(null);
() => $fetch(`${config.public.apiBase}character/characters/${route.params.id}`, {})
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 }); const $validate = useVuelidate(rules, { form });
onMounted(async () => { onMounted(async () => {
if (!form.value) return;
const fileupload = await import('file-upload-with-preview'); const fileupload = await import('file-upload-with-preview');
let FileUploadWithPreview = fileupload.default; let FileUploadWithPreview = fileupload.default;
// single image upload featuredImageUploader.value =new FileUploadWithPreview('featuredImage', {
new FileUploadWithPreview('featuredImage', {
images: { images: {
baseImage: form.value.featured_image || '/assets/images/file-preview.svg', baseImage: form.value.featured_image || '/assets/images/file-preview.svg',
backgroundImage: '', backgroundImage: '',
}, },
}); });
// single image upload featuredIconUploader.value = new FileUploadWithPreview('featuredIcon', {
new FileUploadWithPreview('featuredIcon', {
images: { images: {
baseImage: form.value.featured_icon || '/assets/images/file-preview.svg', baseImage: form.value.featured_icon || '/assets/images/file-preview.svg',
backgroundImage: '', backgroundImage: '',
@ -173,6 +192,10 @@
}); });
form.value.type = typeOptions.find(option => option.value === form.value.type); 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 () => { const submitForm = async () => {
@ -189,14 +212,14 @@
formData.append('type', form.value.type.value); formData.append('type', form.value.type.value);
formData.append('description', form.value.description); 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); 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); 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', method: 'PUT',
body: formData body: formData
}).then(() => { }).then(() => {
@ -230,10 +253,21 @@
const allowed = ['image/png', 'image/jpeg', 'image/jpg'] const allowed = ['image/png', 'image/jpeg', 'image/jpg']
if (!allowed.includes(file.type)) { if (!allowed.includes(file.type)) {
alert('Hanya PNG yang diizinkan') alert('Hanya PNG atau JPG yang diizinkan')
return return
} }
form.value.featured_icon = file form.value.featured_icon = file
} }
const resetForm = () => {
refresh()
isSubmitted.value = false;
$validate.value.form.$reset();
featuredImageUploader?.value?.clearPreviewPanel();
featuredIconUploader?.value?.clearPreviewPanel();
};
</script> </script>

View File

@ -50,7 +50,7 @@
</template> </template>
</div> </div>
<div :class="{ 'has-error': $validate.form.sex.$error, 'has-success': isSubmitted && !$validate.form.sex.$error }"> <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"> <div class="grid grid-cols-1 gap-2">
<label class="inline-flex"> <label class="inline-flex">
<input type="radio" v-model="form.sex" class="form-radio" value="1" /> <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> <span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span>
</label> </label>
<template v-if="isSubmitted && $validate.form.featured_image.$error"> <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> </template>
<div class="custom-file-container__image-preview"></div> <div class="custom-file-container__image-preview"></div>
</div> </div>
@ -104,7 +104,7 @@
<span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span> <span class="custom-file-container__custom-file__custom-file-control ltr:pr-20 rtl:pl-20"></span>
</label> </label>
<template v-if="isSubmitted && $validate.form.featured_icon.$error"> <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> </template>
<div class="custom-file-container__image-preview"></div> <div class="custom-file-container__image-preview"></div>
</div> </div>
@ -123,30 +123,40 @@
<script lang="ts" setup> <script lang="ts" setup>
import { reactive } from 'vue' import { reactive } from 'vue'
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators'; import { required, helpers } from '@vuelidate/validators';
import Multiselect from '@suadelabs/vue3-multiselect'; import Multiselect from '@suadelabs/vue3-multiselect';
import '@suadelabs/vue3-multiselect/dist/vue3-multiselect.css'; import '@suadelabs/vue3-multiselect/dist/vue3-multiselect.css';
import '@/assets/css/file-upload-preview.css'; import '@/assets/css/file-upload-preview.css';
useHead({ title: 'Tambah Konten' }); useHead({ title: 'Tambah Karakter' });
const form = ref({ const form = ref({
name: '', name: '',
description: '', description: '',
sex: null, sex: null,
type: '', type: null,
featured_image: '', featured_image: '',
featured_icon: '', featured_icon: '',
}); });
const isSubmitted = ref(false); 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 = { const rules = {
form: { form: {
name: { required }, name: { required },
description: { required }, description: { required },
sex: { required }, sex: { required },
type: { required }, type: { required },
featured_image: { required }, featured_image: { required, imageType, maxFileSize },
featured_icon: { required }, featured_icon: { required, imageType, maxFileSize },
} }
}; };
const $validate = useVuelidate(rules, { form }); const $validate = useVuelidate(rules, { form });
@ -200,7 +210,7 @@
} }
isLoading.value = true; isLoading.value = true;
await $fetch(`${config.public.apiBase}character/characters/`, { await $fetch(`${config.public.apiBase}/character/characters/`, {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(() => { }).then(() => {
@ -247,7 +257,7 @@
name: '', name: '',
description: '', description: '',
sex: null, sex: null,
type: '', type: null,
featured_image: '', featured_image: '',
featured_icon: '', featured_icon: '',
}; };

View File

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

View File

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