Initial release v1.0.0

This commit is contained in:
al-saloul
2025-12-11 11:42:12 +03:00
parent 60c9dabe8c
commit 1deb423acd
28 changed files with 1531 additions and 0 deletions

7
CHANGELOG.md Normal file
View File

@@ -0,0 +1,7 @@
# Changelog
All notable changes to `:package_name` will be documented in this file.
## 1.0.0 - 202X-XX-XX
- initial release

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) :vendor_name <author@domain.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

172
README.md Normal file
View File

@@ -0,0 +1,172 @@
# Filament Image Gallery
[![Latest Version on Packagist](https://img.shields.io/packagist/v/al-saloul/filament-image-gallery.svg?style=flat-square)](https://packagist.org/packages/al-saloul/filament-image-gallery)
[![Total Downloads](https://img.shields.io/packagist/dt/al-saloul/filament-image-gallery.svg?style=flat-square)](https://packagist.org/packages/al-saloul/filament-image-gallery)
A Filament 3 plugin for displaying image galleries with zoom, rotate, flip, and fullscreen capabilities using [Viewer.js](https://fengyuanchen.github.io/viewerjs/).
## Features
- 📊 **Table Column** - Display image galleries in table rows with stacked thumbnails
- 📋 **Infolist Entry** - Show image galleries in infolists with horizontal scrolling
- 🧩 **Blade Component** - Use standalone in any Blade view
- 🔍 **Viewer.js Integration** - Zoom, rotate, flip, and fullscreen image viewing
- 🌙 **Dark Mode Support** - Works seamlessly with dark mode
- 🌐 **RTL Support** - Full right-to-left language support
- 🌍 **Translations** - English and Arabic translations included
## Installation
```bash
composer require al-saloul/filament-image-gallery
```
Optionally, publish the config file:
```bash
php artisan vendor:publish --tag=image-gallery-config
```
## Usage
### Table Column
```php
use Alsaloul\ImageGallery\Tables\Columns\ImageGalleryColumn;
ImageGalleryColumn::make('images')
->getStateUsing(fn ($record) => $record->images->pluck('image')->toArray())
->circle()
->stacked(3)
->ring(2, '#3b82f6')
->limit(3)
->limitedRemainingText(),
```
#### Available Methods
| Method | Description | Default |
|--------|-------------|---------|
| `thumbWidth(int)` | Thumbnail width in pixels | `40` |
| `thumbHeight(int)` | Thumbnail height in pixels | `40` |
| `limit(int\|null)` | Maximum images to show | `3` |
| `stacked(int\|bool)` | Stack thumbnails with overlap. Pass `int` for custom spacing (e.g., `stacked(5)`) | `false` |
| `square(bool)` | Square shape with rounded corners | `false` |
| `circle(bool)` | Circular shape | `false` |
| `ring(int, string)` | Add border ring with width and optional color | `1, null` |
| `ringColor(string)` | Set ring color separately | `null` |
| `limitedRemainingText(bool)` | Show "+N" badge for remaining | `true` |
| `emptyText(string)` | Text when no images | `'No images'` |
---
### Infolist Entry
```php
use Alsaloul\ImageGallery\Infolists\Entries\ImageGalleryEntry;
ImageGalleryEntry::make('images')
->thumbWidth(128)
->thumbHeight(128)
->gap('gap-4')
->emptyText('No images available'),
```
#### Available Methods
| Method | Description | Default |
|--------|-------------|---------|
| `thumbWidth(int)` | Thumbnail width in pixels | `128` |
| `thumbHeight(int)` | Thumbnail height in pixels | `128` |
| `gap(string)` | Tailwind gap class | `'gap-4'` |
| `rounded(string)` | Tailwind rounded class | `'rounded-lg'` |
| `zoomCursor(bool)` | Show zoom cursor on hover | `true` |
| `emptyText(string)` | Text when no images | `'No images'` |
| `wrapperClass(string)` | Additional wrapper classes | `null` |
---
### Blade Component
```blade
<x-image-gallery::image-gallery
:images="$trip->images"
empty-text="No images for this trip"
:thumb-width="150"
:thumb-height="150"
rounded="rounded-xl"
gap="gap-6"
/>
```
#### Available Props
| Prop | Description | Default |
|------|-------------|---------|
| `images` | Array of image URLs or objects with `image` property | `[]` |
| `emptyText` | Text when no images | Translated message |
| `thumbWidth` | Thumbnail width in pixels | `128` |
| `thumbHeight` | Thumbnail height in pixels | `128` |
| `rounded` | Tailwind rounded class | `'rounded-lg'` |
| `gap` | Tailwind gap class | `'gap-4'` |
| `wrapperClass` | Additional wrapper classes | `''` |
| `zoomCursor` | Show zoom cursor on hover | `true` |
| `id` | Custom gallery ID | Auto-generated |
---
## Examples
### Circular Stacked Images with Blue Ring
```php
ImageGalleryColumn::make('images')
->circle()
->stacked(3)
->ring(2, '#3b82f6')
->limit(3)
```
### Square Images Without Stacking
```php
ImageGalleryColumn::make('images')
->square()
->limit(5)
```
### Custom Overlap Spacing
```php
ImageGalleryColumn::make('images')
->circle()
->stacked(5) // -space-x-5
->limit(4)
```
---
## Image Data Format
All components accept images in multiple formats:
```php
// Array of URLs
$images = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg'];
// Array of objects with 'image' property
$images = [['image' => 'https://example.com/image1.jpg']];
// Eloquent collection with 'image' attribute
$images = $trip->images;
```
## Changelog
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
## Credits
- [Al-Saloul](https://github.com/al-saloul)
- [Viewer.js](https://fengyuanchen.github.io/viewerjs/)
## License
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

50
bin/build.js Normal file
View File

@@ -0,0 +1,50 @@
import esbuild from 'esbuild'
const isDev = process.argv.includes('--dev')
async function compile(options) {
const context = await esbuild.context(options)
if (isDev) {
await context.watch()
} else {
await context.rebuild()
await context.dispose()
}
}
const defaultOptions = {
define: {
'process.env.NODE_ENV': isDev ? `'development'` : `'production'`,
},
bundle: true,
mainFields: ['module', 'main'],
platform: 'neutral',
sourcemap: isDev ? 'inline' : false,
sourcesContent: isDev,
treeShaking: true,
target: ['es2020'],
minify: !isDev,
plugins: [{
name: 'watchPlugin',
setup: function (build) {
build.onStart(() => {
console.log(`Build started at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`)
})
build.onEnd((result) => {
if (result.errors.length > 0) {
console.log(`Build failed at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`, result.errors)
} else {
console.log(`Build finished at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`)
}
})
}
}],
}
compile({
...defaultOptions,
entryPoints: ['./resources/js/index.js'],
outfile: './resources/dist/skeleton.js',
})

71
composer.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "al-saloul/filament-image-gallery",
"description": "A Filament plugin for displaying image galleries with zoom, rotate, and flip capabilities",
"keywords": [
"al-saloul",
"laravel",
"filament",
"image-gallery",
"viewer"
],
"homepage": "https://github.com/al-saloul/filament-image-gallery",
"support": {
"issues": "https://github.com/al-saloul/filament-image-gallery/issues",
"source": "https://github.com/al-saloul/filament-image-gallery"
},
"license": "MIT",
"authors": [
{
"name": "Al-Saloul",
"email": "author@domain.com",
"role": "Developer"
}
],
"require": {
"php": "^8.1",
"filament/filament": "^3.0",
"filament/forms": "^3.0",
"filament/tables": "^3.0",
"filament/infolists": "^3.0",
"spatie/laravel-package-tools": "^1.15.0"
},
"require-dev": {
"laravel/pint": "^1.0",
"nunomaduro/collision": "^7.9",
"orchestra/testbench": "^8.0",
"pestphp/pest": "^2.1",
"pestphp/pest-plugin-arch": "^2.0",
"pestphp/pest-plugin-laravel": "^2.0"
},
"autoload": {
"psr-4": {
"Alsaloul\\ImageGallery\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Alsaloul\\ImageGallery\\Tests\\": "tests/"
}
},
"scripts": {
"analyse": "vendor/bin/phpstan analyse",
"test": "vendor/bin/pest",
"test-coverage": "vendor/bin/pest --coverage",
"format": "vendor/bin/pint"
},
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"extra": {
"laravel": {
"providers": [
"Alsaloul\\ImageGallery\\ImageGalleryServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

37
config/image-gallery.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
// config for Alsaloul/ImageGallery
return [
/*
|--------------------------------------------------------------------------
| Viewer.js Configuration
|--------------------------------------------------------------------------
|
| Configure how Viewer.js is loaded. By default, it's loaded from CDN.
| Set 'cdn' to false if you want to bundle it locally.
|
*/
'viewer_js' => [
'cdn' => true,
'version' => '1.11.6',
],
/*
|--------------------------------------------------------------------------
| Default Settings
|--------------------------------------------------------------------------
|
| These are the default values used by the image gallery components.
| You can override these when using the components.
|
*/
'defaults' => [
'thumb_width' => 128,
'thumb_height' => 128,
'rounded' => 'rounded-lg',
'gap' => 'gap-4',
'stacked' => true,
'limit' => 3,
],
];

370
configure.php Normal file
View File

@@ -0,0 +1,370 @@
#!/usr/bin/env php
<?php
$gitName = run('git config user.name');
$authorName = ask('Author name', $gitName);
$gitEmail = run('git config user.email');
$authorEmail = ask('Author email', $gitEmail);
$usernameGuess = explode(':', run('git config remote.origin.url'))[1] ?? '';
if ($usernameGuess !== '') {
$usernameGuess = dirname($usernameGuess);
$usernameGuess = basename($usernameGuess);
}
$authorUsername = ask('Author username', $usernameGuess);
$vendorName = ask('Vendor name', $authorUsername);
$vendorSlug = slugify($vendorName);
$vendorNamespace = str_replace('-', '', ucwords($vendorName));
$vendorNamespace = ask('Vendor namespace', $vendorNamespace);
$currentDirectory = getcwd();
$folderName = basename($currentDirectory);
$packageName = ask('Package name', $folderName);
$packageSlug = slugify($packageName);
$packageSlugWithoutPrefix = removePrefix('filament-', $packageSlug);
$className = titleCase($packageName);
$className = ask('Class name', $className);
$variableName = lcfirst($className);
$description = ask('Package description', "This is my package $packageSlug");
$usePhpStan = confirm('Enable PhpStan?', true);
$usePint = confirm('Enable Pint?', true);
$useDependabot = confirm('Enable Dependabot?', true);
$useLaravelRay = confirm('Enable Ray?', true);
$useUpdateChangelogWorkflow = confirm('Use automatic changelog updater workflow?', true);
$isTheme = confirm('Is this a custom theme?');
$formsOnly = ! $isTheme && confirm('Is this for Forms only?');
$tablesOnly = ! ($isTheme || $formsOnly) && confirm('Is this for Tables only?');
writeln("\r");
writeln('------');
writeln("Author : \e[0;36m$authorName ($authorUsername, $authorEmail)\e[0m");
writeln("Vendor : \e[0;36m$vendorName ($vendorSlug)\e[0m");
writeln('Package : ' . "\e[0;36m" . $packageSlug . ($description ? " <{$description}>" : '') . "\e[0m");
writeln("Namespace : \e[0;36m$vendorNamespace\\$className\e[0m");
writeln("Class name : \e[0;36m$className\e[0m");
writeln('---');
writeln("\e[1;37mPackages & Utilities\e[0m");
writeln('Larastan/PhpStan : ' . ($usePhpStan ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m");
writeln('Pint : ' . ($usePint ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m");
writeln('Use Dependabot : ' . ($useDependabot ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m");
writeln('Use Ray : ' . ($useLaravelRay ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m");
writeln('Auto-Changelog : ' . ($useUpdateChangelogWorkflow ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m");
if ($formsOnly) {
writeln("Filament/Forms : \e[0;32mYes\e[0m");
} elseif ($tablesOnly) {
writeln("Filament/Tables : \e[0;32mYes\e[0m");
} else {
writeln("Filament/Filament : \e[0;32mYes\e[0m");
}
writeln('------');
writeln("\r");
writeln('This script will replace the above values in all relevant files in the project directory.');
writeln("\r");
if (! confirm('Modify files?', true)) {
exit(1);
}
if ($formsOnly) {
safeUnlink(__DIR__ . '/src/SkeletonTheme.php');
safeUnlink(__DIR__ . '/src/SkeletonPlugin.php');
removeComposerDeps([
'filament/filament',
'filament/tables',
], 'require');
} elseif ($tablesOnly) {
safeUnlink(__DIR__ . '/src/SkeletonTheme.php');
safeUnlink(__DIR__ . '/src/SkeletonPlugin.php');
removeComposerDeps([
'filament/filament',
'filament/forms',
], 'require');
} else {
if ($isTheme) {
safeUnlink(__DIR__ . '/src/SkeletonServiceProvider.php');
safeUnlink(__DIR__ . '/src/SkeletonPlugin.php');
safeUnlink(__DIR__ . '/src/Skeleton.php');
removeDirectory(__DIR__ . '/bin');
removeDirectory(__DIR__ . '/config');
removeDirectory(__DIR__ . '/database');
removeDirectory(__DIR__ . '/stubs');
removeDirectory(__DIR__ . '/resources/js');
removeDirectory(__DIR__ . '/resources/lang');
removeDirectory(__DIR__ . '/resources/views');
removeDirectory(__DIR__ . '/src/Commands');
removeDirectory(__DIR__ . '/src/Facades');
removeDirectory(__DIR__ . '/src/Testing');
setupPackageJsonForTheme();
} else {
safeUnlink(__DIR__ . '/src/SkeletonTheme.php');
}
removeComposerDeps([
'filament/forms',
'filament/tables',
], 'require');
}
$files = (str_starts_with(strtoupper(PHP_OS), 'WIN') ? replaceForWindows() : replaceForAllOtherOSes());
foreach ($files as $file) {
replaceInFile($file, [
':author_name' => $authorName,
':author_username' => $authorUsername,
'author@domain.com' => $authorEmail,
':vendor_name' => $vendorName,
':vendor_slug' => $vendorSlug,
'VendorName' => $vendorNamespace,
':package_name' => $packageName,
':package_slug' => $packageSlug,
':package_slug_without_prefix' => $packageSlugWithoutPrefix,
'Skeleton' => $className,
'skeleton' => $packageSlug,
'migration_table_name' => titleSnake($packageSlug),
'variable' => $variableName,
':package_description' => $description,
]);
match (true) {
str_contains($file, determineSeparator('src/Skeleton.php')) => rename($file, determineSeparator('./src/' . $className . '.php')),
str_contains($file, determineSeparator('src/SkeletonServiceProvider.php')) => rename($file, determineSeparator('./src/' . $className . 'ServiceProvider.php')),
str_contains($file, determineSeparator('src/SkeletonTheme.php')) => rename($file, determineSeparator('./src/' . $className . (str_ends_with($className, 'Theme') ? '.php' : 'Theme.php'))),
str_contains($file, determineSeparator('src/SkeletonPlugin.php')) => rename($file, determineSeparator('./src/' . $className . 'Plugin.php')),
str_contains($file, determineSeparator('src/Facades/Skeleton.php')) => rename($file, determineSeparator('./src/Facades/' . $className . '.php')),
str_contains($file, determineSeparator('src/Commands/SkeletonCommand.php')) => rename($file, determineSeparator('./src/Commands/' . $className . 'Command.php')),
str_contains($file, determineSeparator('src/Testing/TestsSkeleton.php')) => rename($file, determineSeparator('./src/Testing/Tests' . $className . '.php')),
str_contains($file, determineSeparator('database/migrations/create_skeleton_table.php.stub')) => rename($file, determineSeparator('./database/migrations/create_' . titleSnake($packageSlugWithoutPrefix) . '_table.php.stub')),
str_contains($file, determineSeparator('config/skeleton.php')) => rename($file, determineSeparator('./config/' . $packageSlugWithoutPrefix . '.php')),
str_contains($file, determineSeparator('resources/lang/en/skeleton.php')) => rename($file, determineSeparator('./resources/lang/en/' . $packageSlugWithoutPrefix . '.php')),
str_contains($file, 'README.md') => removeTag($file, 'delete'),
default => [],
};
}
if (! $useDependabot) {
safeUnlink(__DIR__ . '/.github/dependabot.yml');
safeUnlink(__DIR__ . '/.github/workflows/dependabot-auto-merge.yml');
}
if (! $useLaravelRay) {
removeComposerDeps(['spatie/laravel-ray'], 'require-dev');
}
if (! $usePhpStan) {
safeUnlink(__DIR__ . '/phpstan.neon.dist');
safeUnlink(__DIR__ . '/phpstan-baseline.neon');
safeUnlink(__DIR__ . '/.github/workflows/phpstan.yml');
removeComposerDeps([
'phpstan/extension-installer',
'phpstan/phpstan-deprecation-rules',
'phpstan/phpstan-phpunit',
'nunomaduro/larastan',
], 'require-dev');
removeComposerDeps(['analyse'], 'scripts');
}
if (! $usePint) {
safeUnlink(__DIR__ . '/.github/workflows/fix-php-code-style-issues.yml');
safeUnlink(__DIR__ . '/pint.json');
removeComposerDeps([
'laravel/pint',
], 'require-dev');
removeComposerDeps(['format'], 'scripts');
}
if (! $useUpdateChangelogWorkflow) {
safeUnlink(__DIR__ . '/.github/workflows/update-changelog.yml');
}
confirm('Execute `composer install`?') && run('composer install');
if (confirm('Let this script delete itself?', true)) {
unlink(__FILE__);
}
function ask(string $question, string $default = ''): string
{
$def = $default ? "\e[0;33m ($default)" : '';
$answer = readline("\e[0;32m" . $question . $def . ": \e[0m");
if (! $answer) {
return $default;
}
return $answer;
}
function confirm(string $question, bool $default = false): bool
{
$answer = ask($question, ($default ? 'Y/n' : 'y/N'));
if (strtolower($answer) === 'y/n') {
return $default;
}
return strtolower($answer) === 'y';
}
function writeln(string $line): void
{
echo $line . PHP_EOL;
}
function run(string $command): string
{
return trim((string) shell_exec($command));
}
function slugify(string $subject): string
{
return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $subject), '-'));
}
function titleCase(string $subject): string
{
return str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $subject)));
}
function titleSnake(string $subject, string $replace = '_'): string
{
return str_replace(['-', '_'], $replace, $subject);
}
function replaceInFile(string $file, array $replacements): void
{
$contents = file_get_contents($file);
file_put_contents(
$file,
str_replace(
array_keys($replacements),
array_values($replacements),
$contents
)
);
}
function removePrefix(string $prefix, string $content): string
{
if (str_starts_with($content, $prefix)) {
return substr($content, strlen($prefix));
}
return $content;
}
function removeComposerDeps(array $names, string $location): void
{
$data = json_decode(file_get_contents(__DIR__ . '/composer.json'), true);
foreach ($data[$location] as $name => $version) {
if (in_array($name, $names, true)) {
unset($data[$location][$name]);
}
}
file_put_contents(__DIR__ . '/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
function removeNpmDeps(array $names, string $location): void
{
$data = json_decode(file_get_contents(__DIR__ . '/package.json'), true);
foreach ($data[$location] as $name => $version) {
if (in_array($name, $names, true)) {
unset($data[$location][$name]);
}
}
file_put_contents(__DIR__ . '/package.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES |
JSON_UNESCAPED_UNICODE));
}
function removeTag(string $file, string $tag): void
{
$contents = file_get_contents($file);
file_put_contents(
$file,
preg_replace('/<!--' . $tag . '-->.*<!--\/' . $tag . '-->/s', '', $contents) ?: $contents
);
}
function setupPackageJsonForTheme(): void
{
removeNpmDeps([
'purge',
'dev',
'dev:scripts',
'build',
'build:scripts',
], 'scripts');
removeNpmDeps([
'@awcodes/filament-plugin-purge',
'esbuild',
'npm-run-all',
'prettier',
'prettier-plugin-tailwindcss',
], 'devDependencies');
replaceInFile(__DIR__ . '/package.json', [
'dev:styles' => 'dev',
'build:styles' => 'build',
]);
}
function safeUnlink(string $filename): void
{
if (file_exists($filename) && is_file($filename)) {
unlink($filename);
}
}
function determineSeparator(string $path): string
{
return str_replace('/', DIRECTORY_SEPARATOR, $path);
}
function replaceForWindows(): array
{
return preg_split('/\\r\\n|\\r|\\n/', run('dir /S /B * | findstr /v /i .git\ | findstr /v /i \\vendor\\ | findstr /v /i ' . basename(__FILE__) . ' | findstr /r /i /M /F:/ ":author :vendor :package VendorName skeleton migration_table_name vendor_name vendor_slug author@domain.com"'));
}
function replaceForAllOtherOSes(): array
{
return explode(PHP_EOL, run('find ./* ./.github/* -name "vendor" -type d -prune \
-o -name "configure.php" -prune \
-o -type f -print0 | xargs -0 grep -E -r -l -i ":author|:vendor|:package|VendorName|skeleton|migration_table_name|vendor_name|vendor_slug|author@domain.com"'));
}
function removeDirectory($dir): void
{
if (is_dir($dir)) {
$objects = scandir($dir);
foreach ($objects as $object) {
if ($object != '.' && $object != '..') {
if (filetype($dir . '/' . $object) == 'dir') {
removeDirectory($dir . '/' . $object);
} else {
unlink($dir . '/' . $object);
}
}
}
rmdir($dir);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace VendorName\Skeleton\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/*
class ModelFactory extends Factory
{
protected $model = YourModel::class;
public function definition()
{
return [
];
}
}
*/

View File

@@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('migration_table_name_table', function (Blueprint $table) {
$table->id();
// add fields
$table->timestamps();
});
}
};

8
postcss.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
}

21
resources/css/index.css Normal file
View File

@@ -0,0 +1,21 @@
.image-gallery {
scrollbar-width: thin;
scrollbar-color: rgb(156 163 175) transparent;
}
.image-gallery::-webkit-scrollbar {
height: 6px;
}
.image-gallery::-webkit-scrollbar-track {
background: transparent;
}
.image-gallery::-webkit-scrollbar-thumb {
background-color: rgb(156 163 175);
border-radius: 3px;
}
.dark .image-gallery::-webkit-scrollbar-thumb {
background-color: rgb(75 85 99);
}

0
resources/dist/.gitkeep vendored Normal file
View File

21
resources/dist/image-gallery.css vendored Normal file
View File

@@ -0,0 +1,21 @@
.image-gallery {
scrollbar-width: thin;
scrollbar-color: rgb(156 163 175) transparent;
}
.image-gallery::-webkit-scrollbar {
height: 6px;
}
.image-gallery::-webkit-scrollbar-track {
background: transparent;
}
.image-gallery::-webkit-scrollbar-thumb {
background-color: rgb(156 163 175);
border-radius: 3px;
}
.dark .image-gallery::-webkit-scrollbar-thumb {
background-color: rgb(75 85 99);
}

3
resources/dist/image-gallery.js vendored Normal file
View File

@@ -0,0 +1,3 @@
// Image Gallery Plugin - Viewer.js initialization
// The main Viewer.js initialization is done inline via CDN in the viewer-script.blade.php component
// This file is a placeholder for future compiled assets

3
resources/js/index.js Normal file
View File

@@ -0,0 +1,3 @@
// Image Gallery Plugin - Viewer.js initialization
// This file can be used for additional JS functionality
// The main Viewer.js initialization is done inline in the viewer-script.blade.php component

View File

@@ -0,0 +1,5 @@
<?php
return [
'empty' => 'لا توجد صور',
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'empty' => 'No images',
];

View File

@@ -0,0 +1,6 @@
<?php
// translations for VendorName/Skeleton
return [
//
];

0
resources/views/.gitkeep Normal file
View File

View File

@@ -0,0 +1,76 @@
@php
$urls = $getImageUrls();
$limit = $getLimit();
$visibleUrls = $limit ? array_slice($urls, 0, $limit) : $urls;
$remaining = $limit ? max(0, count($urls) - $limit) : 0;
$width = $getThumbWidth();
$height = $isSquare() ? $width : $getThumbHeight();
$isStacked = $isStacked();
$stackedOverlap = $getStackedOverlap();
$isSquare = $isSquare();
$isCircle = $isCircle();
$ringWidth = $getRingWidth();
$ringColor = $getRingColor();
$galleryId = 'gallery-col-' . str_replace(['{', '}', '-'], '', (string) \Illuminate\Support\Str::uuid());
// Determine border radius class
if ($isCircle) {
$borderRadiusClass = 'rounded-full';
} elseif ($isSquare) {
$borderRadiusClass = 'rounded-lg';
} else {
$borderRadiusClass = 'rounded';
}
// Border/Ring styles - only add if ringWidth > 0
$hasRing = $ringWidth > 0;
if ($hasRing) {
$ringStyle = "border-width: {$ringWidth}px; border-style: solid;";
if ($ringColor) {
$ringStyle .= " border-color: {$ringColor};";
$borderColorClass = '';
} else {
$borderColorClass = 'border-white dark:border-gray-800';
}
} else {
$ringStyle = '';
$borderColorClass = '';
}
// Stacked spacing - use dynamic -space-x value
if ($isStacked) {
$stackedClass = "-space-x-{$stackedOverlap} rtl:space-x-reverse";
} else {
$stackedClass = 'gap-1';
}
@endphp
<div
id="{{ $galleryId }}"
class="flex items-center {{ $stackedClass }}"
data-viewer-gallery
wire:ignore.self
>
@forelse($visibleUrls as $src)
<img
src="{{ $src }}"
loading="lazy"
class="object-cover {{ $borderColorClass }} shadow-sm {{ $borderRadiusClass }} hover:scale-110 transition cursor-zoom-in"
style="width: {{ $width }}px; height: {{ $height }}px; min-width: {{ $width }}px; {{ $ringStyle }}"
alt="image"
/>
@empty
<span class="text-sm text-gray-400 dark:text-gray-500">{{ $getEmptyText() }}</span>
@endforelse
@if($shouldShowRemainingText() && $remaining > 0)
<span class="flex items-center justify-center text-xs font-medium text-gray-600 dark:text-gray-200"
style="width: {{ $width }}px; height: {{ $height }}px; min-width: {{ $width }}px;">
+{{ $remaining }}
</span>
@endif
</div>
@once
<x-image-gallery::viewer-script />
@endonce

View File

@@ -0,0 +1,42 @@
@props([
'images' => [],
'emptyText' => null,
'thumbWidth' => 128,
'thumbHeight' => 128,
'rounded' => 'rounded-lg',
'gap' => 'gap-4',
'wrapperClass' => '',
'zoomCursor' => true,
'id' => null,
])
@php
$galleryId = $id ?? 'gallery-' . str_replace(['{', '}', '-'], '', (string) \Illuminate\Support\Str::uuid());
$urls = collect($images)->map(function($item) {
if (is_string($item)) return $item;
if (is_array($item)) return $item['image'] ?? $item['url'] ?? null;
if (is_object($item)) return $item->image ?? $item->url ?? null;
return null;
})->filter()->values();
$emptyTextDisplay = $emptyText ?? __('image-gallery::messages.empty');
@endphp
<div
id="{{ $galleryId }}"
class="image-gallery flex overflow-x-auto {{ $gap }} my-4 pb-2 select-none {{ $wrapperClass }}"
data-viewer-gallery
>
@forelse($urls as $src)
<img
src="{{ $src }}"
loading="lazy"
class="{{ $rounded }} shadow object-cover border border-gray-200 dark:border-gray-700 hover:scale-105 transition {{ $zoomCursor ? 'cursor-zoom-in' : '' }}"
style="width: {{ (int) $thumbWidth }}px; height: {{ (int) $thumbHeight }}px; flex-shrink: 0;"
alt="image"
/>
@empty
<span class="text-gray-400 dark:text-gray-500">{{ $emptyTextDisplay }}</span>
@endforelse
</div>
<x-image-gallery::viewer-script />

View File

@@ -0,0 +1,125 @@
@once
<!-- Viewer.js CDN assets -->
<link rel="stylesheet" href="https://unpkg.com/viewerjs@1.11.6/dist/viewer.min.css" />
<script src="https://unpkg.com/viewerjs@1.11.6/dist/viewer.min.js"></script>
<script>
(function() {
function initOne(el) {
if (!el || el._viewer || !window.Viewer) return;
el._viewer = new Viewer(el, {
toolbar: {
zoomIn: 1,
zoomOut: 1,
oneToOne: 1,
reset: 1,
prev: 1,
play: 0,
next: 1,
rotateLeft: 1,
rotateRight: 1,
flipHorizontal: 1,
flipVertical: 1,
},
navbar: false,
inline: false,
movable: true,
rotatable: true,
scalable: true,
fullscreen: true,
transition: true,
title: false,
});
}
function destroyOne(el) {
if (el && el._viewer) {
try {
el._viewer.destroy();
} catch(e) {}
el._viewer = null;
}
}
function scan() {
document.querySelectorAll('[data-viewer-gallery]').forEach(function(el) {
// Destroy and reinitialize to handle SPA navigation
destroyOne(el);
initOne(el);
});
}
// Run on various lifecycle events
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
// DOM is already ready
scan();
}
// Filament/Livewire 3.x SPA navigation
document.addEventListener('livewire:navigated', function() {
setTimeout(scan, 100);
});
// Livewire 3.x morph updates
document.addEventListener('livewire:init', function() {
if (window.Livewire) {
Livewire.hook('morph.updated', function({ el }) {
setTimeout(scan, 100);
});
}
});
// For Livewire 2.x compatibility
document.addEventListener('livewire:load', scan);
if (window.Livewire && window.Livewire.hook) {
try {
window.Livewire.hook('message.processed', function() {
setTimeout(scan, 100);
});
} catch (e) {}
}
// Turbolinks/Turbo compatibility
document.addEventListener('turbo:load', scan);
document.addEventListener('turbolinks:load', scan);
// Alpine.js x-init hook
document.addEventListener('alpine:init', function() {
Alpine.directive('image-gallery-init', function(el) {
initOne(el);
});
});
// MutationObserver for dynamic content
const observer = new MutationObserver(function(mutations) {
let shouldScan = false;
mutations.forEach(function(mutation) {
if (mutation.addedNodes.length) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
if (node.hasAttribute && node.hasAttribute('data-viewer-gallery')) {
shouldScan = true;
}
if (node.querySelectorAll) {
const galleries = node.querySelectorAll('[data-viewer-gallery]');
if (galleries.length) {
shouldScan = true;
}
}
}
});
}
});
if (shouldScan) {
setTimeout(scan, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();
</script>
@endonce

View File

@@ -0,0 +1,36 @@
@php
$urls = $getImageUrls();
$width = $getThumbWidth();
$height = $getThumbHeight();
$gap = $getGap();
$rounded = $getRounded();
$zoomCursor = $hasZoomCursor();
$wrapperClass = $getWrapperClass() ?? '';
$galleryId = 'gallery-entry-' . str_replace(['{', '}', '-'], '', (string) \Illuminate\Support\Str::uuid());
@endphp
<x-dynamic-component :component="$getEntryWrapperView()" :entry="$entry">
<div
id="{{ $galleryId }}"
class="image-gallery flex overflow-x-auto {{ $gap }} my-2 pb-2 select-none {{ $wrapperClass }}"
data-viewer-gallery
>
@forelse($urls as $src)
<img
src="{{ $src }}"
loading="lazy"
class="{{ $rounded }} shadow object-cover border border-gray-200 dark:border-gray-700 hover:scale-105 transition {{ $zoomCursor ? 'cursor-zoom-in' : '' }}"
style="width: {{ $width }}px; height: {{ $height }}px; flex-shrink: 0;"
alt="image"
/>
@empty
<span class="text-gray-400 dark:text-gray-500">{{ $getEmptyText() }}</span>
@endforelse
</div>
</x-dynamic-component>
@once
@push('scripts')
<x-image-gallery::viewer-script />
@endpush
@endonce

View File

@@ -0,0 +1,37 @@
<?php
namespace Alsaloul\ImageGallery;
use Filament\Contracts\Plugin;
use Filament\Panel;
class ImageGalleryPlugin implements Plugin
{
public function getId(): string
{
return 'image-gallery';
}
public function register(Panel $panel): void
{
//
}
public function boot(Panel $panel): void
{
//
}
public static function make(): static
{
return app(static::class);
}
public static function get(): static
{
/** @var static $plugin */
$plugin = filament(app(static::class)->getId());
return $plugin;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Alsaloul\ImageGallery;
use Filament\Support\Assets\Css;
use Filament\Support\Assets\Js;
use Filament\Support\Facades\FilamentAsset;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
class ImageGalleryServiceProvider extends PackageServiceProvider
{
public static string $name = 'image-gallery';
public static string $viewNamespace = 'image-gallery';
public function configurePackage(Package $package): void
{
$package->name(static::$name)
->hasConfigFile()
->hasViews(static::$viewNamespace)
->hasTranslations();
}
public function packageBooted(): void
{
// Register assets
FilamentAsset::register(
$this->getAssets(),
$this->getAssetPackageName()
);
}
protected function getAssetPackageName(): ?string
{
return 'al-saloul/filament-image-gallery';
}
/**
* @return array<\Filament\Support\Assets\Asset>
*/
protected function getAssets(): array
{
return [
Css::make('image-gallery-styles', __DIR__ . '/../resources/dist/image-gallery.css'),
Js::make('image-gallery-scripts', __DIR__ . '/../resources/dist/image-gallery.js'),
];
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Alsaloul\ImageGallery\Infolists\Entries;
use Closure;
use Filament\Infolists\Components\Entry;
class ImageGalleryEntry extends Entry
{
protected string $view = 'image-gallery::entries.image-gallery';
protected int | Closure $thumbWidth = 128;
protected int | Closure $thumbHeight = 128;
protected string | Closure $gap = 'gap-4';
protected string | Closure $rounded = 'rounded-lg';
protected bool | Closure $zoomCursor = true;
protected string | Closure $emptyText = 'No images';
protected string | Closure | null $wrapperClass = null;
public function thumbWidth(int | Closure $width): static
{
$this->thumbWidth = $width;
return $this;
}
public function getThumbWidth(): int
{
return $this->evaluate($this->thumbWidth);
}
public function thumbHeight(int | Closure $height): static
{
$this->thumbHeight = $height;
return $this;
}
public function getThumbHeight(): int
{
return $this->evaluate($this->thumbHeight);
}
public function gap(string | Closure $gap): static
{
$this->gap = $gap;
return $this;
}
public function getGap(): string
{
return $this->evaluate($this->gap);
}
public function rounded(string | Closure $rounded): static
{
$this->rounded = $rounded;
return $this;
}
public function getRounded(): string
{
return $this->evaluate($this->rounded);
}
public function zoomCursor(bool | Closure $condition = true): static
{
$this->zoomCursor = $condition;
return $this;
}
public function hasZoomCursor(): bool
{
return $this->evaluate($this->zoomCursor);
}
public function emptyText(string | Closure $text): static
{
$this->emptyText = $text;
return $this;
}
public function getEmptyText(): string
{
return $this->evaluate($this->emptyText);
}
public function wrapperClass(string | Closure | null $class): static
{
$this->wrapperClass = $class;
return $this;
}
public function getWrapperClass(): ?string
{
return $this->evaluate($this->wrapperClass);
}
/**
* Get normalized image URLs from state
*/
public function getImageUrls(): array
{
$state = $this->getState();
if (empty($state)) {
return [];
}
return collect($state)->map(function ($item) {
if (is_string($item)) {
return $item;
}
if (is_array($item)) {
return $item['image'] ?? $item['url'] ?? null;
}
if (is_object($item)) {
return $item->image ?? $item->url ?? null;
}
return null;
})->filter()->values()->toArray();
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace Alsaloul\ImageGallery\Tables\Columns;
use Closure;
use Filament\Tables\Columns\Column;
class ImageGalleryColumn extends Column
{
protected string $view = 'image-gallery::columns.image-gallery';
protected int | Closure $thumbWidth = 40;
protected int | Closure $thumbHeight = 40;
protected int | Closure | null $limit = 3;
protected bool | Closure $isStacked = false;
protected int | Closure $stackedOverlap = 2;
protected bool | Closure $isSquare = false;
protected bool | Closure $isCircle = false;
protected int | Closure $ringWidth = 1;
protected string | Closure | null $ringColor = null;
protected bool | Closure $showRemainingText = true;
protected string | Closure $emptyText = 'No images';
public function thumbWidth(int | Closure $width): static
{
$this->thumbWidth = $width;
return $this;
}
public function getThumbWidth(): int
{
return $this->evaluate($this->thumbWidth);
}
public function thumbHeight(int | Closure $height): static
{
$this->thumbHeight = $height;
return $this;
}
public function getThumbHeight(): int
{
return $this->evaluate($this->thumbHeight);
}
public function limit(int | Closure | null $limit): static
{
$this->limit = $limit;
return $this;
}
public function getLimit(): ?int
{
return $this->evaluate($this->limit);
}
public function stacked(int | bool | Closure $overlap = true): static
{
if (is_int($overlap)) {
$this->isStacked = true;
$this->stackedOverlap = $overlap;
} else {
$this->isStacked = $overlap;
}
return $this;
}
public function isStacked(): bool
{
return $this->evaluate($this->isStacked);
}
public function getStackedOverlap(): int
{
return $this->evaluate($this->stackedOverlap);
}
public function square(bool | Closure $condition = true): static
{
$this->isSquare = $condition;
return $this;
}
public function isSquare(): bool
{
return $this->evaluate($this->isSquare);
}
public function circle(bool | Closure $condition = true): static
{
$this->isCircle = $condition;
return $this;
}
public function isCircle(): bool
{
return $this->evaluate($this->isCircle);
}
public function ring(int | Closure $width = 2, string | Closure | null $color = null): static
{
$this->ringWidth = $width;
if ($color !== null) {
$this->ringColor = $color;
}
return $this;
}
public function ringColor(string | Closure | null $color): static
{
$this->ringColor = $color;
return $this;
}
public function getRingWidth(): int
{
return $this->evaluate($this->ringWidth);
}
public function getRingColor(): ?string
{
return $this->evaluate($this->ringColor);
}
public function limitedRemainingText(bool | Closure $condition = true): static
{
$this->showRemainingText = $condition;
return $this;
}
public function shouldShowRemainingText(): bool
{
return $this->evaluate($this->showRemainingText);
}
public function emptyText(string | Closure $text): static
{
$this->emptyText = $text;
return $this;
}
public function getEmptyText(): string
{
return $this->evaluate($this->emptyText);
}
/**
* Get normalized image URLs from state
*/
public function getImageUrls(): array
{
$state = $this->getState();
if (empty($state)) {
return [];
}
return collect($state)->map(function ($item) {
if (is_string($item)) {
return $item;
}
if (is_array($item)) {
return $item['image'] ?? $item['url'] ?? null;
}
if (is_object($item)) {
return $item->image ?? $item->url ?? null;
}
return null;
})->filter()->values()->toArray();
}
}

0
stubs/.gitkeep Normal file
View File