From cb242e59ba4a5072b892f1a1e207865884f4f86b Mon Sep 17 00:00:00 2001 From: Matthias Guillitte Date: Tue, 16 Sep 2025 16:30:37 +0200 Subject: [PATCH] Minimal Viable Product + Refactor to pinia store + Fix PDF export --- .../Controllers/ResumeComponentController.php | 50 +++ .../ResumeComponentPlacementController.php | 48 ++- app/Http/Controllers/ResumeController.php | 29 +- .../StoreResumeComponentPlacementRequest.php | 7 +- app/Models/ResumeComponent.php | 5 + app/Models/ResumeComponentData.php | 48 ++- app/Models/ResumeComponentInput.php | 5 - app/Models/ResumeComponentInputData.php | 6 + app/Models/ResumeComponentPlacement.php | 9 +- .../ResumeComponentPlacementPolicy.php | 5 + config/app.php | 3 + package.json | 1 + pnpm-lock.yaml | 46 +++ resources/css/app.css | 7 + resources/js/app.ts | 2 + .../js/components/SidebarListResumeItem.vue | 48 +++ .../SidebarListResumeItemToolButton.vue | 14 + resources/js/components/SidebarResumeList.vue | 61 ++- .../resume/ComponentsSelectionList.vue | 85 +++++ .../resume/ComponentsSelectionListItem.vue | 19 + .../components/resume/PrintResumeButton.vue | 8 +- .../components/resume/ResumeComponentEdit.vue | 31 +- .../resume/ResumeComponentsList.vue | 65 +++- .../resume/ResumeComponentsListItem.vue | 33 ++ .../ResumeComponentsListItemToolButton.vue | 14 + .../js/components/resume/ResumeEditPanel.vue | 19 +- .../js/components/resume/ResumeNameInput.vue | 30 +- .../components/resume/ResumePreviewPanel.vue | 30 +- resources/js/components/ui/sidebar/utils.ts | 2 +- resources/js/layouts/app/AppSidebarLayout.vue | 2 +- resources/js/lib/pdfExport.ts | 24 +- resources/js/lib/pinia.ts | 5 + resources/js/lib/utils.ts | 8 +- resources/js/pages/resumes/Edit.vue | 34 +- resources/js/ssr.ts | 2 + resources/js/stores/resume.ts | 360 ++++++++++++++++++ resources/js/stores/ui.ts | 14 + routes/api.php | 10 +- routes/web.php | 3 + 39 files changed, 1055 insertions(+), 137 deletions(-) create mode 100644 app/Http/Controllers/ResumeComponentController.php create mode 100644 resources/js/components/SidebarListResumeItem.vue create mode 100644 resources/js/components/SidebarListResumeItemToolButton.vue create mode 100644 resources/js/components/resume/ComponentsSelectionList.vue create mode 100644 resources/js/components/resume/ComponentsSelectionListItem.vue create mode 100644 resources/js/components/resume/ResumeComponentsListItem.vue create mode 100644 resources/js/components/resume/ResumeComponentsListItemToolButton.vue create mode 100644 resources/js/lib/pinia.ts create mode 100644 resources/js/stores/resume.ts create mode 100644 resources/js/stores/ui.ts diff --git a/app/Http/Controllers/ResumeComponentController.php b/app/Http/Controllers/ResumeComponentController.php new file mode 100644 index 0000000..7516f46 --- /dev/null +++ b/app/Http/Controllers/ResumeComponentController.php @@ -0,0 +1,50 @@ +user(); + $validated = $request->validated(); + // 1) User owns the linked resume + $resume = Resume::find($validated['resume_id']); + if (!$resume || !$user->can('update', $resume)) { + return response()->json(['error' => 'You do not own that resume'], 403); + } + // 2) the resume does not already have a component placement with that order + $existingPlacement = ResumeComponentPlacement::where('resume_id', $resume->id) + ->where('order', $validated['order']) + ->first(); + if ($existingPlacement) { + return response()->json(['error' => 'Component placement with that order already exists'], 422); + } - // + // === CREATION === + $newResumeComponentData = ResumeComponentData::createWithInputs(ResumeComponent::find($validated['component_id'])); + $componentPlacement = $resume->componentsPlacements()->create([ + 'resume_component_data_id' => $newResumeComponentData->id, + 'order' => $validated['order'], + ]); + return response()->json($componentPlacement->load('componentData.component', 'componentData.inputData.componentInput.dataType'), 201); } /** @@ -56,7 +79,6 @@ class ResumeComponentPlacementController extends Controller // Update component data $componentData = collect($data['component_data'])->except(['input_data', 'component'])->toArray(); $componentData['resume_component_id'] = $data['component_data']['component']['id'] ?? null; - $componentData['resume_component_placement_id'] = $resumeComponentPlacement->id; $resumeComponentPlacement->componentData()->update($componentData); // Update input data @@ -79,13 +101,27 @@ class ResumeComponentPlacementController extends Controller return response()->json($resumeComponentPlacement); } + public function unlink(ResumeComponentPlacement $placement) + { + // Load relations + $placement->load('componentData.component', 'componentData.inputData'); + + // Create a new ResumeComponentData without inputs + $newComponentData = ResumeComponentData::copyFrom($placement->componentData()->first()); + + // Update the placement to link to the new component data + $placement->resume_component_data_id = $newComponentData->id; + $placement->save(); + + return response()->json($newComponentData->load('component', 'inputData.componentInput.dataType')); + } + /** * Remove the specified resource from storage. */ public function destroy(ResumeComponentPlacement $resumeComponentPlacement) { - dd('hello'); - - // + $resumeComponentPlacement->delete(); + return response()->json(null, 204); } } diff --git a/app/Http/Controllers/ResumeController.php b/app/Http/Controllers/ResumeController.php index d78247f..6106bff 100644 --- a/app/Http/Controllers/ResumeController.php +++ b/app/Http/Controllers/ResumeController.php @@ -8,6 +8,7 @@ use App\Models\Resume; use Illuminate\Http\JsonResponse; use Inertia\Inertia; use Illuminate\Http\Request; +use \Illuminate\Database\Eloquent\ModelNotFoundException; class ResumeController extends Controller { @@ -31,11 +32,27 @@ class ResumeController extends Controller } $newResume = new Resume(); + $newResume->creator()->associate($request->user()); $newResume->save(); // Redirect to the edit page for the new resume return redirect()->route('resumes.edit', $newResume); } + public function duplicate(Resume $resume) + { + $newResume = $resume->replicate(); + $newResume->name = $resume->name . ' (Copy)'; + $newResume->save(); + + foreach ($resume->componentsPlacements as $placement) { + $newPlacement = $placement->replicate(); + $newPlacement->resume_id = $newResume->id; + $newPlacement->save(); + } + + return redirect()->route('resumes.edit', $newResume); + } + /** * Store a newly created resource in storage. */ @@ -55,8 +72,15 @@ class ResumeController extends Controller /** * Show the form for editing the specified resource. */ - public function edit(Request $request, Resume $resume) + public function edit(Request $request, int $resumeId) { + $resume = null; + try { + $resume = Resume::findOrFail($resumeId); + } catch (ModelNotFoundException $e) { + return redirect()->route(config('app.redirect_route')); + } + // Check if the user can edit the resume if ($request->user()->cannot('update', $resume)) { abort(403); @@ -80,6 +104,7 @@ class ResumeController extends Controller */ public function destroy(Resume $resume) { - // + $resume->delete(); + return response()->json(null, 204); } } diff --git a/app/Http/Requests/StoreResumeComponentPlacementRequest.php b/app/Http/Requests/StoreResumeComponentPlacementRequest.php index 21cde9a..d0965b9 100644 --- a/app/Http/Requests/StoreResumeComponentPlacementRequest.php +++ b/app/Http/Requests/StoreResumeComponentPlacementRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Models\ResumeComponentPlacement; use Illuminate\Foundation\Http\FormRequest; class StoreResumeComponentPlacementRequest extends FormRequest @@ -11,7 +12,7 @@ class StoreResumeComponentPlacementRequest extends FormRequest */ public function authorize(): bool { - return false; + return $this->user()->can('create', ResumeComponentPlacement::class); } /** @@ -22,7 +23,9 @@ class StoreResumeComponentPlacementRequest extends FormRequest public function rules(): array { return [ - // + 'component_id' => ['required', 'exists:resume_components,id'], + 'resume_id' => ['required', 'exists:resumes,id'], + 'order' => ['required', 'integer'], ]; } } diff --git a/app/Models/ResumeComponent.php b/app/Models/ResumeComponent.php index d82e777..d6da341 100644 --- a/app/Models/ResumeComponent.php +++ b/app/Models/ResumeComponent.php @@ -36,4 +36,9 @@ class ResumeComponent extends Model return $this->belongsToMany(ResumeComponentPlacement::class) ->using(ResumeComponentData::class); } + + public function inputs(): HasMany + { + return $this->hasMany(ResumeComponentInput::class, 'resume_component_id'); + } } diff --git a/app/Models/ResumeComponentData.php b/app/Models/ResumeComponentData.php index 06c86c8..fafac2f 100644 --- a/app/Models/ResumeComponentData.php +++ b/app/Models/ResumeComponentData.php @@ -2,24 +2,64 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\Pivot; -class ResumeComponentData extends Pivot +class ResumeComponentData extends Model { protected $table = 'resume_component_data'; - public function component() + protected $fillable = [ + 'resume_component_id', + ]; + + public function component(): BelongsTo { return $this->belongsTo(ResumeComponent::class, 'resume_component_id'); } - public function componentPlacements() + public function componentPlacements(): HasMany { return $this->hasMany(ResumeComponentPlacement::class); } - public function inputData() + public function inputData(): HasMany { return $this->hasMany(ResumeComponentInputData::class, 'resume_component_data_id'); } + + public static function createWithInputs(ResumeComponent $component): ResumeComponentData + { + $componentData = ResumeComponentData::create([ + 'resume_component_id' => $component->id, + ]); + + foreach ($component->inputs()->get() as $input) { + $componentData->inputData()->create([ + 'resume_component_data_id' => $componentData->id, + 'resume_component_input_id' => $input->id, + ]); + } + + return $componentData->refresh(); + } + + public static function copyFrom(ResumeComponentData $existingData): ResumeComponentData + { + $componentData = ResumeComponentData::create([ + 'resume_component_id' => $existingData->resume_component_id, + ]); + + foreach ($existingData->inputData()->get() as $inputDatum) { + $componentData->inputData()->create([ + 'resume_component_data_id' => $componentData->id, + 'resume_component_input_id' => $inputDatum->resume_component_input_id, + 'value' => $inputDatum->value, + ]); + } + + return $componentData->refresh(); + } } diff --git a/app/Models/ResumeComponentInput.php b/app/Models/ResumeComponentInput.php index be69a66..6f38a38 100644 --- a/app/Models/ResumeComponentInput.php +++ b/app/Models/ResumeComponentInput.php @@ -24,11 +24,6 @@ class ResumeComponentInput extends Pivot 'placeholder' ]; - public function resumes() - { - return ; - } - public function component(): BelongsTo { return $this->belongsTo(ResumeComponent::class); diff --git a/app/Models/ResumeComponentInputData.php b/app/Models/ResumeComponentInputData.php index 52fb330..d0bb2a5 100644 --- a/app/Models/ResumeComponentInputData.php +++ b/app/Models/ResumeComponentInputData.php @@ -8,6 +8,12 @@ class ResumeComponentInputData extends Pivot { protected $table = 'resume_component_input_data'; + protected $fillable = [ + 'resume_component_data_id', + 'resume_component_input_id', + 'value', + ]; + public function componentData() { diff --git a/app/Models/ResumeComponentPlacement.php b/app/Models/ResumeComponentPlacement.php index cb23422..a59521c 100644 --- a/app/Models/ResumeComponentPlacement.php +++ b/app/Models/ResumeComponentPlacement.php @@ -4,14 +4,21 @@ namespace App\Models; use App\Policies\ResumeComponentPlacementPolicy; use Illuminate\Database\Eloquent\Attributes\UsePolicy; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Pivot; #[UsePolicy(ResumeComponentPlacementPolicy::class)] -class ResumeComponentPlacement extends Pivot +class ResumeComponentPlacement extends Model { protected $table = 'resume_component_placements'; + protected $fillable = [ + 'resume_component_data_id', + 'resume_id', + 'order', + ]; + public function componentData(): BelongsTo { return $this->belongsTo(ResumeComponentData::class, 'resume_component_data_id'); diff --git a/app/Policies/ResumeComponentPlacementPolicy.php b/app/Policies/ResumeComponentPlacementPolicy.php index 7016feb..3d6fb1b 100644 --- a/app/Policies/ResumeComponentPlacementPolicy.php +++ b/app/Policies/ResumeComponentPlacementPolicy.php @@ -32,6 +32,11 @@ class ResumeComponentPlacementPolicy return true; } + public function createForResume(User $user, ResumeComponentPlacement $componentPlacement): bool + { + return $user->can('update', $componentPlacement->load('resume')->resume); + } + /** * Determine whether the user can update the model. */ diff --git a/config/app.php b/config/app.php index 324b513..8a935c0 100644 --- a/config/app.php +++ b/config/app.php @@ -123,4 +123,7 @@ return [ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + + 'redirect_route' => 'dashboard', + ]; diff --git a/package.json b/package.json index f710bc5..cb75a85 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "jspdf": "^3.0.1", "laravel-vite-plugin": "^2.0.0", "lucide-vue-next": "^0.468.0", + "pinia": "^3.0.3", "reka-ui": "^2.2.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01f5f7e..e65faad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: lucide-vue-next: specifier: ^0.468.0 version: 0.468.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)) reka-ui: specifier: ^2.2.0 version: 2.4.1(typescript@5.9.2)(vue@3.5.18(typescript@5.9.2)) @@ -881,14 +884,23 @@ packages: '@vue/compiler-vue2@2.7.16': resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + '@vue/devtools-api@7.7.7': + resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==} + '@vue/devtools-core@8.0.0': resolution: {integrity: sha512-5bPtF0jAFnaGs4C/4+3vGRR5U+cf6Y8UWK0nJflutEDGepHxl5L9JRaPdHQYCUgrzUaf4cY4waNBEEGXrfcs3A==} peerDependencies: vue: ^3.0.0 + '@vue/devtools-kit@7.7.7': + resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==} + '@vue/devtools-kit@8.0.0': resolution: {integrity: sha512-b11OeQODkE0bctdT0RhL684pEV2DPXJ80bjpywVCbFn1PxuL3bmMPDoJKjbMnnoWbrnUYXYzFfmMWBZAMhORkQ==} + '@vue/devtools-shared@7.7.7': + resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==} + '@vue/devtools-shared@8.0.0': resolution: {integrity: sha512-jrKnbjshQCiOAJanoeJjTU7WaCg0Dz2BUal6SaR6VM/P3hiFdX5Q6Pxl73ZMnrhCxNK9nAg5hvvRGqs+6dtU1g==} @@ -1762,6 +1774,15 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pinia@3.0.3: + resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -3040,6 +3061,10 @@ snapshots: de-indent: 1.0.2 he: 1.2.0 + '@vue/devtools-api@7.7.7': + dependencies: + '@vue/devtools-kit': 7.7.7 + '@vue/devtools-core@8.0.0(vite@7.1.2(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1))(vue@3.5.18(typescript@5.9.2))': dependencies: '@vue/devtools-kit': 8.0.0 @@ -3052,6 +3077,16 @@ snapshots: transitivePeerDependencies: - vite + '@vue/devtools-kit@7.7.7': + dependencies: + '@vue/devtools-shared': 7.7.7 + birpc: 2.5.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.2 + '@vue/devtools-kit@8.0.0': dependencies: '@vue/devtools-shared': 8.0.0 @@ -3062,6 +3097,10 @@ snapshots: speakingurl: 14.0.1 superjson: 2.2.2 + '@vue/devtools-shared@7.7.7': + dependencies: + rfdc: 1.4.1 + '@vue/devtools-shared@8.0.0': dependencies: rfdc: 1.4.1 @@ -3916,6 +3955,13 @@ snapshots: picomatch@4.0.3: {} + pinia@3.0.3(typescript@5.9.2)(vue@3.5.18(typescript@5.9.2)): + dependencies: + '@vue/devtools-api': 7.7.7 + vue: 3.5.18(typescript@5.9.2) + optionalDependencies: + typescript: 5.9.2 + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 diff --git a/resources/css/app.css b/resources/css/app.css index 1a7ee31..45cab6b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -7,6 +7,13 @@ @custom-variant dark (&:is(.dark *)); +/* FIX jspdf export (https://github.com/parallax/jsPDF/issues/3532#issuecomment-1492983053) */ +@layer base { + img { + @apply inline-block; + } +} + @theme inline { --font-sans: Instrument Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; diff --git a/resources/js/app.ts b/resources/js/app.ts index 792a46c..0cd1a4f 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -6,6 +6,7 @@ import type { DefineComponent } from 'vue'; import { createApp, h } from 'vue'; import { ZiggyVue } from 'ziggy-js'; import { initializeTheme } from './composables/useAppearance'; +import pinia from './lib/pinia'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -15,6 +16,7 @@ createInertiaApp({ setup({ el, App, props, plugin }) { createApp({ render: () => h(App, props) }) .use(plugin) + .use(pinia) .use(ZiggyVue) .mount(el); }, diff --git a/resources/js/components/SidebarListResumeItem.vue b/resources/js/components/SidebarListResumeItem.vue new file mode 100644 index 0000000..1d2e190 --- /dev/null +++ b/resources/js/components/SidebarListResumeItem.vue @@ -0,0 +1,48 @@ + + + diff --git a/resources/js/components/SidebarListResumeItemToolButton.vue b/resources/js/components/SidebarListResumeItemToolButton.vue new file mode 100644 index 0000000..e9878ac --- /dev/null +++ b/resources/js/components/SidebarListResumeItemToolButton.vue @@ -0,0 +1,14 @@ + + + diff --git a/resources/js/components/SidebarResumeList.vue b/resources/js/components/SidebarResumeList.vue index f48f88d..d07a4a5 100644 --- a/resources/js/components/SidebarResumeList.vue +++ b/resources/js/components/SidebarResumeList.vue @@ -1,29 +1,49 @@ diff --git a/resources/js/components/resume/ComponentsSelectionList.vue b/resources/js/components/resume/ComponentsSelectionList.vue new file mode 100644 index 0000000..c83af93 --- /dev/null +++ b/resources/js/components/resume/ComponentsSelectionList.vue @@ -0,0 +1,85 @@ + + + diff --git a/resources/js/components/resume/ComponentsSelectionListItem.vue b/resources/js/components/resume/ComponentsSelectionListItem.vue new file mode 100644 index 0000000..bf8a8a3 --- /dev/null +++ b/resources/js/components/resume/ComponentsSelectionListItem.vue @@ -0,0 +1,19 @@ + + + diff --git a/resources/js/components/resume/PrintResumeButton.vue b/resources/js/components/resume/PrintResumeButton.vue index cdec876..e84d5b6 100644 --- a/resources/js/components/resume/PrintResumeButton.vue +++ b/resources/js/components/resume/PrintResumeButton.vue @@ -1,15 +1,13 @@ diff --git a/resources/js/components/resume/ResumeComponentEdit.vue b/resources/js/components/resume/ResumeComponentEdit.vue index e77041b..d8d961a 100644 --- a/resources/js/components/resume/ResumeComponentEdit.vue +++ b/resources/js/components/resume/ResumeComponentEdit.vue @@ -1,26 +1,23 @@ diff --git a/resources/js/components/resume/ResumeComponentsList.vue b/resources/js/components/resume/ResumeComponentsList.vue index bcc34aa..e95b74f 100644 --- a/resources/js/components/resume/ResumeComponentsList.vue +++ b/resources/js/components/resume/ResumeComponentsList.vue @@ -1,31 +1,68 @@