Bug fixes + Save status
This commit is contained in:
@@ -97,6 +97,7 @@ class ResumeComponentPlacementController extends Controller
|
||||
|
||||
$resumeComponentPlacement->push();
|
||||
$resumeComponentPlacement->refresh();
|
||||
$resumeComponentPlacement->load('componentData.component', 'componentData.inputData.componentInput.dataType');
|
||||
|
||||
return response()->json($resumeComponentPlacement);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class UpdateResumeComponentPlacementRequest extends FormRequest
|
||||
'component_data.component.id' => 'required|exists:resume_components,id',
|
||||
'component_data.input_data' => 'required|array',
|
||||
'component_data.input_data.*.id' => 'required|exists:resume_component_input_data,id',
|
||||
'component_data.input_data.*.value' => 'required|string',
|
||||
'component_data.input_data.*.value' => 'present',
|
||||
'component_data.input_data.*.component_input.id' => 'required|exists:resume_component_inputs,id',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"concurrently": "^9.0.1",
|
||||
"jspdf": "^3.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"lucide-vue-next": "^0.512.0",
|
||||
"pinia": "^3.0.3",
|
||||
"reka-ui": "^2.2.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -36,8 +36,8 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(vite@7.1.2(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1))
|
||||
lucide-vue-next:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(vue@3.5.18(typescript@5.9.2))
|
||||
specifier: ^0.512.0
|
||||
version: 0.512.0(vue@3.5.18(typescript@5.9.2))
|
||||
pinia:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3(typescript@5.9.2)(vue@3.5.18(typescript@5.9.2))
|
||||
@@ -526,6 +526,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.30':
|
||||
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1624,8 +1627,8 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lucide-vue-next@0.468.0:
|
||||
resolution: {integrity: sha512-quV/6T8YB1XK0VOEnebg3Byd8Rsan5/m95cvjnuHV4vcS3qEnLAybkrSh0hk3ppavx+V7R1PjNW+mGDvcBdz4A==}
|
||||
lucide-vue-next@0.512.0:
|
||||
resolution: {integrity: sha512-FNZSsb/0ieJ1b10B3HjGFNGPXOtVEeKqSeEW/m2Is5hy7CrkkBTrTMloTw9tQ3pitfMwvAHLg5MnhWJLULKr1w==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
@@ -2693,7 +2696,7 @@ snapshots:
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.30
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
@@ -2703,6 +2706,12 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -3836,7 +3845,7 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucide-vue-next@0.468.0(vue@3.5.18(typescript@5.9.2)):
|
||||
lucide-vue-next@0.512.0(vue@3.5.18(typescript@5.9.2)):
|
||||
dependencies:
|
||||
vue: 3.5.18(typescript@5.9.2)
|
||||
|
||||
|
||||
58
resources/js/components/SaveStatusIcon.vue
Normal file
58
resources/js/components/SaveStatusIcon.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { useResumesStore } from '@/stores/resume';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { CloudCheck, LoaderCircle, CircleAlert } from 'lucide-vue-next';
|
||||
|
||||
const resumeStore = useResumesStore();
|
||||
|
||||
const DISAPPEAR_DELAY = 5000;
|
||||
|
||||
const isSaving = computed(() => resumeStore.isSaving);
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
watch(() => resumeStore.savingError, (newError) => {
|
||||
error.value = newError;
|
||||
if (newError) {
|
||||
showSaved.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const showSaved = ref(false);
|
||||
let showSavedTimeout: ReturnType<typeof setTimeout>;
|
||||
watch(isSaving, (newIsSaving: boolean) => {
|
||||
if (!newIsSaving && !error.value) {
|
||||
if (showSavedTimeout) {
|
||||
clearTimeout(showSavedTimeout);
|
||||
}
|
||||
showSaved.value = true;
|
||||
showSavedTimeout = setTimeout(() => {
|
||||
showSaved.value = false;
|
||||
}, DISAPPEAR_DELAY);
|
||||
} else {
|
||||
showSaved.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between gap-2 items-center p-3 h-4">
|
||||
<p class="text-red-400">{{ error }}</p>
|
||||
<Transition mode="out-in" appear>
|
||||
<CircleAlert class="w-4 h-4 text-red-500" v-if="error"/>
|
||||
<LoaderCircle class="w-4 h-4 animate-spin" v-else-if="isSaving"/>
|
||||
<CloudCheck class="w-4 h-4" v-else-if="showSaved" title="Sauvegardé"/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -40,7 +40,7 @@ function removeResume(deletedResume: Resume) {
|
||||
if (resumeStore.resumes.length > 0) {
|
||||
window.location.href = route("resumes.edit", resumeStore.resumes[0], false);
|
||||
} else {
|
||||
window.location.href = route("resumes.create");
|
||||
window.location.href = route("dashboard");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,20 +28,21 @@ async function sendChangedData(newData: ResumeInputData[]) {
|
||||
clearTimeout(delayedSendTimeout);
|
||||
}
|
||||
delayedSendTimeout = setTimeout(async () => {
|
||||
const { data, error } = await httpApi(route('resume-component-placements.update', newSelectedComponent), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ...newSelectedComponent, _method: 'PUT' })
|
||||
}, {immediate: true});
|
||||
// Handle error
|
||||
if (error) {
|
||||
console.error('Failed to update component placement:', error, data);
|
||||
return;
|
||||
}
|
||||
// const { data, error } = await httpApi(route('resume-component-placements.update', newSelectedComponent), {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
// 'Accept': 'application/json'
|
||||
// },
|
||||
// body: JSON.stringify({ ...newSelectedComponent, _method: 'PUT' })
|
||||
// }, {immediate: true});
|
||||
// // Handle error
|
||||
// if (error) {
|
||||
// console.error('Failed to update component placement:', error, data);
|
||||
// return;
|
||||
// }
|
||||
resumeStore.modifyCurrentSelectedResumePlacementToApi(newSelectedComponent!);
|
||||
}, SEND_CHANGED_DATA_DELAY);
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import ComponentsSelectionList from './ComponentsSelectionList.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useResumesStore } from '@/stores/resume';
|
||||
import { useShowComponentSelectionStore } from '@/stores/ui';
|
||||
import SaveStatusIcon from '../SaveStatusIcon.vue';
|
||||
|
||||
const resumeStore = useResumesStore();
|
||||
const selectedComponent = resumeStore.currentSelectedResumePlacement;
|
||||
@@ -14,7 +15,8 @@ const showComponentSelection = computed<boolean>(() => showComponentSelectionSto
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-1 gap-4 rounded-xl p-4 overflow-x-auto max-w-[25%] bg-accent relative">
|
||||
<div class="flex flex-col h-full flex-1 gap-4 rounded-xl p-4 overflow-x-auto max-w-[25%] bg-accent relative">
|
||||
<SaveStatusIcon />
|
||||
<Transition mode="out-in" appear>
|
||||
<ResumeComponentEdit v-if="selectedComponent != null" :key="selectedComponent ? selectedComponent.id : 'form'" />
|
||||
<ComponentsSelectionList v-else-if="showComponentSelection" />
|
||||
|
||||
@@ -29,6 +29,23 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
function isSaving() {
|
||||
return resumeStore.isSaving;
|
||||
}
|
||||
// Confirmation when closing the tab if saving
|
||||
window.addEventListener("beforeunload", function (e) {
|
||||
if (!isSaving()) return;
|
||||
|
||||
console.log('Saving in progress, showing confirmation dialog.');
|
||||
|
||||
const confirmationMessage = 'Des changements sont toujours en cours de sauvegarde. '
|
||||
+ 'Si vous quittez avant de sauvegarder, vos modifications seront perdues.';
|
||||
|
||||
(e || window.event).returnValue = confirmationMessage; //Gecko + IE
|
||||
return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc.
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -10,6 +10,8 @@ const useResumesStore = defineStore('resumes', {
|
||||
resumesAreFetched: false as boolean,
|
||||
currentResumeIndex: -1 as number,
|
||||
selectedResumePlacementIndex: -1 as number,
|
||||
isSaving: false as boolean,
|
||||
savingError: null as string | null,
|
||||
}),
|
||||
getters: {
|
||||
hasResumes: (state) => computed(() => state.resumes.length > 0),
|
||||
@@ -48,9 +50,19 @@ const useResumesStore = defineStore('resumes', {
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setIsSaving(saving: boolean) {
|
||||
this.isSaving = saving;
|
||||
if (saving) {
|
||||
this.savingError = null;
|
||||
}
|
||||
},
|
||||
setSavingError(error: string | null) {
|
||||
this.setIsSaving(false);
|
||||
this.savingError = error;
|
||||
},
|
||||
async fetchResumes() {
|
||||
try {
|
||||
this.resumesAreFetched = false;
|
||||
this.setIsSaving(true);
|
||||
// get from cache
|
||||
const cachedResumes = localStorage.resumes;
|
||||
if (cachedResumes) {
|
||||
@@ -59,10 +71,14 @@ const useResumesStore = defineStore('resumes', {
|
||||
|
||||
const { data: resumes, error } = await httpApi<Resume[]>(route("resumes.index"));
|
||||
if (error || !resumes) {
|
||||
console.error('Failed to fetch resumes:', error);
|
||||
return;
|
||||
console.error('Failed to fetch resumes:', error);
|
||||
this.setSavingError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setIsSaving(false);
|
||||
this.setResumes(resumes);
|
||||
this.resumesAreFetched = false;
|
||||
|
||||
// Store in cache
|
||||
this.saveResumesToCache();
|
||||
@@ -70,6 +86,7 @@ const useResumesStore = defineStore('resumes', {
|
||||
this.resumesAreFetched = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch resumes:', error);
|
||||
this.setSavingError(error as string);
|
||||
}
|
||||
},
|
||||
async updateResumeToApi(resumeIndex: number) {
|
||||
@@ -83,6 +100,7 @@ const useResumesStore = defineStore('resumes', {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setIsSaving(true);
|
||||
const resumeToUpdate = this.resumes[resumeIndex];
|
||||
const { data: updatedResume, error } = await httpApi<null>(route("resumes.update", { resume: resumeToUpdate.id }), {
|
||||
method: 'PUT',
|
||||
@@ -93,12 +111,16 @@ const useResumesStore = defineStore('resumes', {
|
||||
},
|
||||
body: JSON.stringify(resumeToUpdate),
|
||||
});
|
||||
this.setIsSaving(false);
|
||||
this.setSavingError("Poor nigga detected !");
|
||||
if (error || !updatedResume) {
|
||||
console.error('Failed to update resumes:', error);
|
||||
this.setSavingError(error as string);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update resumes:', error);
|
||||
this.setSavingError(error as string);
|
||||
}
|
||||
},
|
||||
async updateCurrentResumeToApi() {
|
||||
@@ -108,7 +130,38 @@ const useResumesStore = defineStore('resumes', {
|
||||
localStorage.resumes = JSON.stringify(this.resumes);
|
||||
},
|
||||
setResumes(resumes: Array<Resume>) {
|
||||
this.resumes = resumes;
|
||||
// this.resumes = resumes;
|
||||
// for (const resume of resumes) {
|
||||
// const existingResume = this.resumes.find(r => r.id === resume.id);
|
||||
// if (existingResume) {
|
||||
// Object.assign(existingResume, resume);
|
||||
// if (this.currentResumeIndex >= 0 && this.resumes[this.currentResumeIndex].id === resume.id) {
|
||||
// // Update the current resume reference if it's the same resume
|
||||
// this.currentResumeIndex = this.resumes.indexOf(existingResume);
|
||||
// }
|
||||
// } else {
|
||||
// this.resumes.push(resume);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Do a copy of the resumes array
|
||||
// Set the resumes array to resumes
|
||||
// For each old resume, look for it in the new resumes array by id
|
||||
// If found, keep the reference of the old resume (to keep reactivity)
|
||||
// If not found, add the new resume to the array
|
||||
const currentResumeId = this.currentResumeIndex >= 0 && this.currentResumeIndex < this.resumes.length ? this.resumes[this.currentResumeIndex].id : null;
|
||||
// this.resumes = resumes;
|
||||
for (const oldResume of this.resumes) {
|
||||
const existingResume = resumes.find(r => r.id === oldResume.id);
|
||||
if (existingResume) {
|
||||
existingResume.components_placements = oldResume.components_placements;
|
||||
if (oldResume.id === currentResumeId) {
|
||||
this.currentResumeIndex = this.resumes.indexOf(oldResume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.resumes = resumes
|
||||
},
|
||||
addResume(resume: Resume) {
|
||||
this.resumes.push(resume);
|
||||
@@ -141,11 +194,15 @@ const useResumesStore = defineStore('resumes', {
|
||||
this.updateCurrentResume(resume);
|
||||
},
|
||||
setAndUpdateCurrentResumeWhenFetched(resume: Resume) {
|
||||
watch(() => this.resumesAreFetched, (newVal) => {
|
||||
if (newVal === true) {
|
||||
this.setAndUpdateCurrentResume(resume);
|
||||
}
|
||||
});
|
||||
if (this.resumesAreFetched) {
|
||||
this.setAndUpdateCurrentResume(resume);
|
||||
} else {
|
||||
watch(() => this.resumesAreFetched, (newVal) => {
|
||||
if (newVal === true) {
|
||||
this.setAndUpdateCurrentResume(resume);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
updateCurrentResume(updatedResume: Resume) {
|
||||
if (this.currentResumeIndex < 0 || this.currentResumeIndex >= this.resumes.length) return;
|
||||
@@ -191,6 +248,7 @@ const useResumesStore = defineStore('resumes', {
|
||||
}
|
||||
console.debug("Updating component placement:", componentPlacement);
|
||||
|
||||
this.setIsSaving(true);
|
||||
const { data: updatedPlacement, error } = await httpApi<ResumeComponentPlacement>(route("resume-component-placements.update", componentPlacement.id), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -200,8 +258,10 @@ const useResumesStore = defineStore('resumes', {
|
||||
},
|
||||
body: JSON.stringify(componentPlacement),
|
||||
});
|
||||
this.setIsSaving(false);
|
||||
if (error || !updatedPlacement) {
|
||||
console.error('Failed to update resume placements:', error);
|
||||
this.setSavingError(error as string);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,6 +270,7 @@ const useResumesStore = defineStore('resumes', {
|
||||
this.saveResumesToCache();
|
||||
} catch (error) {
|
||||
console.error('Failed to update resume placements:', error);
|
||||
this.setSavingError(error as string);
|
||||
}
|
||||
},
|
||||
setSelectedResumePlacement(index: number) {
|
||||
@@ -238,6 +299,10 @@ const useResumesStore = defineStore('resumes', {
|
||||
if (!this.hasCurrentSelectedResumePlacement.value) return;
|
||||
this.modifyResumePlacements(this.selectedResumePlacementIndex, updatedPlacement);
|
||||
},
|
||||
modifyCurrentSelectedResumePlacementToApi(updatedPlacement: ResumeComponentPlacement) {
|
||||
this.modifyCurrentSelectedResumePlacement(updatedPlacement);
|
||||
this.updateCurrentResumePlacementsToApi(this.selectedResumePlacementIndex);
|
||||
},
|
||||
swapComponentsPlacementsOrder(indexA: number, indexB: number) {
|
||||
if (!this.hasCurrentResume.value || !this.currentResume.value?.components_placements) return;
|
||||
const currentResume = this.currentResume.value;
|
||||
@@ -266,6 +331,7 @@ const useResumesStore = defineStore('resumes', {
|
||||
// It will return the new component_data that need to be set to the placement
|
||||
const placementToUnlink = currentResume.components_placements![index];
|
||||
try {
|
||||
this.setIsSaving(true);
|
||||
const { data: newComponentData, error } = await httpApi<ResumeComponentData>(route("resume-component-placements.unlink", placementToUnlink.id), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -274,8 +340,10 @@ const useResumesStore = defineStore('resumes', {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
});
|
||||
this.setIsSaving(false);
|
||||
if (error || !newComponentData) {
|
||||
console.error('Failed to unlink resume placement:', error);
|
||||
this.setSavingError(error as string);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,6 +354,7 @@ const useResumesStore = defineStore('resumes', {
|
||||
this.saveResumesToCache();
|
||||
} catch (error) {
|
||||
console.error('Failed to unlink resume placement:', error);
|
||||
this.setSavingError(error as string);
|
||||
}
|
||||
},
|
||||
async deleteComponentPlacement(index: number) {
|
||||
@@ -295,6 +364,7 @@ const useResumesStore = defineStore('resumes', {
|
||||
|
||||
const placementToDelete = currentResume.components_placements![index];
|
||||
try {
|
||||
this.setIsSaving(true);
|
||||
const { error } = await httpApi<null>(route("resume-component-placements.destroy", placementToDelete.id), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@@ -302,8 +372,10 @@ const useResumesStore = defineStore('resumes', {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
});
|
||||
this.setIsSaving(false);
|
||||
if (error) {
|
||||
console.error('Failed to delete resume placement:', error);
|
||||
this.setSavingError(error as string);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,6 +389,7 @@ const useResumesStore = defineStore('resumes', {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete resume placement:', error);
|
||||
this.setSavingError(error as string);
|
||||
}
|
||||
},
|
||||
clearSelectedResumePlacement() {
|
||||
|
||||
Reference in New Issue
Block a user