From 1b615163be90d689c396b17f439eaae5d17090ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= This is a test email from profile , attach from docs,
+ * inline images to base64, inline CSS). Does not persist any changes or send email.
+ */
+ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
+ {
+ $this->authorize('view', $emailTemplate);
+
+ $renderer = app(EmailTemplateRenderer::class);
+ $subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
+ $html = (string) ($request->input('html') ?? $emailTemplate->html_template);
+ $text = (string) ($request->input('text') ?? $emailTemplate->text_template);
+
+ // Do not persist tmp images, but allow previewing with them present
+ $html = $this->adoptTmpImagesInHtml($emailTemplate, $html, false);
+
+ // Context resolution (same as sendTest)
+ $ctx = [];
+ if ($id = $request->integer('contract_id')) {
+ $contract = Contract::query()->with(['clientCase.client.person'])->find($id);
+ if ($contract) {
+ $ctx['contract'] = $contract;
+ if ($contract->clientCase) {
+ $ctx['client_case'] = $contract->clientCase;
+ if ($contract->clientCase->client) {
+ $ctx['client'] = $contract->clientCase->client;
+ $ctx['person'] = optional($contract->clientCase->client)->person;
+ }
+ }
+ }
+ }
+ if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
+ $case = ClientCase::query()->with(['client.person'])->find($id);
+ if ($case) {
+ $ctx['client_case'] = $case;
+ if ($case->client) {
+ $ctx['client'] = $case->client;
+ $ctx['person'] = optional($case->client)->person;
+ }
+ }
+ }
+ if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
+ $client = Client::query()->with(['person'])->find($id);
+ if ($client) {
+ $ctx['client'] = $client;
+ $ctx['person'] = optional($client)->person;
+ }
+ }
+ $ctx['extra'] = (array) $request->input('extra', []);
+
+ $rendered = $renderer->render([
+ 'subject' => $subject,
+ 'html' => $html,
+ 'text' => $text,
+ ], $ctx);
+
+ $attachments = [];
+ if (! empty($rendered['html'])) {
+ $rendered['html'] = $this->repairImgWithoutSrc($rendered['html']);
+ $rendered['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $rendered['html']);
+ $embed = (string) $request->input('embed', 'base64');
+ if ($embed === 'base64') {
+ try {
+ $imageInliner = app(\App\Services\EmailImageInliner::class);
+ $rendered['html'] = $imageInliner->inline($rendered['html']);
+ } catch (\Throwable $e) {
+ }
+ } else {
+ $rendered['html'] = $this->absolutizeStorageUrls($request, $rendered['html']);
+ }
+ try {
+ $inliner = new CssToInlineStyles;
+ $rendered['html'] = $inliner->convert($rendered['html']);
+ } catch (\Throwable $e) {
+ }
+ }
+
+ return response()->json([
+ 'subject' => $rendered['subject'] ?? $subject,
+ 'html' => $rendered['html'] ?? '',
+ 'text' => $rendered['text'] ?? ($text ?? ''),
+ 'attachments' => $attachments,
+ ]);
+ }
+
+ /**
+ * Convert any
(or absolute URLs whose path is /storage/...) to
+ * absolute URLs using the current request scheme+host, so email clients like Gmail can fetch
+ * them through their proxy reliably.
+ */
+ protected function absolutizeStorageUrls(Request $request, string $html): string
+ {
+ if ($html === '' || stripos($html, '
getSchemeAndHttpHost();
+
+ return preg_replace_callback('#
]+)src=["\']([^"\']+)["\']([^>]*)>#i', function (array $m) use ($host) {
+ $before = $m[1] ?? '';
+ $src = $m[2] ?? '';
+ $after = $m[3] ?? '';
+ $path = $src;
+ if (preg_match('#^https?://#i', $src)) {
+ $parts = parse_url($src);
+ $path = $parts['path'] ?? '';
+ if (! preg_match('#^/?storage/#i', (string) $path)) {
+ return $m[0];
+ }
+ } else {
+ if (! preg_match('#^/?storage/#i', (string) $path)) {
+ return $m[0];
+ }
+ }
+ $rel = '/'.ltrim(preg_replace('#^/?storage/#i', 'storage/', (string) $path), '/');
+ $abs = rtrim($host, '/').$rel;
+
+ return '
';
+ }, $html);
+ }
+
+ /**
+ * Fix patterns where an
tag lacks a src attribute but is immediately followed by a URL.
+ * Example to fix:
+ *
\nhttps://domain.tld/storage/email-images/foo.png
+ * becomes:
+ *
+ * The trailing URL text is removed.
+ */
+ protected function repairImgWithoutSrc(string $html): string
+ {
+ if ($html === '' || stripos($html, ' when not present and keep the in-between content
+ $setSrc = function (array $m): string {
+ $attrs = $m[1] ?? '';
+ $between = $m[2] ?? '';
+ $url = $m[3] ?? '';
+ if (preg_match('#\bsrc\s*=#i', $attrs)) {
+ return $m[0];
+ }
+
+ return '
'.$between;
+ };
+
+ // Up to 700 chars of any content (non-greedy) between tag and URL
+ $gap = '(.{0,700}?)';
+ $urlAbs = '(https?://[^\s<>"\']+/storage/[^\s<>"\']+)';
+ $urlRel = '(/storage/[^\s<>"\']+)';
+
+ // Case 1: Plain text URL after
+ $html = preg_replace_callback('#
]*)>'.$gap.$urlAbs.'#is', $setSrc, $html);
+ $html = preg_replace_callback('#
]*)>'.$gap.$urlRel.'#is', $setSrc, $html);
+
+ // Case 2: Linked URL after
(keep the anchor text, consume the URL into src)
+ $setSrcAnchor = function (array $m): string {
+ $attrs = $m[1] ?? '';
+ $between = $m[2] ?? '';
+ $url = $m[3] ?? '';
+ $anchor = $m[4] ?? '';
+ if (preg_match('#\bsrc\s*=#i', $attrs)) {
+ return $m[0];
+ }
+
+ // Keep the anchor but its href stays as-is; we only set img src
+ return '
'.$between.$anchor;
+ };
+ $html = preg_replace_callback('#
]*)>'.$gap.$urlAbs.'(\s*]+href=["\'][^"\']+["\'][^>]*>.*?)#is', $setSrcAnchor, $html);
+ $html = preg_replace_callback('#
]*)>'.$gap.$urlRel.'(\s*]+href=["\'][^"\']+["\'][^>]*>.*?)#is', $setSrcAnchor, $html);
+
+ // Fallback: if a single image is missing src and there is a single /storage URL anywhere, attach it
+ if (preg_match_all('#
]*\bsrc\s*=)[^>]*>#i', $html, $missingImgs) === 1) {
+ if (count($missingImgs[0]) === 1) {
+ if (preg_match_all('#(?:https?://[^\s<>"\']+)?/storage/[^\s<>"\']+#i', $html, $urls) === 1 && count($urls[0]) === 1) {
+ $onlyUrl = $urls[0][0];
+ $html = preg_replace('#
]*\bsrc\s*=)[^>]*)>#i', '
', $html, 1);
+ }
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * As a conservative fallback, populate missing
src attributes using this template's
+ * attached image Documents. We try to match by the alt attribute first (e.g., alt="Logo"
+ * will match a document named "logo.*"); if there is only one image document, we will use it.
+ */
+ protected function attachSrcFromTemplateDocuments(EmailTemplate $tpl, string $html): string
+ {
+ if ($html === '' || stripos($html, '
getRelationValue('documents');
+ if ($docs === null) {
+ $docs = $tpl->documents()->get(['id', 'name', 'path', 'file_name', 'original_name', 'mime_type']);
+ }
+ $imageDocs = collect($docs ?: [])->filter(function ($d) {
+ $mime = strtolower((string) ($d->mime_type ?? ''));
+
+ return $mime === '' || str_starts_with($mime, 'image/');
+ })->values();
+
+ if ($imageDocs->isEmpty()) {
+ return $html;
+ }
+
+ // Build lookups by basename without extension
+ $byStem = [];
+ foreach ($imageDocs as $d) {
+ $base = pathinfo($d->file_name ?: ($d->name ?: ($d->original_name ?: basename((string) $d->path))), PATHINFO_FILENAME);
+ if ($base) {
+ $byStem[strtolower($base)] = $d;
+ }
+ }
+
+ $callback = function (array $m) use (&$byStem, $imageDocs) {
+ $attrs = $m[1] ?? '';
+ if (preg_match('#\bsrc\s*=#i', $attrs)) {
+ return $m[0];
+ }
+
+ $alt = null;
+ if (preg_match('#\balt\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $attrs, $am)) {
+ $alt = trim(html_entity_decode($am[1] !== '' ? $am[1] : ($am[2] ?? ''), ENT_QUOTES | ENT_HTML5));
+ }
+
+ $chosen = null;
+ if ($alt) {
+ $key = strtolower(preg_replace('#[^a-z0-9]+#i', '', $alt));
+ // try exact stem
+ if (isset($byStem[strtolower($alt)])) {
+ $chosen = $byStem[strtolower($alt)];
+ }
+ if (! $chosen) {
+ // try relaxed: any stem containing the alt
+ foreach ($byStem as $stem => $doc) {
+ $relaxedStem = preg_replace('#[^a-z0-9]+#i', '', (string) $stem);
+ if ($relaxedStem !== '' && str_contains($relaxedStem, $key)) {
+ $chosen = $doc;
+ break;
+ }
+ }
+ }
+ }
+
+ if (! $chosen && method_exists($imageDocs, 'count') && $imageDocs->count() === 1) {
+ $chosen = $imageDocs->first();
+ }
+
+ if (! $chosen) {
+ return $m[0];
+ }
+
+ $url = '/storage/'.ltrim((string) $chosen->path, '/');
+
+ return '
';
+ };
+
+ $html = preg_replace_callback('#
]*)>#i', $callback, $html);
+
+ return $html;
+ }
+
+ /**
+ * Upload an image for use in email templates. Stores to a temporary folder first and returns a public URL.
+ */
+ public function uploadImage(Request $request)
+ {
+ $this->authorize('create', EmailTemplate::class);
+
+ $validated = $request->validate([
+ 'file' => ['required', 'image', 'max:5120'], // 5MB
+ ]);
+
+ /** @var \Illuminate\Http\UploadedFile $file */
+ $file = $validated['file'];
+ // store into tmp first; move on save
+ $path = $file->store('tmp/email-images', 'public');
+ // Return a relative URL to avoid mismatched host/ports in dev
+ $url = '/storage/'.$path;
+
+ return response()->json([
+ 'url' => $url,
+ 'path' => $path,
+ 'tmp' => true,
+ ]);
+ }
+
+ /**
+ * Replace an image referenced by the template, updating the existing Document row if found
+ * (and deleting the old file), or creating a new Document if none exists. Returns the new URL.
+ */
+ public function replaceImage(Request $request, EmailTemplate $emailTemplate)
+ {
+ $this->authorize('update', $emailTemplate);
+
+ $validated = $request->validate([
+ 'file' => ['required', 'image', 'max:5120'],
+ 'current_src' => ['nullable', 'string'],
+ ]);
+
+ /** @var \Illuminate\Http\UploadedFile $file */
+ $file = $validated['file'];
+ $currentSrc = (string) ($validated['current_src'] ?? '');
+
+ // Normalize current src to a public disk path when possible
+ $currentPath = null;
+ if ($currentSrc !== '') {
+ $parsed = parse_url($currentSrc);
+ $path = $parsed['path'] ?? $currentSrc;
+ // Accept /storage/... or raw path; strip leading storage/
+ if (preg_match('#/storage/(.+)#i', $path, $m)) {
+ $path = $m[1];
+ }
+ $path = ltrim(preg_replace('#^storage/#', '', $path), '/');
+ if ($path !== '') {
+ $currentPath = $path;
+ }
+ }
+
+ // Find existing document for this template matching the path
+ $doc = null;
+ if ($currentPath) {
+ $doc = $emailTemplate->documents()->where('path', $currentPath)->first();
+ }
+
+ // Store the new file
+ $ext = $file->getClientOriginalExtension();
+ $nameBase = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) ?: 'image';
+ $dest = 'email-images/'.$nameBase.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
+ Storage::disk('public')->put($dest, File::get($file->getRealPath()));
+
+ // Delete old file if we will update an existing document
+ if ($doc && $doc->path && Storage::disk('public')->exists($doc->path)) {
+ try {
+ Storage::disk('public')->delete($doc->path);
+ } catch (\Throwable $e) {
+ // ignore
+ }
+ }
+
+ $full = storage_path('app/public/'.$dest);
+ try {
+ $mime = File::exists($full) ? File::mimeType($full) : null;
+ } catch (\Throwable $e) {
+ $mime = null;
+ }
+ try {
+ $size = Storage::disk('public')->size($dest);
+ } catch (\Throwable $e) {
+ $size = null;
+ }
+
+ if ($doc) {
+ $doc->forceFill([
+ 'name' => basename($dest),
+ 'path' => $dest,
+ 'file_name' => basename($dest),
+ 'original_name' => $file->getClientOriginalName(),
+ 'extension' => $ext ?: null,
+ 'mime_type' => $mime,
+ 'size' => $size,
+ ])->save();
+ } else {
+ $doc = $emailTemplate->documents()->create([
+ 'name' => basename($dest),
+ 'description' => null,
+ 'user_id' => optional(auth()->user())->id,
+ 'disk' => 'public',
+ 'path' => $dest,
+ 'file_name' => basename($dest),
+ 'original_name' => $file->getClientOriginalName(),
+ 'extension' => $ext ?: null,
+ 'mime_type' => $mime,
+ 'size' => $size,
+ 'is_public' => true,
+ ]);
+ }
+
+ return response()->json([
+ 'url' => '/storage/'.$dest,
+ 'path' => $dest,
+ 'document_id' => $doc->id,
+ 'replaced' => (bool) $currentPath,
+ ]);
+ }
+
+ /**
+ * Scan HTML for images stored in /storage/tmp/email-images and move them into a permanent
+ * location under /storage/email-images, create Document records and update the HTML.
+ */
+ protected function adoptTmpImages(EmailTemplate $tpl): void
+ {
+ $html = (string) ($tpl->html_template ?? '');
+ if ($html === '' || stripos($html, 'tmp/email-images/') === false) {
+ return;
+ }
+
+ // Match any tmp paths inside src attributes, accepting absolute or relative URLs
+ $paths = [];
+ $matches = [];
+ if (preg_match_all('#/storage/tmp/email-images/[^"\']+#i', $html, $matches)) {
+ $paths = array_merge($paths, $matches[0]);
+ }
+ if (preg_match_all('#tmp/email-images/[^"\']+#i', $html, $matches)) {
+ $paths = array_merge($paths, $matches[0]);
+ }
+ $paths = array_values(array_unique($paths));
+ if (empty($paths)) {
+ return;
+ }
+
+ foreach ($paths as $tmpRel) {
+ // Normalize path (strip any leading storage/)
+ // Normalize to disk-relative path
+ $tmpRel = ltrim(preg_replace('#^/?storage/#i', '', $tmpRel), '/');
+ if (! Storage::disk('public')->exists($tmpRel)) {
+ continue;
+ }
+
+ $ext = pathinfo($tmpRel, PATHINFO_EXTENSION);
+ $base = pathinfo($tmpRel, PATHINFO_FILENAME);
+ $candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
+ // Ensure dest doesn't exist
+ while (Storage::disk('public')->exists($candidate)) {
+ $candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
+ }
+
+ // Move the file
+ Storage::disk('public')->move($tmpRel, $candidate);
+
+ // Create Document record
+ try {
+ $full = storage_path('app/public/'.$candidate);
+ $mime = File::exists($full) ? File::mimeType($full) : null;
+ } catch (\Throwable $e) {
+ $mime = null;
+ }
+ try {
+ $size = Storage::disk('public')->size($candidate);
+ } catch (\Throwable $e) {
+ $size = null;
+ }
+
+ $tpl->documents()->create([
+ 'name' => basename($candidate),
+ 'description' => null,
+ 'user_id' => optional(auth()->user())->id,
+ 'disk' => 'public',
+ 'path' => $candidate,
+ 'file_name' => basename($candidate),
+ 'original_name' => basename($candidate),
+ 'extension' => $ext ?: null,
+ 'mime_type' => $mime,
+ 'size' => $size,
+ 'is_public' => true,
+ ]);
+
+ // Update HTML to reference the new permanent path (use relative /storage URL)
+ $to = '/storage/'.$candidate;
+ $from = ['/storage/'.$tmpRel, $tmpRel];
+ $html = str_replace($from, $to, $html);
+ // Also replace absolute URL variants like https://domain/storage/
'.e($mailProfile->name).' at '.e(now()->toDateTimeString()).'.]+)src=[\"\']([^\"\']+)[\"\']([^>]*)>#i', function (array $m): string {
+ $before = $m[1] ?? '';
+ $src = $m[2] ?? '';
+ $after = $m[3] ?? '';
+
+ // Skip if already data URI or external
+ if (stripos($src, 'data:') === 0) {
+ return $m[0];
+ }
+
+ // Accept either relative (/storage/...) OR absolute URLs whose path begins with /storage/
+ $path = $src;
+ if (preg_match('#^https?://#i', $src)) {
+ $parts = parse_url($src);
+ $path = $parts['path'] ?? '';
+ }
+ if (! preg_match('#^/?storage/#i', (string) $path)) {
+ return $m[0];
+ }
+
+ $rel = ltrim(preg_replace('#^/?storage/#i', '', (string) $path), '/');
+ $full = storage_path('app/public/'.$rel);
+ if (! File::exists($full)) {
+ return $m[0];
+ }
+
+ // Determine mime type
+ $mime = null;
+ try {
+ $mime = File::mimeType($full);
+ } catch (\Throwable) {
+ $mime = null;
+ }
+ if ($mime === null) {
+ $ext = strtolower(pathinfo($full, PATHINFO_EXTENSION));
+ $map = [
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'svg' => 'image/svg+xml',
+ 'webp' => 'image/webp',
+ ];
+ $mime = $map[$ext] ?? 'application/octet-stream';
+ }
+
+ // Cap size to avoid huge emails (e.g., 5 MB)
+ $max = 5 * 1024 * 1024;
+ try {
+ if (File::size($full) > $max) {
+ return $m[0];
+ }
+ } catch (\Throwable) {
+ // ignore size errors
+ }
+
+ try {
+ $data = base64_encode(File::get($full));
+ } catch (\Throwable) {
+ return $m[0];
+ }
+
+ $dataUri = 'data:'.$mime.';base64,'.$data;
+
+ return '
';
+ }, $html);
+ }
+}
diff --git a/app/Services/EmailTemplateRenderer.php b/app/Services/EmailTemplateRenderer.php
new file mode 100644
index 0000000..c340522
--- /dev/null
+++ b/app/Services/EmailTemplateRenderer.php
@@ -0,0 +1,92 @@
+buildMap($ctx);
+ $replacer = static function (?string $input) use ($map): ?string {
+ if ($input === null) {
+ return null;
+ }
+
+ return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
+ $key = $m[1];
+
+ return (string) data_get($map, $key, '');
+ }, $input);
+ };
+
+ return [
+ 'subject' => $replacer($template['subject']) ?? '',
+ 'html' => $replacer($template['html'] ?? null) ?? null,
+ 'text' => $replacer($template['text'] ?? null) ?? null,
+ ];
+ }
+
+ /**
+ * @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, extra?:array} $ctx
+ */
+ protected function buildMap(array $ctx): array
+ {
+ $out = [];
+ if (isset($ctx['client'])) {
+ $c = $ctx['client'];
+ $out['client'] = [
+ 'id' => data_get($c, 'id'),
+ 'uuid' => data_get($c, 'uuid'),
+ ];
+ }
+ if (isset($ctx['person'])) {
+ $p = $ctx['person'];
+ $out['person'] = [
+ 'first_name' => data_get($p, 'first_name'),
+ 'last_name' => data_get($p, 'last_name'),
+ 'full_name' => trim((data_get($p, 'first_name', '')).' '.(data_get($p, 'last_name', ''))),
+ 'email' => data_get($p, 'email'),
+ 'phone' => data_get($p, 'phone'),
+ ];
+ }
+ if (isset($ctx['client_case'])) {
+ $c = $ctx['client_case'];
+ $out['case'] = [
+ 'id' => data_get($c, 'id'),
+ 'uuid' => data_get($c, 'uuid'),
+ 'reference' => data_get($c, 'reference'),
+ ];
+ }
+ if (isset($ctx['contract'])) {
+ $co = $ctx['contract'];
+ $out['contract'] = [
+ 'id' => data_get($co, 'id'),
+ 'uuid' => data_get($co, 'uuid'),
+ 'reference' => data_get($co, 'reference'),
+ 'amount' => data_get($co, 'amount'),
+ ];
+ $meta = data_get($co, 'meta');
+ if (is_array($meta)) {
+ $out['contract']['meta'] = $meta;
+ }
+ }
+ if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
+ $out['extra'] = $ctx['extra'];
+ }
+
+ return $out;
+ }
+}
diff --git a/composer.json b/composer.json
index 4252aab..0bee2c3 100644
--- a/composer.json
+++ b/composer.json
@@ -5,6 +5,7 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
+ "tijsverkoyen/css-to-inline-styles": "^2.2",
"php": "^8.2",
"arielmejiadev/larapex-charts": "^2.1",
"diglactic/laravel-breadcrumbs": "^10.0",
diff --git a/composer.lock b/composer.lock
index 20fa89b..c640d1c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "af8a7f4584f3bab04f410483a25e092f",
+ "content-hash": "51fd57123c1b9f51c24f28e04a692ec4",
"packages": [
{
"name": "arielmejiadev/larapex-charts",
@@ -10335,6 +10335,6 @@
"platform": {
"php": "^8.2"
},
- "platform-dev": [],
+ "platform-dev": {},
"plugin-api-version": "2.6.0"
}
diff --git a/database/migrations/2025_10_10_000001_create_email_templates_table.php b/database/migrations/2025_10_10_000001_create_email_templates_table.php
new file mode 100644
index 0000000..cee2363
--- /dev/null
+++ b/database/migrations/2025_10_10_000001_create_email_templates_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->string('name');
+ $table->string('key')->unique();
+ $table->string('subject_template');
+ $table->longText('html_template')->nullable();
+ $table->longText('text_template')->nullable();
+ $table->json('entity_types')->nullable(); // e.g. ["client","contract","client_case"]
+ $table->boolean('active')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('email_templates');
+ }
+};
diff --git a/package-lock.json b/package-lock.json
index 0a27895..b5b52a8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"lodash": "^4.17.21",
"material-design-icons-iconfont": "^6.7.0",
"preline": "^2.7.0",
+ "quill": "^1.3.7",
"reka-ui": "^2.5.1",
"tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2",
@@ -34,10 +35,10 @@
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
- "@vitejs/plugin-vue": "^5.0.0",
+ "@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.16",
"axios": "^1.7.4",
- "laravel-vite-plugin": "^1.0",
+ "laravel-vite-plugin": "^2.0.1",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^7.1.7",
@@ -873,6 +874,13 @@
"url": "https://opencollective.com/popperjs"
}
},
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.29",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
+ "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
@@ -1375,16 +1383,19 @@
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
- "version": "5.2.4",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
- "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
+ "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-beta.29"
+ },
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
- "vite": "^5.0.0 || ^6.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
"vue": "^3.2.25"
}
},
@@ -1772,11 +1783,28 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -1790,7 +1818,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -1875,6 +1902,15 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1957,6 +1993,26 @@
"url": "https://github.com/sponsors/kossnocorp"
}
},
+ "node_modules/deep-equal": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
+ "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arguments": "^1.1.1",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "regexp.prototype.flags": "^1.5.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1966,6 +2022,40 @@
"node": ">=0.10.0"
}
},
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
@@ -1998,7 +2088,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -2044,7 +2133,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2054,7 +2142,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2064,7 +2151,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -2158,6 +2244,18 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
+ "node_modules/eventemitter3": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
+ "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
+ "license": "MIT"
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2380,11 +2478,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -2409,7 +2515,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -2455,7 +2560,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2464,11 +2568,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2481,7 +2596,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -2505,6 +2619,22 @@
"node": ">= 0.4"
}
},
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2532,6 +2662,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2577,6 +2723,24 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2608,9 +2772,9 @@
}
},
"node_modules/laravel-vite-plugin": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz",
- "integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
+ "integrity": "sha512-zQuvzWfUKQu9oNVi1o0RZAJCwhGsdhx4NEOyrVQwJHaWDseGP9tl7XUPLY2T8Cj6+IrZ6lmyxlR1KC8unf3RLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2621,10 +2785,10 @@
"clean-orphaned-assets": "bin/clean.js"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
- "vite": "^5.0.0 || ^6.0.0"
+ "vite": "^7.0.0"
}
},
"node_modules/lilconfig": {
@@ -2682,7 +2846,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2864,6 +3027,31 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/object-is": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@@ -2876,6 +3064,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
+ "node_modules/parchment": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
+ "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3183,6 +3377,40 @@
],
"license": "MIT"
},
+ "node_modules/quill": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
+ "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "clone": "^2.1.1",
+ "deep-equal": "^1.0.1",
+ "eventemitter3": "^2.0.3",
+ "extend": "^3.0.2",
+ "parchment": "^1.1.4",
+ "quill-delta": "^3.6.2"
+ }
+ },
+ "node_modules/quill/node_modules/fast-diff": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
+ "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/quill/node_modules/quill-delta": {
+ "version": "3.6.3",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
+ "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "deep-equal": "^1.0.1",
+ "extend": "^3.0.2",
+ "fast-diff": "1.1.2"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -3216,6 +3444,26 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/reka-ui": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.1.tgz",
@@ -3342,6 +3590,38 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/package.json b/package.json
index 8dc69fe..dc8fe0c 100644
--- a/package.json
+++ b/package.json
@@ -10,10 +10,10 @@
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
- "@vitejs/plugin-vue": "^5.0.0",
+ "@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.16",
"axios": "^1.7.4",
- "laravel-vite-plugin": "^1.0",
+ "laravel-vite-plugin": "^2.0.1",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^7.1.7",
@@ -25,6 +25,7 @@
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/vue-fontawesome": "^3.0.8",
+ "quill": "^1.3.7",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@internationalized/date": "^3.9.0",
diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue
index b7f9d70..c76b3a1 100644
--- a/resources/js/Layouts/AdminLayout.vue
+++ b/resources/js/Layouts/AdminLayout.vue
@@ -10,6 +10,7 @@ import {
faBars,
faGears,
faKey,
+ faEnvelope,
} from "@fortawesome/free-solid-svg-icons";
import Dropdown from "@/Components/Dropdown.vue";
import DropdownLink from "@/Components/DropdownLink.vue";
@@ -96,6 +97,17 @@ const navGroups = computed(() => [
icon: faFileWord,
active: ["admin.document-templates.index"],
},
+ {
+ key: "admin.email-templates.index",
+ label: "Email predloge",
+ route: "admin.email-templates.index",
+ icon: faEnvelope,
+ active: [
+ "admin.email-templates.index",
+ "admin.email-templates.create",
+ "admin.email-templates.edit",
+ ],
+ },
{
key: "admin.mail-profiles.index",
label: "Mail profili",
diff --git a/resources/js/Pages/Admin/EmailTemplates/Edit.vue b/resources/js/Pages/Admin/EmailTemplates/Edit.vue
new file mode 100644
index 0000000..4772804
--- /dev/null
+++ b/resources/js/Pages/Admin/EmailTemplates/Edit.vue
@@ -0,0 +1,1320 @@
+
+
+
+
+ {{ props.template ? "Uredi predlogo" : "Nova predloga" }}
+
+ <img> nima atributa src,
+ bo ob predogledu in pošiljanju sistem poskušal samodejno nastaviti sliko
+ na podlagi pripetih slik te predloge. Najprej se išče ujemanje po
+ alt (npr. alt="Logo" → dokument z imenom
+ »logo«); če je pripeta le ena slika, bo uporabljena ta.
+ {{
+ preview.text
+ }}
+
+
+ {{ a.src }} → cid:{{ a.cid }}
+ {{ finalHtml }}
+ Email predloge
+
+
+
+
+
+
+
+
+ Ime
+ Ključ
+ Entities
+ Aktivno
+ Akcije
+
+
+
+ {{ t.name }}
+ {{ t.key }}
+ {{ (t.entity_types || []).join(', ') }}
+ {{ t.active ? 'da' : 'ne' }}
+
+
+
+

Case: {{ case.uuid }}
Meta: {{ contract.meta.foo }}
', + 'text' => 'Client: {{ client.uuid }} Extra: {{ extra.note }}', + ]; + + $ctx = [ + 'person' => (object) ['first_name' => 'Jane', 'last_name' => 'Doe', 'email' => 'jane@example.test'], + 'client' => (object) ['uuid' => 'cl-123'], + 'client_case' => (object) ['uuid' => 'cc-456', 'reference' => 'REF-1'], + 'contract' => (object) ['uuid' => 'co-789', 'reference' => 'CON-42', 'meta' => ['foo' => 'bar']], + 'extra' => ['note' => 'hello'], + ]; + + $result = $renderer->render($template, $ctx); + + expect($result['subject'])->toBe('Hello Jane Doe - CON-42'); + expect($result['html'])->toContain('Case: cc-456'); + expect($result['html'])->toContain('Meta: bar'); + expect($result['text'])->toBe('Client: cl-123 Extra: hello'); +}); diff --git a/tests/Unit/RepairImgWithoutSrcTest.php b/tests/Unit/RepairImgWithoutSrcTest.php new file mode 100644 index 0000000..2a6e404 --- /dev/null +++ b/tests/Unit/RepairImgWithoutSrcTest.php @@ -0,0 +1,24 @@ + +