Skip to content

Commit af5e984

Browse files
authored
[ADVAPP-1921]: Introduce insert stock photo tip tap extension (#25)
* [ADVAPP-1921]: Introduce insert stock photo tip tap extension Signed-off-by: Dan Harrin <dan.harrin@canyongbs.com> * Use components Signed-off-by: Dan Harrin <dan.harrin@canyongbs.com> * formatting Signed-off-by: Dan Harrin <dan.harrin@canyongbs.com> * Add modal submit action label Signed-off-by: Dan Harrin <dan.harrin@canyongbs.com> --------- Signed-off-by: Dan Harrin <dan.harrin@canyongbs.com>
1 parent 0b69df2 commit af5e984

9 files changed

Lines changed: 405 additions & 38 deletions

File tree

resources/dist/filament-tiptap-editor.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/dist/filament-tiptap-editor.js

Lines changed: 37 additions & 37 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/views/components/icon.blade.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,10 @@ class="h-5 w-5"
301301
/>
302302
@break
303303

304+
@case('stock-image')
305+
<path d="M6 4C6 3.44772 6.44772 3 7 3H21C21.5523 3 22 3.44772 22 4V16C22 16.5523 21.5523 17 21 17H18V20C18 20.5523 17.5523 21 17 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H6V4ZM8 7H17C17.5523 7 18 7.44772 18 8V15H20V5H8V7ZM16 15.7394V9H4V18.6321L11.4911 11.6404L16 15.7394ZM7 13.5C7.82843 13.5 8.5 12.8284 8.5 12C8.5 11.1716 7.82843 10.5 7 10.5C6.17157 10.5 5.5 11.1716 5.5 12C5.5 12.8284 6.17157 13.5 7 13.5Z"></path>
306+
@break
307+
304308
@case('paragraph')
305309
<path
306310
d="M12 6V21H10V16C6.68629 16 4 13.3137 4 10C4 6.68629 6.68629 4 10 4H20V6H17V21H15V6H12ZM10 6C7.79086 6 6 7.79086 6 10C6 12.2091 7.79086 14 10 14V6Z"
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
<x-dynamic-component
2+
:component="$getFieldWrapperView()"
3+
:field="$field"
4+
>
5+
<div
6+
x-data="{
7+
state: $wire.$entangle('{{ $getStatePath() }}'),
8+
query: '',
9+
images: [],
10+
currentPage: 1,
11+
lastPage: 1,
12+
total: 0,
13+
loading: false,
14+
error: null,
15+
selectedImage: null,
16+
searchTimeout: null,
17+
18+
init() {
19+
this.$watch('query', () => {
20+
clearTimeout(this.searchTimeout);
21+
22+
this.searchTimeout = setTimeout(() => {
23+
this.currentPage = 1;
24+
this.loadImages();
25+
}, 500);
26+
});
27+
},
28+
29+
async loadImages() {
30+
if (!this.query.trim()) {
31+
this.images = [];
32+
this.currentPage = 1;
33+
this.lastPage = 1;
34+
this.total = 0;
35+
36+
return;
37+
}
38+
39+
this.loading = true;
40+
this.error = null;
41+
42+
try {
43+
const response = await fetch(@js($getUrl()), {
44+
method: 'POST',
45+
headers: {
46+
'Content-Type': 'application/json',
47+
},
48+
body: JSON.stringify({
49+
search: this.query,
50+
page: this.currentPage
51+
})
52+
});
53+
54+
if (!response.ok) {
55+
throw new Error('Failed to fetch images');
56+
}
57+
58+
const data = await response.json();
59+
this.images = data.data || [];
60+
this.currentPage = data.current_page || 1;
61+
this.lastPage = data.last_page || 1;
62+
this.total = data.total || 0;
63+
} catch (error) {
64+
this.error = error.message;
65+
} finally {
66+
this.loading = false;
67+
}
68+
},
69+
70+
selectImage(image) {
71+
this.selectedImage = image;
72+
this.state = {
73+
src: image.url,
74+
alt: image.title
75+
};
76+
},
77+
78+
goToPage(page) {
79+
if (page >= 1 && page <= this.lastPage && page !== this.currentPage) {
80+
this.currentPage = page;
81+
this.loadImages();
82+
}
83+
},
84+
85+
isSelected(image) {
86+
return this.selectedImage && this.selectedImage.url === image.url;
87+
}
88+
}"
89+
class="space-y-4"
90+
>
91+
<x-filament::input.wrapper
92+
inline-prefix
93+
prefix-icon="heroicon-m-magnifying-glass"
94+
>
95+
<x-filament::input
96+
type="search"
97+
x-model="query"
98+
placeholder="Search stock images..."
99+
/>
100+
</x-filament::input.wrapper>
101+
102+
<div x-show="loading" class="flex justify-center py-8">
103+
<x-filament::loading-indicator class="h-8 w-8 text-gray-500 dark:text-gray-400" />
104+
</div>
105+
106+
<div x-show="error" class="bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-md p-4">
107+
<div class="flex">
108+
<div class="flex-shrink-0">
109+
@svg('heroicon-m-exclamation-circle', 'h-5 w-5 text-danger-400 dark:text-danger-300')
110+
</div>
111+
112+
<div class="ml-3">
113+
<p class="text-sm text-danger-700 dark:text-danger-200" x-text="error"></p>
114+
</div>
115+
</div>
116+
</div>
117+
118+
<div x-show="!loading && !error && total > 0" class="text-sm text-gray-600 dark:text-gray-400">
119+
Showing <span x-text="images.length"></span> of <span x-text="total"></span> images
120+
</div>
121+
122+
<div x-show="!loading && !error && images.length === 0 && !query.trim()" class="text-center py-12">
123+
@svg('heroicon-o-photo', 'mx-auto h-12 w-12 text-gray-400 dark:text-gray-500')
124+
125+
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">Search for stock images</h3>
126+
127+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Enter a search term above to find images.</p>
128+
</div>
129+
130+
<div x-show="!loading && !error && images.length === 0 && query.trim()" class="text-center py-12">
131+
@svg('heroicon-o-photo', 'mx-auto h-12 w-12 text-gray-400 dark:text-gray-500')
132+
133+
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No images found</h3>
134+
135+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search terms.</p>
136+
</div>
137+
138+
<div x-show="!loading && !error && images.length > 0" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
139+
<template x-for="image in images" x-bind:key="image.url">
140+
<div
141+
x-on:click="selectImage(image)"
142+
x-bind:class="{
143+
'ring-2 ring-primary-500 ring-offset-2 dark:ring-offset-gray-800': isSelected(image),
144+
'hover:ring-2 hover:ring-gray-300 dark:hover:ring-gray-600 hover:ring-offset-2 dark:hover:ring-offset-gray-800': !isSelected(image)
145+
}"
146+
class="relative aspect-square bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden cursor-pointer transition-all duration-200 group"
147+
>
148+
<img
149+
x-bind:src="image.preview_url"
150+
x-bind:alt="image.title"
151+
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
152+
loading="lazy"
153+
/>
154+
155+
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200"></div>
156+
157+
<div
158+
x-show="isSelected(image)"
159+
class="absolute top-2 right-2 w-6 h-6 bg-primary-500 rounded-full flex items-center justify-center"
160+
>
161+
@svg('heroicon-m-check', 'w-4 h-4 text-white')
162+
</div>
163+
164+
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
165+
<p class="text-white text-xs font-medium truncate" x-text="image.title"></p>
166+
</div>
167+
</div>
168+
</template>
169+
</div>
170+
171+
<div x-show="!loading && !error && lastPage > 1" class="flex items-center justify-between">
172+
<div class="flex items-center space-x-4">
173+
<button
174+
x-on:click="goToPage(currentPage - 1)"
175+
x-bind:disabled="currentPage === 1"
176+
type="button"
177+
x-bind:class="{
178+
'opacity-50 cursor-not-allowed': currentPage === 1,
179+
'hover:text-primary-600 dark:hover:text-primary-400': currentPage !== 1
180+
}"
181+
class="text-sm text-gray-500 dark:text-gray-400 transition-colors duration-200 flex items-center"
182+
>
183+
@svg('heroicon-c-chevron-left', 'w-3 h-3 mr-1')
184+
185+
Previous
186+
</button>
187+
188+
<div class="flex items-center space-x-2">
189+
<template x-for="page in Array.from({ length: Math.min(5, lastPage) }, (_, i) => {
190+
const start = Math.max(1, Math.min(currentPage - 2, lastPage - 4));
191+
return start + i;
192+
}).filter(p => p <= lastPage)" x-bind:key="page">
193+
<button
194+
x-on:click="goToPage(page)"
195+
type="button"
196+
x-bind:class="{
197+
'text-primary-600 dark:text-primary-400 font-medium': page === currentPage,
198+
'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300': page !== currentPage
199+
}"
200+
class="text-sm px-2 py-1 rounded transition-colors duration-200"
201+
x-text="page"
202+
></button>
203+
</template>
204+
</div>
205+
206+
<button
207+
x-on:click="goToPage(currentPage + 1)"
208+
x-bind:disabled="currentPage === lastPage"
209+
type="button"
210+
x-bind:class="{
211+
'opacity-50 cursor-not-allowed': currentPage === lastPage,
212+
'hover:text-primary-600 dark:hover:text-primary-400': currentPage !== lastPage
213+
}"
214+
class="text-sm text-gray-500 dark:text-gray-400 transition-colors duration-200 flex items-center"
215+
>
216+
Next
217+
218+
@svg('heroicon-c-chevron-right', 'w-3 h-3 mr-1')
219+
</button>
220+
</div>
221+
222+
<span class="text-sm text-gray-500 dark:text-gray-400">
223+
Page <span x-text="currentPage"></span> of <span x-text="lastPage"></span>
224+
</span>
225+
</div>
226+
227+
<div x-show="selectedImage" class="mt-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
228+
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Selected Image:</h4>
229+
230+
<div class="flex items-center space-x-3">
231+
<img
232+
x-bind:src="selectedImage?.preview_url"
233+
x-bind:alt="selectedImage?.title"
234+
class="w-16 h-16 object-cover rounded-lg"
235+
>
236+
237+
<div class="flex-1 min-w-0">
238+
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" x-text="selectedImage?.title"></p>
239+
<p class="text-sm text-gray-500 dark:text-gray-400 truncate" x-text="selectedImage?.url"></p>
240+
</div>
241+
242+
<button
243+
x-on:click="selectedImage = null; state = null"
244+
type="button"
245+
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-200"
246+
>
247+
@svg('heroicon-m-x-mark', 'w-5 h-5')
248+
</button>
249+
</div>
250+
</div>
251+
</div>
252+
</x-dynamic-component>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@props([
2+
'editor' => null,
3+
'statePath' => null,
4+
'icon' => 'stock-image',
5+
])
6+
7+
@if ($editor->hasStockImages())
8+
@php
9+
$action = "\$wire.mountFormComponentAction('" . $statePath . "', 'filament_tiptap_stock_image');";
10+
@endphp
11+
12+
<x-filament-tiptap-editor::button
13+
:action="$action"
14+
label="Insert stock image"
15+
:icon="$icon"
16+
/>
17+
@endif

resources/views/tiptap-editor.blade.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class="tiptap-toolbar relative z-[1] flex flex-col divide-x divide-gray-950/10 r
9595
<x-dynamic-component
9696
component="{{ $tool['button'] }}"
9797
:state-path="$statePath"
98+
:editor="$field"
9899
/>
99100
@elseif ($tool === 'blocks')
100101
@if ($blocks && $shouldSupportBlocks)

src/Actions/StockImageAction.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace FilamentTiptapEditor\Actions;
4+
5+
use Filament\Forms\Components\Actions\Action;
6+
use Filament\Forms\Contracts\HasForms;
7+
use FilamentTiptapEditor\Components\StockImagePicker;
8+
use FilamentTiptapEditor\TiptapEditor;
9+
use Livewire\Component;
10+
11+
class StockImageAction extends Action
12+
{
13+
protected function setUp(): void
14+
{
15+
parent::setUp();
16+
17+
$this
18+
->modalWidth('xl')
19+
->modalHeading('Insert stock image')
20+
->modalSubmitActionLabel('Insert')
21+
->form(fn (TiptapEditor $component) => [
22+
StockImagePicker::make('image')
23+
->required()
24+
->hiddenLabel()
25+
->url($component->getStockImagesUrl()),
26+
])
27+
->action(function (TiptapEditor $component, Component & HasForms $livewire, array $data) {
28+
$livewire->dispatch(
29+
event: 'insertFromAction',
30+
type: 'media',
31+
statePath: $component->getStatePath(),
32+
media: [
33+
'src' => $data['image']['src'] ?? null,
34+
'alt' => $data['image']['alt'] ?? null,
35+
],
36+
);
37+
});
38+
}
39+
40+
public static function getDefaultName(): ?string
41+
{
42+
return 'filament_tiptap_stock_image';
43+
}
44+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace FilamentTiptapEditor\Components;
4+
5+
use Closure;
6+
use Filament\Forms\Components\Field;
7+
8+
class StockImagePicker extends Field
9+
{
10+
/**
11+
* @var view-string
12+
*/
13+
protected string $view = 'filament-tiptap-editor::components.stock-image-picker';
14+
15+
protected string | Closure | null $url = null;
16+
17+
public function url(string | Closure | null $url): static
18+
{
19+
$this->url = $url;
20+
21+
return $this;
22+
}
23+
24+
public function getUrl(): ?string
25+
{
26+
return $this->evaluate($this->url);
27+
}
28+
}

0 commit comments

Comments
 (0)