Models refactor + Basic functionnalities
Some checks failed
linter / quality (push) Failing after 3m25s
tests / ci (push) Failing after 12m2s

This commit is contained in:
2025-08-26 12:12:02 +02:00
parent 715d2a884a
commit 55a52086c1
49 changed files with 1074 additions and 269 deletions

View File

@@ -7,6 +7,6 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold">Laravel Starter Kit</span>
<span class="mb-0.5 truncate leading-tight font-semibold">CVAtron</span>
</div>
</template>

View File

@@ -7,14 +7,6 @@ import { type NavItem } from '@/types';
import { Link } from '@inertiajs/vue3';
import { LayoutGrid } from 'lucide-vue-next';
import AppLogo from './AppLogo.vue';
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: '/dashboard',
icon: LayoutGrid,
},
];
</script>
<template>
@@ -32,7 +24,7 @@ const mainNavItems: NavItem[] = [
</SidebarHeader>
<SidebarContent>
<NavMain :items="mainNavItems" />
<slot name="sidebar-content" />
</SidebarContent>
<SidebarFooter>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
import { httpApi } from '@/lib/utils';
import { type NavItem } from '@/types';
import { Link, usePage } from '@inertiajs/vue3';
import { ref, onMounted } from 'vue';
import { Resume } from '@/types/resume';
const items = ref<NavItem[]>([]);
const page = usePage();
onMounted(async () => {
try {
const { data: resumes, error } = await httpApi<Resume[]>(route("resumes.index"));
if (error || !resumes) {
console.error('Failed to fetch resumes:', error);
return;
}
items.value = resumes.map((resume: Resume) => ({
title: resume.name,
href: route("resumes.edit", resume),
}));
} catch (error) {
console.error('Failed to fetch resumes:', error);
}
});
</script>
<template>
<SidebarGroup class="px-2 py-0">
<SidebarGroupLabel>Mes CV</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton as-child :is-active="item.href === page.url" :tooltip="item.title">
<Link :href="item.href">
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ResumeComponentPlacement } from '@/types/resume';
import { defineAsyncComponent } from 'vue';
const props = defineProps<{
componentPlacement: ResumeComponentPlacement | null
}>();
const componentFile = defineAsyncComponent(
() => import(
/* @vite-ignore */
`./resumeComponents/${props.componentPlacement?.component_data?.component?.vue_component_name}`
)
);
</script>
<template>
<component :is="componentFile" :componentPlacement="props.componentPlacement" />
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { Resume, ResumeComponentPlacement, ResumeInputData } from '@/types/resume';
import Button from '../ui/button/Button.vue';
import { ChevronLeft } from 'lucide-vue-next';
import { SidebarGroup, SidebarGroupLabel } from '@/components/ui/sidebar';
import ResumeComponentEditForm from './ResumeComponentEditForm.vue';
import { httpApi } from '@/lib/utils';
const SEND_CHANGED_DATA_DELAY = 500;
const props = defineProps<{
resume: Resume
selectedComponent: ResumeComponentPlacement | null
}>();
const emit = defineEmits(['selected-component-change']);
let delayedSendTimeout: ReturnType<typeof setTimeout> | null = null
async function sendChangedData(newData: ResumeInputData[]) {
const newSelectedComponent = {
...props.selectedComponent,
component_data: {
...props.selectedComponent?.component_data,
input_data: newData
}
};
let data, error;
if (delayedSendTimeout) {
clearTimeout(delayedSendTimeout);
}
delayedSendTimeout = setTimeout(async () => {
const { data, error } = await httpApi(route('resume-component-placements.update', newSelectedComponent.id), {
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;
}
}, SEND_CHANGED_DATA_DELAY);
// Handle error
if (error) {
console.error('Failed to update component placement:', error, data);
return;
}
emit('selected-component-change', newSelectedComponent);
}
</script>
<template>
<div class="h-full w-full">
<Button @click="emit('selected-component-change', null)" variant="outline" size="icon" class="cursor-pointer"><ChevronLeft class="w-4 h-4" /></Button>
<SidebarGroup class="w-full p-0">
<SidebarGroupLabel>{{ props.selectedComponent?.component_data?.component?.name }}</SidebarGroupLabel>
<ResumeComponentEditForm
v-if="props.selectedComponent?.component_data?.input_data"
:data="props.selectedComponent?.component_data?.input_data!"
@data-changed="sendChangedData($event)"
/>
<p v-else class="text-destructive">No component input data : {{ props.selectedComponent?.component_data }}</p>
</SidebarGroup>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ResumeInputData } from '@/types/resume';
import ResumeComponentEditFormInput from './ResumeComponentEditFormInput.vue';
const props = defineProps<{
data: ResumeInputData[]
}>();
const emit = defineEmits(['data-changed']);
function handleDataChanged(updatedData: ResumeInputData) {
const index = props.data.findIndex(input => input.id === updatedData.id);
const dataCopy = [...props.data];
if (index !== -1) {
dataCopy[index] = updatedData;
emit('data-changed', dataCopy);
}
}
</script>
<template>
<form
@submit.prevent="emit('data-changed', props.data)"
class="w-full space-y-4"
>
<ResumeComponentEditFormInput v-for="input in props.data" :model="input" v-bind:key="input.id" @data-changed="handleDataChanged" />
</form>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ResumeInputData } from '@/types/resume';
import { defineAsyncComponent } from 'vue';
import Label from '../ui/label/Label.vue';
const props = defineProps<{
model: ResumeInputData
}>();
const componentFile = defineAsyncComponent(
() => import(
/* @vite-ignore */
`./resumeComponentsFormInput/${props.model.component_input.data_type.vue_component_name}`
)
);
const emit = defineEmits(['data-changed']);
</script>
<template>
<div class="w-full">
<Label>{{ props.model.component_input?.label }}</Label>
<component
class="file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground
dark:bg-primary/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-primary px-3 py-1 text-base shadow-xs
transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-primary file:text-sm
file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm
focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
:name="props.model.component_input?.name"
:is="componentFile"
:model="props.model"
@data-changed="emit('data-changed', $event)"
/>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { Resume, ResumeComponentPlacement } from '@/types/resume';
import { computed } from 'vue';
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
import { Link } from '@inertiajs/vue3';
const props = defineProps<{
resume: Resume;
selectedComponent: ResumeComponentPlacement | null;
}>();
const orderedComponentsPlacements = computed(() => {
console.log('Ordered Components Placements:', props.resume.components_placements);
return props.resume.components_placements ? [...props.resume.components_placements].sort((a, b) => a.order - b.order) : [];
});
const emit = defineEmits(['selected-component-change']);
</script>
<template>
<SidebarGroup class="w-full p-0">
<SidebarGroupLabel>Composants du CV</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="component in orderedComponentsPlacements" :key="component.id">
<SidebarMenuButton as-child class="cursor-pointer" :tooltip="component.component_data?.component?.name">
<div @click="emit('selected-component-change', component)">
<span>{{ component.component_data?.component?.name }}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { Resume, ResumeComponentPlacement } from '@/types/resume';
import ResumeComponentEdit from './ResumeComponentEdit.vue';
import ResumeComponentsList from './ResumeComponentsList.vue';
const props = defineProps<{
resume: Resume
selectedComponent: ResumeComponentPlacement | null
}>();
const emit = defineEmits(['selected-component-change']);
</script>
<template>
<div class="flex h-full flex-1 gap-4 rounded-xl p-4 overflow-x-auto max-w-[25%] bg-accent relative">
<Transition mode="out-in" appear>
<ResumeComponentEdit v-if="selectedComponent != null" :resume="props.resume" :selectedComponent="props.selectedComponent" @selected-component-change="emit('selected-component-change', $event)" :key="selectedComponent ? selectedComponent.id : 'form'" />
<ResumeComponentsList v-else :resume="props.resume" :selectedComponent="props.selectedComponent" @selected-component-change="emit('selected-component-change', $event)" />
</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>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { Resume, ResumeComponentPlacement } from '@/types/resume';
import ResumeComponent from './ResumeComponent.vue';
const props = defineProps<{
resume: Resume,
selectedComponent: ResumeComponentPlacement | null
}>();
const emit = defineEmits(['selected-component-change']);
</script>
<template>
<div class="flex-2 w-full p-6">
<div id="resume" class="aspect-[0.707317073] w-full max-w-[84.1cm] bg-white text-black">
<ResumeComponent v-for="componentPlacement in resume.components_placements" :key="componentPlacement.id" :componentPlacement="componentPlacement" @click="emit('selected-component-change', componentPlacement)" />
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { ResumeComponentPlacement } from '@/types/resume';
const props = defineProps<{
componentPlacement: ResumeComponentPlacement | null
}>();
</script>
<template>
<div class="w-full">
I'm an email component : {{ props.componentPlacement?.component_data?.input_data[0].value }}
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { ResumeComponentPlacement } from '@/types/resume';
const props = defineProps<{
componentPlacement: ResumeComponentPlacement | null
}>();
</script>
<template>
<div class="w-full">
I'm an name component : {{ props.componentPlacement?.component_data?.input_data[0].value }}
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { ResumeInputData } from '@/types/resume';
const props = defineProps<{
model: ResumeInputData
}>();
const emit = defineEmits(['data-changed']);
console.log('model value ', props.model);
</script>
<template>
<input
type="email"
:value="props.model.value"
@input="emit('data-changed', { ...props.model, value: $event.target.value })"
/>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { ResumeInputData } from '@/types/resume';
const props = defineProps<{
model: ResumeInputData
}>();
const emit = defineEmits(['data-changed']);
console.log('model value ', props.model);
</script>
<template>
<input
type="text"
:value="props.model.value"
@input="emit('data-changed', { ...props.model, value: $event.target.value })"
/>
</template>

View File

@@ -3,6 +3,7 @@ import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import SidebarResumeList from '@/components/SidebarResumeList.vue';
import type { BreadcrumbItemType } from '@/types';
interface Props {
@@ -16,7 +17,11 @@ withDefaults(defineProps<Props>(), {
<template>
<AppShell variant="sidebar">
<AppSidebar />
<AppSidebar>
<template #sidebar-content>
<SidebarResumeList />
</template>
</AppSidebar>
<AppContent variant="sidebar" class="overflow-x-hidden">
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot />

View File

@@ -1,6 +1,36 @@
import { useFetch, UseFetchOptions } from '@vueuse/core';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
let csrfInitialized = false
async function ensureCsrf() {
if (!csrfInitialized) {
await useFetch('/sanctum/csrf-cookie', {
credentials: 'include',
})
csrfInitialized = true
}
}
export async function httpApi<T>(url: string, options?: RequestInit, useFetchOptions?: UseFetchOptions): Promise<{data: T | null, error: any}> {
await ensureCsrf();
const { data, error } = await useFetch(url, {
credentials: 'include',
...options,
},
useFetchOptions
).json();
if (!error.value && data.value) {
return { data: data.value, error: null };
} else {
return { data: data.value, error: error.value };
}
}

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import AppLayout from '@/layouts/AppLayout.vue';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/vue3';
import { Resume, ResumeComponent, ResumeComponentPlacement } from '@/types/resume';
import ResumeEditPanel from '@/components/resume/ResumeEditPanel.vue';
import ResumePreviewPanel from '@/components/resume/ResumePreviewPanel.vue';
import { ref } from 'vue';
const props = defineProps<{
resume: Resume
}>();
const selectedComponent = ref<ResumeComponentPlacement | null>(null);
const breadcrumbs: BreadcrumbItem[] = [
{
title: props.resume?.name ?? 'Sans titre',
href: '/resumes/edit',
},
];
function changeSelectedComponent(newComponent: ResumeComponentPlacement) {
selectedComponent.value = newComponent;
// Update the resume
props.resume.components_placements! = props.resume.components_placements!.map(component =>
component.id === newComponent.id ? newComponent : component
);
}
console.log('Resume : ', props.resume);
</script>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 gap-4 rounded-xl p-4 overflow-x-auto">
<ResumeEditPanel :resume="props.resume" :selected-component="selectedComponent" @selected-component-change="changeSelectedComponent" />
<ResumePreviewPanel :resume="props.resume" :selected-component="selectedComponent" @selected-component-change="changeSelectedComponent" />
</div>
</AppLayout>
</template>

55
resources/js/types/resume.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
export type Resume = {
id: number;
name: string;
components_placements: ResumeComponentPlacement[] | null;
}
export type ResumeComponentPlacement = {
id: number;
resume_component_data_id: number;
resume_id: number;
order: number;
component_data: ResumeComponentData | null;
}
export type ResumeComponentData = {
id: number;
resume_component_id: number;
component: ResumeComponent | null;
input_data: ResumeInputData[] | null;
}
export type ResumeComponent = {
id: number;
name: string;
vue_component_name: string;
}
export type ResumeInputData = {
id: number;
resume_component_data_id: number;
resume_component_input_id: number;
value: any;
component_input: ResumeComponentInput | null;
}
export type ResumeComponentInput = {
id: number;
resume_component_data_type_id: number;
name: string;
label: string | null;
placeholder: string | null;
data_type: ResumeComponentDataType | null;
}
export type ResumeComponentDataType = {
id: number;
data_structure: string;
vue_component_name: string;
}