-
{emit('update:resume-title', event.target.value); titleChanged = (event.target.value !== originalTitle)}" />
+
{resumeStore.setCurrentResumeName(event.target.value); titleChanged = (event.target.value !== originalTitle)}" />
diff --git a/resources/js/components/resume/ResumePreviewPanel.vue b/resources/js/components/resume/ResumePreviewPanel.vue
index 3054921..d138116 100644
--- a/resources/js/components/resume/ResumePreviewPanel.vue
+++ b/resources/js/components/resume/ResumePreviewPanel.vue
@@ -1,25 +1,35 @@
-
+
+
diff --git a/resources/js/components/ui/sidebar/utils.ts b/resources/js/components/ui/sidebar/utils.ts
index 6edb140..9a352b4 100644
--- a/resources/js/components/ui/sidebar/utils.ts
+++ b/resources/js/components/ui/sidebar/utils.ts
@@ -3,7 +3,7 @@ import { createContext } from 'reka-ui'
export const SIDEBAR_COOKIE_NAME = 'sidebar_state'
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
-export const SIDEBAR_WIDTH = '16rem'
+export const SIDEBAR_WIDTH = '20rem'
export const SIDEBAR_WIDTH_MOBILE = '18rem'
export const SIDEBAR_WIDTH_ICON = '3rem'
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
diff --git a/resources/js/layouts/app/AppSidebarLayout.vue b/resources/js/layouts/app/AppSidebarLayout.vue
index 69b2db1..6431bd9 100644
--- a/resources/js/layouts/app/AppSidebarLayout.vue
+++ b/resources/js/layouts/app/AppSidebarLayout.vue
@@ -23,7 +23,7 @@ withDefaults(defineProps
(), {
-
+
diff --git a/resources/js/lib/pdfExport.ts b/resources/js/lib/pdfExport.ts
index af071d3..e6bb9ae 100644
--- a/resources/js/lib/pdfExport.ts
+++ b/resources/js/lib/pdfExport.ts
@@ -1,13 +1,33 @@
import { jsPDF } from "jspdf";
export function exportToPdf(element: HTMLElement, name: string) {
- const pdf = new jsPDF();
+ // const pdf = new jsPDF({orientation: 'portrait', unit: 'mm', format: 'a4', precision: 1, hotfixes: ["px_scaling"]});
+ // pdf.html(element, {
+ // callback: function (doc) {
+ // doc.save(name);
+ // },
+ // margin: [0, 0, 0, 0],
+ // autoPaging: 'text',
+ // // width: 210,
+ // // windowWidth: 2100,
+ // html2canvas: {
+ // allowTaint: true,
+ // letterRendering: true,
+ // logging: true,
+ // // width: 210,
+ // // windowWidth: 2100,
+ // // scale: 0.2,
+ // // scale: 210 / element.scrollWidth,
+ // },
+ // });
+
+ const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
pdf.html(element, {
callback: function (doc) {
doc.save(name);
},
width: 210,
- windowWidth: element.scrollWidth,
+ windowWidth: 1080,
});
}
diff --git a/resources/js/lib/pinia.ts b/resources/js/lib/pinia.ts
new file mode 100644
index 0000000..8a05980
--- /dev/null
+++ b/resources/js/lib/pinia.ts
@@ -0,0 +1,5 @@
+import { createPinia } from 'pinia'
+
+const pinia = createPinia()
+
+export default pinia
diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts
index 3512cae..82f8fb2 100644
--- a/resources/js/lib/utils.ts
+++ b/resources/js/lib/utils.ts
@@ -26,9 +26,13 @@ export async function httpApi(url: string, options?: RequestInit, useFetchOpt
...options,
},
useFetchOptions
- ).json();
+ );
- if (!error.value && data.value) {
+ if (data.value) {
+ data.value = JSON.parse(data.value as string) as T;
+ }
+
+ if (!error.value) {
return { data: data.value, error: null };
} else {
return { data: data.value, error: error.value };
diff --git a/resources/js/pages/resumes/Edit.vue b/resources/js/pages/resumes/Edit.vue
index 0070770..5a4631b 100644
--- a/resources/js/pages/resumes/Edit.vue
+++ b/resources/js/pages/resumes/Edit.vue
@@ -2,20 +2,25 @@
import AppLayout from '@/layouts/AppLayout.vue';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/vue3';
-import { Resume, ResumeComponentPlacement } from '@/types/resume';
+import { Resume } from '@/types/resume';
+import { useResumesStore } from '@/stores/resume';
import ResumeEditPanel from '@/components/resume/ResumeEditPanel.vue';
import ResumePreviewPanel from '@/components/resume/ResumePreviewPanel.vue';
-import { computed, ref, watch } from 'vue';
+import { useShowComponentSelectionStore } from '@/stores/ui';
const props = defineProps<{
resume: Resume
}>();
-const localResume = ref({ ...props.resume });
+const resumeStore = useResumesStore();
+resumeStore.setAndUpdateCurrentResumeWhenFetched(props.resume);
-const resumeTitle = computed(() => (localResume.value.name == '' ? 'Sans titre' : localResume.value.name) ?? 'Sans titre');
+const showComponentSelectionStore = useShowComponentSelectionStore();
+showComponentSelectionStore.setShowComponentSelection(false);
-const selectedComponent = ref(null);
+const resumeTitle = resumeStore.currentResumeName;
+
+resumeStore.setSelectedResumePlacement(-1);
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -24,21 +29,6 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
-function changeSelectedComponent(newComponent: ResumeComponentPlacement) {
- selectedComponent.value = newComponent;
- // Update the resume
- localResume.value.components_placements! = localResume.value.components_placements!.map(component =>
- component.id === newComponent.id ? newComponent : component
- );
-}
-
-function changeResumeTitle(newTitle: string) {
- console.log('Changing resume title to ', newTitle);
- localResume.value.name = newTitle;
-}
-
-console.debug('Resume : ', localResume.value);
-
@@ -46,8 +36,8 @@ console.debug('Resume : ', localResume.value);
-
-
+
+
diff --git a/resources/js/ssr.ts b/resources/js/ssr.ts
index 8fcdebe..fa1c474 100644
--- a/resources/js/ssr.ts
+++ b/resources/js/ssr.ts
@@ -4,6 +4,7 @@ import { renderToString } from 'vue/server-renderer';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createSSRApp, DefineComponent, h } from 'vue';
import { ZiggyVue } from 'ziggy-js';
+import pinia from './lib/pinia';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -16,6 +17,7 @@ createServer((page) =>
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
+ .use(pinia)
.use(ZiggyVue, {
...page.props.ziggy,
location: new URL(page.props.ziggy.location),
diff --git a/resources/js/stores/resume.ts b/resources/js/stores/resume.ts
new file mode 100644
index 0000000..495b60c
--- /dev/null
+++ b/resources/js/stores/resume.ts
@@ -0,0 +1,360 @@
+import { httpApi } from '@/lib/utils';
+import { Resume, ResumeComponentData, ResumeComponentPlacement } from '@/types/resume';
+import { set } from '@vueuse/core';
+import { defineStore } from 'pinia'
+import { computed, ComputedRef, watch } from 'vue';
+
+const useResumesStore = defineStore('resumes', {
+ state: () => ({
+ resumes: [] as Array,
+ resumesAreFetched: false as boolean,
+ currentResumeIndex: -1 as number,
+ selectedResumePlacementIndex: -1 as number,
+ }),
+ getters: {
+ hasResumes: (state) => computed(() => state.resumes.length > 0),
+
+ /* === CURRENT RESUME === */
+ currentResume(state): ComputedRef {
+ console.debug("Current resume index : ", state.currentResumeIndex);
+ return computed(() => state.currentResumeIndex >= 0 ? state.resumes[state.currentResumeIndex] : null);
+ },
+ hasCurrentResume: (state) => computed(() => state.currentResumeIndex >= 0 && state.currentResumeIndex < state.resumes.length),
+ currentResumeName() {
+ return computed(() => {
+ const resume = this.currentResume;
+ return resume ? (resume.value?.name || 'Sans titre') : 'Sans titre';
+ });
+ },
+
+ /* === SELECTED RESUME PLACEMENT === */
+ hasCurrentSelectedResumePlacement(): ComputedRef {
+ return computed(() => {
+ const currentResume = this.currentResume;
+ const selectedPlacementIndex = this.selectedResumePlacementIndex;
+ return currentResume !== null &&
+ selectedPlacementIndex >= 0 &&
+ selectedPlacementIndex < (currentResume.value?.components_placements?.length ?? 0);
+ });
+ },
+ currentSelectedResumePlacement(): ComputedRef {
+ return computed(() => {
+ if (!this.hasCurrentSelectedResumePlacement.value) return null;
+
+ const currentResume = this.currentResume;
+ const selectedPlacementIndex = this.selectedResumePlacementIndex;
+ return currentResume.value.components_placements[selectedPlacementIndex];
+ });
+ },
+ },
+ actions: {
+ async fetchResumes() {
+ try {
+ this.resumesAreFetched = false;
+ // get from cache
+ const cachedResumes = localStorage.resumes;
+ if (cachedResumes) {
+ this.setResumes(JSON.parse(cachedResumes));
+ }
+
+ const { data: resumes, error } = await httpApi(route("resumes.index"));
+ if (error || !resumes) {
+ console.error('Failed to fetch resumes:', error);
+ return;
+ }
+ this.setResumes(resumes);
+
+ // Store in cache
+ this.saveResumesToCache();
+
+ this.resumesAreFetched = true;
+ } catch (error) {
+ console.error('Failed to fetch resumes:', error);
+ }
+ },
+ async updateResumeToApi(resumeIndex: number) {
+ try {
+ if (!this.resumesAreFetched) {
+ console.warn("Resumes are not fetched yet. Cannot update to API.");
+ return;
+ }
+ if (resumeIndex < 0 || resumeIndex >= this.resumes.length) {
+ console.warn("Invalid resume index. Cannot update to API.");
+ return;
+ }
+
+ const resumeToUpdate = this.resumes[resumeIndex];
+ const { data: updatedResume, error } = await httpApi(route("resumes.update", { resume: resumeToUpdate.id }), {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify(resumeToUpdate),
+ });
+ if (error || !updatedResume) {
+ console.error('Failed to update resumes:', error);
+ return;
+ }
+ } catch (error) {
+ console.error('Failed to update resumes:', error);
+ }
+ },
+ async updateCurrentResumeToApi() {
+ await this.updateResumeToApi(this.currentResumeIndex);
+ },
+ async saveResumesToCache() {
+ localStorage.resumes = JSON.stringify(this.resumes);
+ },
+ setResumes(resumes: Array) {
+ this.resumes = resumes;
+ },
+ addResume(resume: Resume) {
+ this.resumes.push(resume);
+ this.currentResumeIndex = this.resumes.length - 1;
+ },
+ removeResume(index: number) {
+ if (index < 0 || index >= this.resumes.length) return;
+
+ this.resumes.splice(index, 1);
+ if (this.currentResumeIndex >= this.resumes.length) {
+ this.currentResumeIndex = this.resumes.length - 1;
+ }
+ },
+ removeResumeById(id: number) {
+ const index = this.resumes.findIndex(resume => resume.id === id);
+ if (index === -1) return;
+ this.removeResume(index);
+ },
+ setCurrentResume(index: number) {
+ if (index < 0 || index >= this.resumes.length) return;
+ this.currentResumeIndex = index;
+ },
+ setCurrentResumeById(id: number) {
+ const index = this.resumes.findIndex(resume => resume.id === id);
+ if (index === -1) return;
+ this.setCurrentResume(index);
+ },
+ setAndUpdateCurrentResume(resume: Resume) {
+ this.setCurrentResumeById(resume.id);
+ this.updateCurrentResume(resume);
+ },
+ setAndUpdateCurrentResumeWhenFetched(resume: Resume) {
+ watch(() => this.resumesAreFetched, (newVal) => {
+ if (newVal === true) {
+ this.setAndUpdateCurrentResume(resume);
+ }
+ });
+ },
+ updateCurrentResume(updatedResume: Resume) {
+ if (this.currentResumeIndex < 0 || this.currentResumeIndex >= this.resumes.length) return;
+ set(this.resumes, this.currentResumeIndex, updatedResume);
+ this.saveResumesToCache();
+ },
+ setCurrentResumeName(name: string) {
+ if (this.currentResumeIndex < 0 || this.currentResumeIndex >= this.resumes.length) return;
+ this.resumes[this.currentResumeIndex].name = name;
+ this.saveResumesToCache();
+ },
+
+ /* === RESUME COMPONENT PLACEMENTS === */
+ hasComponentsPlacements(): ComputedRef {
+ return computed(() => {
+ const currentResume = this.currentResume;
+ return currentResume !== null && (currentResume.value?.components_placements?.length ?? 0) > 0;
+ });
+ },
+ async updateCurrentResumePlacementsToApi(index: number) {
+ // resume-component-placements.update
+ try {
+ if (!this.hasCurrentResume.value) {
+ console.warn("No current resume selected. Cannot update placements to API.");
+ return;
+ }
+ const currentResume = this.currentResume.value;
+ if (!currentResume) {
+ console.warn("Current resume is null. Cannot update placements to API.");
+ return;
+ }
+
+ const componentPlacements = currentResume.components_placements;
+ if (!componentPlacements) {
+ console.warn("Current resume has no components placements. Cannot update placements to API.");
+ return;
+ }
+
+ const componentPlacement = componentPlacements[index];
+ if (!componentPlacement) {
+ console.warn("Invalid component placement index. Cannot update placements to API.");
+ return;
+ }
+ console.debug("Updating component placement:", componentPlacement);
+
+ const { data: updatedPlacement, error } = await httpApi(route("resume-component-placements.update", componentPlacement.id), {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify(componentPlacement),
+ });
+ if (error || !updatedPlacement) {
+ console.error('Failed to update resume placements:', error);
+ return;
+ }
+
+ // Update the local state with the updated placement
+ this.modifyResumePlacements(index, updatedPlacement);
+ this.saveResumesToCache();
+ } catch (error) {
+ console.error('Failed to update resume placements:', error);
+ }
+ },
+ setSelectedResumePlacement(index: number) {
+ const currentResume = this.currentResume;
+ if (!this.hasCurrentResume.value || !currentResume.value?.components_placements) {
+ this.selectedResumePlacementIndex = -1;
+ return;
+ }
+ if (index < 0 || index >= currentResume.value.components_placements.length) {
+ this.selectedResumePlacementIndex = -1;
+ return;
+ }
+ this.selectedResumePlacementIndex = index;
+ },
+ setSelectedResumePlacementById(id: number) {
+ if (!this.hasCurrentResume.value || !this.currentResume.value?.components_placements) return;
+ const resumePlacementIndex = this.currentResume.value?.components_placements.findIndex(placement => placement.id === id) ?? -1;
+ this.setSelectedResumePlacement(resumePlacementIndex);
+ },
+ modifyResumePlacements(index:number, modifiedPlacement: ResumeComponentPlacement) {
+ if (!this.hasCurrentResume.value || !this.currentResume.value?.components_placements) return;
+ const currentResume = this.currentResume.value;
+ set(currentResume.components_placements!, index, modifiedPlacement);
+ },
+ modifyCurrentSelectedResumePlacement(updatedPlacement: ResumeComponentPlacement) {
+ if (!this.hasCurrentSelectedResumePlacement.value) return;
+ this.modifyResumePlacements(this.selectedResumePlacementIndex, updatedPlacement);
+ },
+ swapComponentsPlacementsOrder(indexA: number, indexB: number) {
+ if (!this.hasCurrentResume.value || !this.currentResume.value?.components_placements) return;
+ const currentResume = this.currentResume.value;
+ const placements = currentResume.components_placements!;
+
+ if (indexA < 0 || indexA >= placements.length || indexB < 0 || indexB >= placements.length) return;
+
+ // Swap the order values
+ const tempOrder = placements[indexA].order;
+ placements[indexA].order = placements[indexB].order;
+ placements[indexB].order = tempOrder;
+
+ // Swap the placements in the array
+ [placements[indexA], placements[indexB]] = [placements[indexB], placements[indexA]];
+
+ // Update the components placements
+ this.updateCurrentResumePlacementsToApi(indexA);
+ this.updateCurrentResumePlacementsToApi(indexB);
+ },
+ async unlinkComponentPlacement(index: number) {
+ if (!this.hasComponentsPlacements) return;
+ const currentResume = this.currentResume.value!;
+ if (index < 0 || index >= currentResume.components_placements!.length) return;
+
+ // Call the 'resume-component-placements.unlink' API endpoint
+ // It will return the new component_data that need to be set to the placement
+ const placementToUnlink = currentResume.components_placements![index];
+ try {
+ const { data: newComponentData, error } = await httpApi(route("resume-component-placements.unlink", placementToUnlink.id), {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
+ 'Accept': 'application/json'
+ },
+ });
+ if (error || !newComponentData) {
+ console.error('Failed to unlink resume placement:', error);
+ return;
+ }
+
+ const updatedPlacement = { ...placementToUnlink, component_data: newComponentData };
+
+ // Update the local state with the updated placement
+ this.modifyResumePlacements(index, updatedPlacement);
+ this.saveResumesToCache();
+ } catch (error) {
+ console.error('Failed to unlink resume placement:', error);
+ }
+ },
+ async deleteComponentPlacement(index: number) {
+ if (!this.hasComponentsPlacements) return;
+ const currentResume = this.currentResume.value!;
+ if (index < 0 || index >= currentResume.components_placements!.length) return;
+
+ const placementToDelete = currentResume.components_placements![index];
+ try {
+ const { error } = await httpApi(route("resume-component-placements.destroy", placementToDelete.id), {
+ method: 'DELETE',
+ headers: {
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
+ 'Accept': 'application/json'
+ },
+ });
+ if (error) {
+ console.error('Failed to delete resume placement:', error);
+ return;
+ }
+
+ // Remove the placement from the local state
+ currentResume.components_placements!.splice(index, 1);
+ this.saveResumesToCache();
+
+ // Clear selected placement if it was the deleted one
+ if (this.selectedResumePlacementIndex === index) {
+ this.clearSelectedResumePlacement();
+ }
+ } catch (error) {
+ console.error('Failed to delete resume placement:', error);
+ }
+ },
+ clearSelectedResumePlacement() {
+ this.selectedResumePlacementIndex = -1;
+ },
+
+ },
+});
+
+// const useCurrentResumeStore = defineStore('currentResume', {
+// state: () => ({
+// currentResume: null as Resume | null,
+// }),
+// getters: {
+// hasCurrentResume: (state) => computed(() => state.currentResume !== null),
+// currentResumeName: (state) => computed(() => state.currentResume?.name ?? 'Sans titre'),
+// },
+// actions: {
+// setCurrentResume(resume: Resume) {
+// this.currentResume = resume;
+// },
+// setResumeName(name: string) {
+// if (this.currentResume) {
+// this.currentResume.name = name;
+// }
+// }
+// },
+// });
+
+// const useSelectedResumePlacementStore = defineStore('selectedResumePlacement', {
+// state: () => ({
+// selectedResumePlacement: null as ResumeComponentPlacement | null,
+// }),
+// actions: {
+// setSelectedResumePlacement(placement: ResumeComponentPlacement | null) {
+// this.selectedResumePlacement = placement;
+// },
+// },
+// });
+
+export { useResumesStore };
diff --git a/resources/js/stores/ui.ts b/resources/js/stores/ui.ts
new file mode 100644
index 0000000..53b7198
--- /dev/null
+++ b/resources/js/stores/ui.ts
@@ -0,0 +1,14 @@
+import { defineStore } from 'pinia'
+
+const useShowComponentSelectionStore = defineStore('showComponentSelection', {
+ state: () => ({
+ showComponentSelection: false as boolean,
+ }),
+ actions: {
+ setShowComponentSelection(show: boolean) {
+ this.showComponentSelection = show;
+ },
+ },
+});
+
+export { useShowComponentSelectionStore };
diff --git a/routes/api.php b/routes/api.php
index 97769b0..2bb22e4 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,10 +1,18 @@
middleware(['auth:sanctum', 'verified']);
+/* === RESUMES === */
Route::apiResource('resumes', ResumeController::class)->middleware(['auth:sanctum', 'verified']);
+/* === RESUME COMPONENT PLACEMENTS === */
+Route::apiResource('resume-component-placements', ResumeComponentPlacementController::class)->middleware(['auth:sanctum', 'verified']);
+Route::put('resume-component-placements/unlink/{placement}', [ResumeComponentPlacementController::class, 'unlink'])->name('resume-component-placements.unlink')->middleware(['auth:sanctum', 'verified']);
+
+/* === RESUME COMPONENTS === */
+Route::apiResource('resume-components', ResumeComponentController::class)->middleware(['auth:sanctum', 'verified']);
+
diff --git a/routes/web.php b/routes/web.php
index 5497763..72cfb1c 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -12,7 +12,10 @@ Route::get('dashboard', function () {
return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
+/* === RESUMES === */
Route::resource('resumes', ResumeController::class)->middleware(['auth', 'verified']);
+Route::post('resumes/{resume}/duplicate', [ResumeController::class, 'duplicate'])->middleware(['auth', 'verified'])->name('resumes.duplicate');
+
require __DIR__.'/settings.php';
require __DIR__.'/auth.php';