From 1deb423acdd314a0a7f748b153086b9caecbc759 Mon Sep 17 00:00:00 2001 From: al-saloul Date: Thu, 11 Dec 2025 11:42:12 +0300 Subject: [PATCH] Initial release v1.0.0 --- CHANGELOG.md | 7 + LICENSE.md | 21 + README.md | 172 ++++++++ bin/build.js | 50 +++ composer.json | 71 ++++ config/image-gallery.php | 37 ++ configure.php | 370 ++++++++++++++++++ database/factories/ModelFactory.php | 19 + .../migrations/create_skeleton_table.php.stub | 19 + postcss.config.cjs | 8 + resources/css/index.css | 21 + resources/dist/.gitkeep | 0 resources/dist/image-gallery.css | 21 + resources/dist/image-gallery.js | 3 + resources/js/index.js | 3 + resources/lang/ar/messages.php | 5 + resources/lang/en/messages.php | 5 + resources/lang/en/skeleton.php | 6 + resources/views/.gitkeep | 0 .../views/columns/image-gallery.blade.php | 76 ++++ .../views/components/image-gallery.blade.php | 42 ++ .../views/components/viewer-script.blade.php | 125 ++++++ .../views/entries/image-gallery.blade.php | 36 ++ src/ImageGalleryPlugin.php | 37 ++ src/ImageGalleryServiceProvider.php | 49 +++ src/Infolists/Entries/ImageGalleryEntry.php | 135 +++++++ src/Tables/Columns/ImageGalleryColumn.php | 193 +++++++++ stubs/.gitkeep | 0 28 files changed, 1531 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 bin/build.js create mode 100644 composer.json create mode 100644 config/image-gallery.php create mode 100644 configure.php create mode 100644 database/factories/ModelFactory.php create mode 100644 database/migrations/create_skeleton_table.php.stub create mode 100644 postcss.config.cjs create mode 100644 resources/css/index.css create mode 100644 resources/dist/.gitkeep create mode 100644 resources/dist/image-gallery.css create mode 100644 resources/dist/image-gallery.js create mode 100644 resources/js/index.js create mode 100644 resources/lang/ar/messages.php create mode 100644 resources/lang/en/messages.php create mode 100644 resources/lang/en/skeleton.php create mode 100644 resources/views/.gitkeep create mode 100644 resources/views/columns/image-gallery.blade.php create mode 100644 resources/views/components/image-gallery.blade.php create mode 100644 resources/views/components/viewer-script.blade.php create mode 100644 resources/views/entries/image-gallery.blade.php create mode 100644 src/ImageGalleryPlugin.php create mode 100644 src/ImageGalleryServiceProvider.php create mode 100644 src/Infolists/Entries/ImageGalleryEntry.php create mode 100644 src/Tables/Columns/ImageGalleryColumn.php create mode 100644 stubs/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..767365d --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..58c9ad4 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) :vendor_name + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..da01e57 --- /dev/null +++ b/README.md @@ -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 + +``` + +#### 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. diff --git a/bin/build.js b/bin/build.js new file mode 100644 index 0000000..913baf4 --- /dev/null +++ b/bin/build.js @@ -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', +}) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2176e13 --- /dev/null +++ b/composer.json @@ -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 +} \ No newline at end of file diff --git a/config/image-gallery.php b/config/image-gallery.php new file mode 100644 index 0000000..2cf7c96 --- /dev/null +++ b/config/image-gallery.php @@ -0,0 +1,37 @@ + [ + '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, + ], +]; diff --git a/configure.php b/configure.php new file mode 100644 index 0000000..de19a7b --- /dev/null +++ b/configure.php @@ -0,0 +1,370 @@ +#!/usr/bin/env php +" : '') . "\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('/.*/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); + } +} diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php new file mode 100644 index 0000000..c51604f --- /dev/null +++ b/database/factories/ModelFactory.php @@ -0,0 +1,19 @@ +id(); + + // add fields + + $table->timestamps(); + }); + } +}; diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..2855394 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + "postcss-import": {}, + "tailwindcss/nesting": {}, + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/resources/css/index.css b/resources/css/index.css new file mode 100644 index 0000000..d7bb10d --- /dev/null +++ b/resources/css/index.css @@ -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); +} diff --git a/resources/dist/.gitkeep b/resources/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/dist/image-gallery.css b/resources/dist/image-gallery.css new file mode 100644 index 0000000..04f69bd --- /dev/null +++ b/resources/dist/image-gallery.css @@ -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); +} \ No newline at end of file diff --git a/resources/dist/image-gallery.js b/resources/dist/image-gallery.js new file mode 100644 index 0000000..37e528d --- /dev/null +++ b/resources/dist/image-gallery.js @@ -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 diff --git a/resources/js/index.js b/resources/js/index.js new file mode 100644 index 0000000..60596e7 --- /dev/null +++ b/resources/js/index.js @@ -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 diff --git a/resources/lang/ar/messages.php b/resources/lang/ar/messages.php new file mode 100644 index 0000000..bad66a8 --- /dev/null +++ b/resources/lang/ar/messages.php @@ -0,0 +1,5 @@ + 'لا توجد صور', +]; diff --git a/resources/lang/en/messages.php b/resources/lang/en/messages.php new file mode 100644 index 0000000..4aba93b --- /dev/null +++ b/resources/lang/en/messages.php @@ -0,0 +1,5 @@ + 'No images', +]; diff --git a/resources/lang/en/skeleton.php b/resources/lang/en/skeleton.php new file mode 100644 index 0000000..d1b24d9 --- /dev/null +++ b/resources/lang/en/skeleton.php @@ -0,0 +1,6 @@ + 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 + +
+ @forelse($visibleUrls as $src) + image + @empty + {{ $getEmptyText() }} + @endforelse + + @if($shouldShowRemainingText() && $remaining > 0) + + +{{ $remaining }} + + @endif +
+ +@once + +@endonce diff --git a/resources/views/components/image-gallery.blade.php b/resources/views/components/image-gallery.blade.php new file mode 100644 index 0000000..c353a45 --- /dev/null +++ b/resources/views/components/image-gallery.blade.php @@ -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 + + + + diff --git a/resources/views/components/viewer-script.blade.php b/resources/views/components/viewer-script.blade.php new file mode 100644 index 0000000..6ce131a --- /dev/null +++ b/resources/views/components/viewer-script.blade.php @@ -0,0 +1,125 @@ +@once + + + + +@endonce diff --git a/resources/views/entries/image-gallery.blade.php b/resources/views/entries/image-gallery.blade.php new file mode 100644 index 0000000..d02b470 --- /dev/null +++ b/resources/views/entries/image-gallery.blade.php @@ -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 + + + + + +@once + @push('scripts') + + @endpush +@endonce diff --git a/src/ImageGalleryPlugin.php b/src/ImageGalleryPlugin.php new file mode 100644 index 0000000..e661543 --- /dev/null +++ b/src/ImageGalleryPlugin.php @@ -0,0 +1,37 @@ +getId()); + + return $plugin; + } +} diff --git a/src/ImageGalleryServiceProvider.php b/src/ImageGalleryServiceProvider.php new file mode 100644 index 0000000..2535d16 --- /dev/null +++ b/src/ImageGalleryServiceProvider.php @@ -0,0 +1,49 @@ +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'), + ]; + } +} diff --git a/src/Infolists/Entries/ImageGalleryEntry.php b/src/Infolists/Entries/ImageGalleryEntry.php new file mode 100644 index 0000000..5cac64e --- /dev/null +++ b/src/Infolists/Entries/ImageGalleryEntry.php @@ -0,0 +1,135 @@ +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(); + } +} diff --git a/src/Tables/Columns/ImageGalleryColumn.php b/src/Tables/Columns/ImageGalleryColumn.php new file mode 100644 index 0000000..84e5ecc --- /dev/null +++ b/src/Tables/Columns/ImageGalleryColumn.php @@ -0,0 +1,193 @@ +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(); + } +} diff --git a/stubs/.gitkeep b/stubs/.gitkeep new file mode 100644 index 0000000..e69de29