Compare commits

..

5 Commits

Author SHA1 Message Date
071eb18792 WIP 2024-06-21 19:27:40 +02:00
813874a847 WIP 2024-06-12 19:51:59 +02:00
0ce904a7d8 WIP 2024-06-12 19:51:41 +02:00
154e79aacd WIP 2024-06-07 16:26:15 +02:00
d01c7d3868 WIP 2024-06-05 14:12:54 +02:00
51 changed files with 1100 additions and 305 deletions

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreImageRequest;
use App\Http\Requests\UpdateImageRequest;
use App\Models\Image;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -37,9 +38,59 @@ class ImageController extends Controller
/**
* Display the specified resource.
*/
public function show(Image $image) : BinaryFileResponse
public function show(Image $image, string $size = 'original') : BinaryFileResponse|Response
{
return response()->file(Storage::disk('images')->path($image->album->id . '/thumbnail/' . $image->id . '.avif'));
$server = request()->server;
$headerEtag = md5($image->updated_at->format('U') . $image->id . '_' . $size);
$headerExpires = $image->updated_at->addYear()->toRfc2822String();
$headerLastModified = $image->updated_at->toRfc2822String();
$requestModifiedSince =
$server->has('HTTP_IF_MODIFIED_SINCE') &&
$server->get('HTTP_IF_MODIFIED_SINCE') === $headerLastModified;
$requestNoneMatch =
$server->has('HTTP_IF_NONE_MATCH') &&
$server->get('HTTP_IF_NONE_MATCH') === $headerEtag;
$headers = [
'Cache-Control' => 'max-age=0, must-revalidate, private',
'Content-Disposition' => sprintf('inline; filename="%s"', $image->id . '_' . $size),
'Etag' => $headerEtag,
'Expires' => $headerExpires,
'Last-Modified' => $headerLastModified,
'Pragma' => 'no-cache',
];
if ($requestModifiedSince || $requestNoneMatch) {
return response('', 304)->withHeaders($headers);
}
return response()->file(Storage::disk('images')->path($image->album->id . '/' . $size . '/' . $image->id . '.avif'), $headers);
}
/**
* Display the thumbnail of the specified resource.
*/
public function thumbnail(Image $image) : BinaryFileResponse|Response
{
return $this->show($image, 'thumbnail');
}
/**
* Display the lightbox of the specified resource.
*/
public function lightbox(Image $image) : BinaryFileResponse|Response
{
return $this->show($image, 'lightbox');
}
/**
* Display the lightbox of the specified resource.
*/
public function download(Image $image)
{
return Storage::disk('images')->download($image->album_id . '/original/' . $image->id . '.avif', name: $image->album->name . '_' . $image->id . '.avif');
}
/**

View File

@@ -2,11 +2,13 @@
namespace App\Importers;
use App\Importers\Image\Jobs\FinishImageModification;
use App\ImportsMedia;
use Illuminate\Http\UploadedFile;
use App\Importers\Image\Jobs\{ImportImage, GenerateFullscreen, GenerateThumbnail};
use App\Models\Image as ImageModel;
use App\Models\Album;
use Illuminate\Support\Facades\Bus;
class Image implements ImportsMedia {
@@ -19,16 +21,19 @@ class Image implements ImportsMedia {
{
}
public function import(): array {
public function import(): void {
$image = ImageModel::create([
'album_id' => $this->location->id
]);
$imageId = $image->id;
return [
Bus::chain([
new ImportImage($this->file->getPathname(), $image),
Bus::batch([
new GenerateFullscreen($image),
new GenerateThumbnail($image),
];
]),
new FinishImageModification($image),
])->dispatch();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Importers\Image\Jobs;
use App\Models\Image;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use \Throwable;
use Illuminate\Support\Facades\Log;
class FinishImageModification implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;
public function __construct(public Image $image)
{
}
public function handle(): void
{
if (method_exists($this, 'batch') && $this->batch()?->cancelled()) {
return;
}
$this->image->fill([
'isCover' => $this->getCoverFlag(),
'isProcessing' => false,
]);
$this->image->save();
// TODO: Dispatch image cleaned event
}
private function getCoverFlag() : bool {
if(!$this->image->album->hasCover && $this->image->album->images->sortBy('id')->first()->id == $this->image->id) {
return true;
}
return false;
}
public function failed(?Throwable $exception): void
{
Log::error($exception, [
'image' => $this->image,
]);
}
}

View File

@@ -29,20 +29,15 @@ class GenerateFullscreen implements ShouldQueue
public function handle(): void
{
if ($this->batch()->cancelled()) {
if (method_exists($this, 'batch') && $this->batch()?->cancelled()) {
return;
}
$image = InterventionImage::read($this->source);
if($image->width() >= $image->height()) {
// landscape
$image->scaleDown(width: config('gallery.image.fullscreen.maxWidth', 2000));
} else {
// portrait
$image->scaleDown(height: config('gallery.image.fullscreen.maxHeight', 2000));
}
$lightbox = InterventionImage::read($this->source);
$lightbox = $lightbox->scaleDown(height: config('gallery.image.fullscreen.height', 2000));
Storage::disk('images')->put($this->destination, $image->toAvif(config('gallery.image.quality', 80)));
Storage::disk('images')->put($this->destination, $lightbox->toAvif(config('gallery.image.quality', 80)));
$this->image->setLightboxSize($lightbox->width(), $lightbox->height());
}
public function failed(?Throwable $exception): void

View File

@@ -29,20 +29,14 @@ class GenerateThumbnail implements ShouldQueue
public function handle(): void
{
if ($this->batch()->cancelled()) {
if (method_exists($this, 'batch') && $this->batch()?->cancelled()) {
return;
}
$image = InterventionImage::read($this->source);
if($image->width() >= $image->height()) {
// landscape
$image->scaleDown(width: config('gallery.image.thumbnail.maxWidth', 150));
} else {
// portrait
$image->scaleDown(height: config('gallery.image.thumbnail.maxHeight', 150));
}
$thumbnail = InterventionImage::read($this->source);
$thumbnail = $thumbnail->scaleDown(height: config('gallery.image.thumbnail.height', 640), width: config('gallery.image.thumbnail.width', 640));
Storage::disk('images')->put($this->destination, $image->toAvif(config('gallery.image.quality', 80)));
Storage::disk('images')->put($this->destination, $thumbnail->toAvif(config('gallery.image.quality', 80)));
}
public function failed(?Throwable $exception): void

View File

@@ -24,7 +24,7 @@ class ImportImage implements ShouldQueue
public function handle(): void
{
if ($this->batch()->cancelled()) {
if (method_exists($this, 'batch') && $this->batch()?->cancelled()) {
return;
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Importers\Image\Jobs;
use App\Models\Image;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image as InterventionImage;
use \Throwable;
use Illuminate\Support\Facades\Log;
class RotateImage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;
public string $source;
public string $destination;
public function __construct(public Image $image, public int $degrees)
{
$this->source = Storage::disk('images')->path($this->image->album_id . '/original/' . $this->image->id . '.avif');
$this->destination = $this->image->album_id . '/original/' . $this->image->id . '.avif';
}
public function handle(): void {
if (method_exists($this, 'batch') && $this->batch()?->cancelled()) {
return;
}
$rotated = InterventionImage::read($this->source);
$rotated = $rotated->rotate($this->degrees);
Storage::disk('images')->put($this->destination, $rotated->toAvif(config('gallery.image.quality', 80)));
}
public function failed(?Throwable $exception): void
{
Log::error($exception, [
'image' => $this->image,
'source' => $this->source,
'destination' => $this->destination
]);
}
}

View File

@@ -9,5 +9,5 @@ interface ImportsMedia
{
public static function supports(UploadedFile $file): bool;
public function __construct(UploadedFile $file, Album $location);
public function import(): array;
public function import(): void;
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Livewire\Album;
use App\Importers\Image\Jobs\FinishImageModification;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Bus;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
use App\Models\Album;
use App\Models\Image;
use App\Importers\Image\Jobs\RotateImage;
use App\Importers\Image\Jobs\GenerateFullscreen;
use App\Importers\Image\Jobs\GenerateThumbnail;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
class Show extends Component
{
#[Locked]
public Album $album;
#[Locked]
public EloquentCollection $images;
public function mount(Album $album): void {
$this->album = $album;
$this->images = $album->images;
}
#[On('image.rotate')]
public function rotate(int $image_id, string $direction):void {
$degrees = match ($direction) {
'cw' => -90,
'ccw' => 90,
default => 0,
};
$this->dispatchRotateJob(Image::findOrFail($image_id), $degrees);
$this->redirect(route('album.show', $this->album), navigate: true);
}
#[On('image.makeCover')]
public function makeCover(int $image_id):void {
Image::findOrFail($image_id)->makeCover();
}
private function dispatchRotateJob(Image $image, int $degrees) : void {
$image->update([
'isProcessing' => true,
]);
Bus::chain([
new RotateImage($image, $degrees),
Bus::batch([
new GenerateFullscreen($image),
new GenerateThumbnail($image),
]),
new FinishImageModification($image),
])->dispatch();
}
public function render(): View|Factory
{
return view('livewire.album.show')
->title($this->album->name);
}
}

View File

@@ -2,15 +2,22 @@
namespace App\Livewire;
use Illuminate\Support\Collection;
use Livewire\Component;
use App\Models\Tag;
use App\Models\Category;
use Livewire\Attributes\Computed;
class CategoryFilter extends Component
{
public ?Tag $filter = null;
public function setFilter(int $filter) {
#[Computed]
public function categories() : Collection {
return $this->filter?->categories ?? Category::all();
}
public function setFilter(int $filter) : void {
$this->filter = Tag::find($filter);
}
@@ -18,7 +25,6 @@ class CategoryFilter extends Component
{
return view('livewire.category-filter', [
'tags' => Tag::all(),
'categories' => Category::all(),
]);
}
}

View File

@@ -9,7 +9,6 @@ use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Bus;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\WithFileUploads;
@@ -30,17 +29,9 @@ class AddImage extends Component
public function save(MediaImporter $importer) : void {
$this->validate();
$jobs = array_map(fn($file) => $importer->import($file, $this->album), $this->media);
$batch = Bus::batch($jobs)
->name('Media import in ' . $this->album->name)
->allowFailures()
->dispatch();
BatchMutation::create([
'album_id' => $this->album->id,
'batch_id' => $batch->id,
]);
foreach ($this->media as $file) {
$importer->import($file, $this->album);
}
$this->redirect(route('album.show', $this->album), navigate: true);
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Image;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\Attributes\On;
class Grid extends Component
{
public Collection $images;
#[On('image.rotate')]
public function rotate(int id, string $direction) : void {
$degree = match ($direction) {
'ccw' => -90,
'cw' => 90,
default => 0,
}
}
public function mount(Collection $images): void {
$this->images = $images;
}
public function render(): View|Factory
{
return view('livewire.image.grid');
}
}

19
app/Livewire/Menu.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Livewire;
use Livewire\Component;
class Menu extends Component
{
public string $title;
public function mount(?string $title) : void {
$this->title = $title ?? config('app.name');
}
public function render()
{
return view('livewire.menu');
}
}

View File

@@ -28,6 +28,14 @@ class Album extends Model
return $this->images;
}
public function getHasCoverAttribute() : bool {
return $this->images()->where('isCover', 1)->count() > 0;
}
public function getHasProcessingMediaAttribute() : bool {
return $this->images()->where('isProcessing', 1)->count() > 0;
}
public function getThumbnailAttribute() : ?string {
return $this->images()->where('isCover', 1)->first()?->getThumbnail();
}

View File

@@ -18,6 +18,9 @@ class Image extends Model implements HasThumbnail
*/
protected $attributes = [
'isCover' => false,
'isProcessing' => true,
'lightboxWidth' => 0,
'lightboxHeight' => 0,
];
/**
@@ -25,7 +28,7 @@ class Image extends Model implements HasThumbnail
*
* @var array
*/
protected $fillable = ['album_id'];
protected $fillable = ['album_id', 'lightboxWidth', 'lightboxHeight', 'isCover', 'isProcessing'];
public function album(): BelongsTo
{
@@ -33,6 +36,30 @@ class Image extends Model implements HasThumbnail
}
public function getThumbnail() : string {
return route('image.show', $this);
return route('image.thumbnail', $this) . '?cacheBuster3000=' . $this->updated_at->timestamp;
}
public function getDownload() : string {
return route('image.download', $this) . '?cacheBuster3000=' . $this->updated_at->timestamp;
}
public function setLightboxSize(int $width, int $height) : void {
$this->lightboxWidth = $width;
$this->lightboxHeight = $height;
$this->save();
}
public function makeCover() : void {
Image::where('isCover', 1)->where('album_id', $this->album_id)->update(['isCover' => 0]);
$this->isCover = true;
$this->save();
}
public function getLightboxAttribute() : array {
return [
'location' => route('image.lightbox', $this) . '?cacheBuster3000=' . $this->updated_at->timestamp,
'width' => $this->lightboxWidth,
'height' => $this->lightboxHeight,
];
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Services;
use App\ImportsMedia;
use App\Models\Album;
use Illuminate\Foundation\Bus\PendingChain;
use Illuminate\Http\UploadedFile;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
@@ -17,10 +16,11 @@ class MediaImporter {
}
}
public function import(UploadedFile $file, Album $location): array {
public function import(UploadedFile $file, Album $location): void {
foreach ($this->importers as $importer) {
if($importer::supports($file)) {
return (new $importer($file, $location))->import();
(new $importer($file, $location))->import();
return;
}
}

View File

@@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Importers\Image\Jobs\FinishImageModification;
use App\Models\Album;
use App\Models\Image;
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -10,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image as InterventionImage;
use \App\Importers\Image\Jobs\GenerateThumbnail;
use \App\Importers\Image\Jobs\GenerateFullscreen;
use Illuminate\Support\Facades\Bus;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Image>
@@ -22,19 +24,20 @@ class ImageFactory extends Factory
public function configure(): static
{
return $this->afterCreating(function (Image $image) {
if($image->album->images->sortBy('id')->first()->id == $image->id) {
$image->isCover = true;
$image->save();
}
$height = rand(2000, 4000);
$width = rand(2000, 4000);
$image_content = Http::get("https://picsum.photos/{$width}/{$height}")->body();
$encoded = InterventionImage::read($image_content)->toAvif(config('gallery.image.quality'));
Storage::disk('images')->put($image->album->id . '/original/' . $image->id . '.avif', $encoded);
$image_path = $image->album_id . '/original/' . $image->id . '.avif';
Storage::disk('images')->put($image_path, $encoded);
GenerateThumbnail::dispatch($image);
GenerateFullscreen::dispatch($image);
Bus::chain([
Bus::batch([
new GenerateThumbnail($image),
new GenerateFullscreen($image),
]),
new FinishImageModification($image),
])->dispatch();
});
}

View File

@@ -16,6 +16,9 @@ return new class extends Migration
$table->id();
$table->timestamps();
$table->boolean('isCover');
$table->boolean('isProcessing');
$table->bigInteger(column: 'lightboxWidth', unsigned: true)->default(0);
$table->bigInteger(column: 'lightboxHeight', unsigned: true)->default(0);
$table->foreignIdFor(Album::class);
});
}

70
package-lock.json generated
View File

@@ -5,21 +5,20 @@
"packages": {
"": {
"dependencies": {
"@marcreichel/alpine-auto-animate": "^1.1.0",
"filepond": "^4.31.1",
"filepond-plugin-file-validate-size": "^2.2.8",
"filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-exif-orientation": "^1.0.11",
"filepond-plugin-image-preview": "^4.6.12",
"filepond-plugin-image-transform": "^3.8.7"
"photoswipe": "^5.4.4"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.19",
"axios": "^1.6.4",
"laravel-echo": "^1.16.1",
"laravel-vite-plugin": "^1.0",
"postcss": "^8.4.38",
"pusher-js": "^8.4.0-rc2",
"tailwindcss": "^3.4.3",
"vite": "^5.0"
}
@@ -404,6 +403,23 @@
"node": ">=12"
}
},
"node_modules/@formkit/auto-animate": {
"version": "1.0.0-pre-alpha.3",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-1.0.0-pre-alpha.3.tgz",
"integrity": "sha512-lMVZ3LFUIu0RIxCEwmV8nUUJQ46M2bv2NDU3hrhZivViuR1EheC8Mj5sx/ACqK5QLK8XB8z7GDIZBUGdU/9OZQ==",
"peerDependencies": {
"react": "^16.8.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -469,6 +485,14 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@marcreichel/alpine-auto-animate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@marcreichel/alpine-auto-animate/-/alpine-auto-animate-1.1.0.tgz",
"integrity": "sha512-ulKU3TAmZ/YkpO34j+tpGFSNSyvXAcprWkO6laFuLODx28zfJBi61TPkD3Tw9BQAl57jL91yybMFhRT3VHRPlQ==",
"dependencies": {
"@formkit/auto-animate": "^1.0.0-beta.3"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1204,14 +1228,6 @@
"filepond": ">=4.x <5.x"
}
},
"node_modules/filepond-plugin-image-transform": {
"version": "3.8.7",
"resolved": "https://registry.npmjs.org/filepond-plugin-image-transform/-/filepond-plugin-image-transform-3.8.7.tgz",
"integrity": "sha512-vgKwyIDG2y5twanf7YpqZvxkaLudTjwd9vRcoq5sQDB8egUlX5/NA0bQ0823pocrm0fjbFeetICu44mkqeDkIA==",
"peerDependencies": {
"filepond": ">=3.6.0 <5.x"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -1452,15 +1468,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/laravel-echo": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-1.16.1.tgz",
"integrity": "sha512-++Ylb6M3ariC9Rk5WE5gZjj6wcEV5kvLF8b+geJ5/rRIfdoOA+eG6b9qJPrarMD9rY28Apx+l3eelIrCc2skVg==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/laravel-vite-plugin": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.0.2.tgz",
@@ -1682,6 +1689,14 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/photoswipe": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-5.4.4.tgz",
"integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==",
"engines": {
"node": ">= 0.12.0"
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -1873,15 +1888,6 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/pusher-js": {
"version": "8.4.0-rc2",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0-rc2.tgz",
"integrity": "sha512-d87GjOEEl9QgO5BWmViSqW0LOzPvybvX6WA9zLUstNdB57jVJuR27zHkRnrav2a3+zAMlHbP2Og8wug+rG8T+g==",
"dev": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2256,12 +2262,6 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true
},
"node_modules/update-browserslist-db": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",

View File

@@ -15,10 +15,12 @@
"vite": "^5.0"
},
"dependencies": {
"@marcreichel/alpine-auto-animate": "^1.1.0",
"filepond": "^4.31.1",
"filepond-plugin-file-validate-size": "^2.2.8",
"filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-exif-orientation": "^1.0.11",
"filepond-plugin-image-preview": "^4.6.12"
"filepond-plugin-image-preview": "^4.6.12",
"photoswipe": "^5.4.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,5 +1,6 @@
@import 'filepond/dist/filepond.min.css';
@import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
@import 'photoswipe/dist/photoswipe.css';
@tailwind base;
@tailwind components;
@@ -31,3 +32,66 @@
@apply text-gray-900 dark:text-white;
}
.pswp__bg {
@apply backdrop-blur-md bg-white/30 dark:bg-gray-800/50;
}
.pswp__top-bar {
@apply fixed bottom-2 top-auto w-auto left-1/2 -translate-x-1/2 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800 border border-gray-200 dark:border-gray-600;
}
.pswp__button {
width: 60px;
}
.pswp--zoomed-in .pswp__zoom-icn-bar-b {
display: block;
}
.pswp__preloader {
display: none;
}
.pswp__counter {
@apply text-base text-gray-900 dark:text-white content-center px-2;
text-shadow: none;
}
#toaster {
z-index: 100001;
}
#toaster[data-expanded="true"] {
height: var(--full-height);
}
#toaster>div {
--scale: var(--index) * 0.05 + 1;
transform: translate(var(--swipe-amount, 0px), calc(14px * var(--index) + var(--front-toast-height) - 100%)) scale(calc(-1 * var(--scale)));
touch-action: none;
will-change: transform, opacity;
cursor: grab;
}
#toaster>div[data-swiping="true"] {
cursor: grabbing;
}
#toaster>div[data-removed="true"],
#toaster>div[data-hidden="true"] {
opacity: 0;
}
#toaster[data-expanded="true"]>div[data-hidden="true"] {
opacity: 100;
}
#toaster[data-expanded="true"]>div[data-front="true"],
#toaster:hover>div[data-front="true"] {
transform: translate(var(--swipe-amount, 0px), 0);
}
#toaster[data-expanded="true"]>div,
#toaster:hover>div {
transform: translate(var(--swipe-amount, 0px), calc(var(--index) * 14px + var(--offset))) scale(1);
}

View File

@@ -1,22 +1,8 @@
import './bootstrap';
import * as FilePond from 'filepond';
import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
import './lightbox';
import './filepond';
import './notification';
FilePond.registerPlugin(FilePondPluginImageExifOrientation);
FilePond.registerPlugin(FilePondPluginImagePreview);
FilePond.registerPlugin(FilePondPluginFileValidateSize);
FilePond.registerPlugin(FilePondPluginFileValidateType);
window.FilePond = FilePond;
document.addEventListener('alpine:init', () => {
Alpine.store('uploader', {
states: {},
setState(state, value) {
this.states[state] = value;
},
});
document.addEventListener('livewire:init', () => {
ToastinTakin.init();
});

21
resources/js/filepond.js Normal file
View File

@@ -0,0 +1,21 @@
import * as FilePond from 'filepond';
import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
FilePond.registerPlugin(FilePondPluginImageExifOrientation);
FilePond.registerPlugin(FilePondPluginImagePreview);
FilePond.registerPlugin(FilePondPluginFileValidateSize);
FilePond.registerPlugin(FilePondPluginFileValidateType);
window.FilePond = FilePond;
document.addEventListener('alpine:init', () => {
Alpine.store('uploader', {
states: {},
setState(state, value) {
this.states[state] = value;
},
});
});

100
resources/js/lightbox.js Normal file
View File

@@ -0,0 +1,100 @@
import PhotoSwipeLightbox from 'photoswipe/lightbox';
document.addEventListener('livewire:navigated', () => {
const lightbox = new PhotoSwipeLightbox({
gallery: '#album',
children: 'a',
bgOpacity: 1,
arrowPrevSVG: '<svg class="w-12 h-12 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 19-7-7 7-7"/></svg>',
arrowNextSVG: '<svg class="w-12 h-12 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 5 7 7-7 7"/></svg>',
closeSVG: '<div class="bg-transparent w-full h-full justify-around inline-flex items-center hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/></svg></div>',
zoomSVG: '<div class="bg-transparent w-full h-full justify-around inline-flex items-center hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg class="pswp__zoom-icn-bar-v w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m21 21-3.5-3.5M10 7v6m-3-3h6m4 0a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/></svg><svg class="pswp__zoom-icn-bar-b hidden w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m21 21-3.5-3.5M7 10h6m4 0a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/></svg></div>',
pswpModule: () => import('photoswipe')
});
lightbox.on('uiRegister', function() {
lightbox.pswp.ui.registerElement({
name: 'download-button',
order: 9,
isButton: true,
tagName: 'button',
html: '<div class="bg-transparent w-full h-full justify-around inline-flex items-center hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"/></svg></div>',
onInit: (el, pswp) => {
el.setAttribute('download', '');
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener');
pswp.on('change', () => {
el.href = pswp.currSlide.data.element.dataset.pswpDownload;
});
}
});
lightbox.pswp.ui.registerElement({
name: 'makeCover',
ariaLabel: 'Make this Image the Album Cover',
order: 8,
isButton: true,
html: '<div class="w-full h-full bg-transparent inline-flex items-center justify-around hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg class="no-cover w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-width="2" d="M11.083 5.104c.35-.8 1.485-.8 1.834 0l1.752 4.022a1 1 0 0 0 .84.597l4.463.342c.9.069 1.255 1.2.556 1.771l-3.33 2.723a1 1 0 0 0-.337 1.016l1.03 4.119c.214.858-.71 1.552-1.474 1.106l-3.913-2.281a1 1 0 0 0-1.008 0L7.583 20.8c-.764.446-1.688-.248-1.474-1.106l1.03-4.119A1 1 0 0 0 6.8 14.56l-3.33-2.723c-.698-.571-.342-1.702.557-1.771l4.462-.342a1 1 0 0 0 .84-.597l1.753-4.022Z"/></svg><svg class="is-cover hidden w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M13.849 4.22c-.684-1.626-3.014-1.626-3.698 0L8.397 8.387l-4.552.361c-1.775.14-2.495 2.331-1.142 3.477l3.468 2.937-1.06 4.392c-.413 1.713 1.472 3.067 2.992 2.149L12 19.35l3.897 2.354c1.52.918 3.405-.436 2.992-2.15l-1.06-4.39 3.468-2.938c1.353-1.146.633-3.336-1.142-3.477l-4.552-.36-1.754-4.17Z"/></svg></div>',
onInit: (el, pswp) => {
pswp.on('change', () => {
el.querySelector('.is-cover').classList.toggle('hidden', pswp.currSlide.data.element.dataset.isCover === 'false')
el.querySelector('.no-cover').classList.toggle('hidden', pswp.currSlide.data.element.dataset.isCover === 'true')
});
},
onClick: (event, el) => {
Livewire.dispatch(`image.makeCover`, {
image_id: pswp.currSlide.data.element.dataset.id
});
pswp.currSlide.data.element.dataset.isCover = 'true';
el.querySelector('.is-cover').classList.toggle('hidden', pswp.currSlide.data.element.dataset.isCover === 'false')
el.querySelector('.no-cover').classList.toggle('hidden', pswp.currSlide.data.element.dataset.isCover === 'true')
ToastinTakin.success("Das Cover wurde ausgetauscht");
}
});
lightbox.pswp.ui.registerElement({
name: 'rotate-button-cw',
ariaLabel: 'Rotate clockwise',
order: 7,
isButton: true,
html: '<div class="bg-transparent w-full h-full justify-around inline-flex items-center hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" class="w-6 h-6 text-gray-800 dark:text-white" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="-1 -1 23 23"><path d="M19.407 11.376a7.552 7.552 0 1 1-2.212-5.34"/><path d="M17.555 2.612v4h-4"/></svg></div>',
onClick: (event, el) => {
pswp.close();
Livewire.dispatch(`image.rotate`, {
image_id: pswp.currSlide.data.element.dataset.id,
direction: 'cw'
});
}
});
lightbox.pswp.ui.registerElement({
name: 'rotate-button-ccw',
ariaLabel: 'Rotate counter-clockwise',
order: 6,
isButton: true,
html: '<div class="bg-transparent w-full h-full justify-around inline-flex items-center hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" class="w-6 h-6 text-gray-800 dark:text-white scale-x-[-1]" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="-1 -1 23 23"><path d="M19.407 11.376a7.552 7.552 0 1 1-2.212-5.34"/><path d="M17.555 2.612v4h-4"/></svg></div>',
onClick: (event, el) => {
pswp.close();
Livewire.dispatch(`image.rotate`, {
image_id: pswp.currSlide.data.element.dataset.id,
direction: 'cw'
});
}
});
});
lightbox.init();
document.addEventListener('livewire:init', () => {
Livewire.hook('morph.added', ({ el }) => {
if(el.dataset.pswpWidth !== undefined) {
if(lightbox.pswp !== undefined) {
lightbox.pswp.options.dataSource.items.push(el);
}
}
});
});
});

View File

@@ -0,0 +1,144 @@
import {renderToaster, renderToast} from './notification/ui';
import registerSwipe from './notification/touch';
const VISIBLE_TOASTS_AMOUNT = 3;
const TOAST_LIFETIME = 4000;
const GAP = 14;
const TIME_BEFORE_UNMOUNT = 200;
window.ToastinTakin = {
init() {
if (reinitializeToaster()) {
return;
}
renderToaster();
const toaster = document.getElementById("toaster");
registerMouseOver(toaster);
registerKeyboardShortcuts(toaster);
},
success(msg) {
ToastinTakin.show(msg, "success");
},
error(msg) {
ToastinTakin.show(msg, "error");
},
info(msg) {
ToastinTakin.show(msg, "info");
},
warning(msg) {
ToastinTakin.show(msg, "warning");
},
show(msg, type) {
const list = document.getElementById("toaster");
const { toast, id } = renderToast(list, msg, type);
const el = list.children[0];
const height = el.getBoundingClientRect().height;
el.dataset.initialHeight = height;
el.dataset.hidden = false;
list.style.setProperty("--front-toast-height", `${height}px`);
registerSwipe(id);
refreshProperties();
if(list.dataset.expanded === "false") {
registerRemoveTimeout(el);
}
},
remove(id) {
const el = document.querySelector(`[data-toast-id="${id}"]`);
if (!el) {
return;
}
el.dataset.removed = true;
refreshProperties();
const previousTid = el.dataset.unmountTid;
if (previousTid) window.clearTimeout(previousTid);
const tid = window.setTimeout(function () {
el.parentElement?.removeChild(el);
}, TIME_BEFORE_UNMOUNT);
el.dataset.unmountTid = tid;
},
};
function registerRemoveTimeout(el) {
const tid = window.setTimeout(function () {
ToastinTakin.remove(el.dataset.toastId);
}, TOAST_LIFETIME);
el.dataset.removeTid = tid;
}
function reinitializeToaster() {
const ol = document.getElementById("toaster");
if (!ol) {
return;
}
for (let i = 0; i < ol.children.length; i++) {
const el = ol.children[i];
const id = el.dataset.toastId;
registerSwipe(id);
refreshProperties();
registerRemoveTimeout(el);
}
return ol;
}
function registerMouseOver(ol) {
ol.addEventListener("mouseenter", function () {
ol.dataset.expanded = true;
for(let el of ol.children) {
clearRemoveTimeout(el);
}
});
ol.addEventListener("mouseleave", function () {
ol.dataset.expanded = false;
for(let el of ol.children) {
registerRemoveTimeout(el);
}
});
}
function registerKeyboardShortcuts(ol) {
window.addEventListener("keydown", function (e) {
if (e.altKey && e.code === "KeyT") {
if (ol.children.length === 0) {
return;
}
e.preventDefault();
ol.dataset.expanded = ol.dataset.expanded === "true" ? "false" : "true";;
}
});
}
function clearRemoveTimeout(el) {
const tid = el.dataset.removeTid;
if (tid) window.clearTimeout(tid);
}
function refreshProperties() {
const list = document.getElementById("toaster");
let heightsBefore = 0;
let removed = 0;
for (let i = 0; i < list.children.length; i++) {
const el = list.children[i];
if (el.dataset.removed === "true") {
removed++;
continue;
}
const idx = i - removed;
el.dataset.index = idx;
el.dataset.front = (idx === 0).toString();
el.dataset.hidden = (idx > VISIBLE_TOASTS_AMOUNT).toString();
el.style.setProperty("--index", idx);
el.style.setProperty("--offset", `${GAP * idx + heightsBefore}px`);
el.style.setProperty("z-index", list.children.length - i);
heightsBefore += Number(el.dataset.initialHeight);
}
list.style.setProperty('--full-height', `${heightsBefore + GAP * list.children.length}px`);
}

View File

@@ -0,0 +1,6 @@
export function genId() {
return (
Date.now().toString(36) +
Math.random().toString(36).substring(2, 12).padStart(12, 0)
);
}

View File

@@ -0,0 +1,41 @@
const SuccessIcon = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-green-500 bg-green-100 rounded-lg dark:bg-green-800 dark:text-green-200">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z"/>
</svg>
<span class="sr-only">Check icon</span>
</div>`;
const WarningIcon = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-orange-500 bg-orange-100 rounded-lg dark:bg-orange-700 dark:text-orange-200">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z"/>
</svg>
<span class="sr-only">Warning icon</span>
</div>`;
const InfoIcon = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-blue-500 bg-blue-100 rounded-lg dark:bg-blue-800 dark:text-blue-200">
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm9.408-5.5a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2h-.01ZM10 10a1 1 0 1 0 0 2h1v3h-1a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-1v-4a1 1 0 0 0-1-1h-2Z" clip-rule="evenodd"/>
</svg>
<span class="sr-only">Error icon</span>
</div>`;
const ErrorIcon = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-red-500 bg-red-100 rounded-lg dark:bg-red-800 dark:text-red-200">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 11.793a1 1 0 1 1-1.414 1.414L10 11.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L8.586 10 6.293 7.707a1 1 0 0 1 1.414-1.414L10 8.586l2.293-2.293a1 1 0 0 1 1.414 1.414L11.414 10l2.293 2.293Z"/>
</svg>
<span class="sr-only">Error icon</span>
</div>`;
export default function getIcon(type) {
switch (type) {
case "success": return SuccessIcon;
case "info": return InfoIcon;
case "warning": return WarningIcon;
case "error": return ErrorIcon;
default: return null;
}
};

View File

@@ -0,0 +1,57 @@
const SWIPE_THRESHOLD = 50;
export default function registerSwipe(id) {
const el = document.querySelector(`[data-toast-id="${id}"]`);
if (!el) {
return;
}
let dragStartTime = null;
let pointerStart = null;
el.addEventListener("pointerdown", function (event) {
dragStartTime = new Date();
event.target.setPointerCapture(event.pointerId);
if (event.target.tagName === "BUTTON") {
return;
}
el.dataset.swiping = true;
pointerStart = { x: event.clientX, y: event.clientY };
});
el.addEventListener("pointerup", function (event) {
pointerStart = null;
const swipeAmount = Number(
el.style.getPropertyValue("--swipe-amount").replace("px", "") || 0,
);
const timeTaken = new Date().getTime() - dragStartTime.getTime();
const velocity = Math.abs(swipeAmount) / timeTaken;
// Remove only if threshold is met
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
ToastinTakin.remove(id);
return;
}
el.style.setProperty("--swipe-amount", "0px");
el.dataset.swiping = false;
});
el.addEventListener("pointermove", function (event) {
if (!pointerStart) {
return;
}
const yPosition = event.clientY - pointerStart.y;
const xPosition = event.clientX - pointerStart.x;
const clampedX = Math.max(0, xPosition);
const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
const isAllowedToSwipe = Math.abs(clampedX) > swipeStartThreshold;
if (isAllowedToSwipe) {
el.style.setProperty("--swipe-amount", `${xPosition}px`);
} else if (Math.abs(yPosition) > swipeStartThreshold) {
// Swipe is heading into the wrong direction, possibly the user wants to abort.
pointerStart = null;
}
});
}

View File

@@ -0,0 +1,55 @@
import getIcon from './icons';
import { genId } from './helpers';
export function renderToaster() {
const toaster = document.createElement("div");
document.body.appendChild(toaster);
toaster.outerHTML = `
<div
aria-label="Notifications alt+T"
tabindex="-1"
data-expanded="false"
class="fixed right-4 top-4 z-50 w-80"
id="toaster"
></div>`;
return toaster;
}
export function renderToast(list, msg, type) {
const toast = document.createElement("div");
const id = genId();
const count = list.children.length;
const icon = getIcon(type);
list.prepend(toast);
toast.outerHTML = `
<div
aria-live="polite"
aria-atomic="true"
role="alert"
class="absolute select-none transition-opacity transition-transform flex items-center w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800"
tabindex="0"
data-toast-id="${id}"
data-removed="false"
data-hidden="true"
data-index="${0}"
data-front="true"
data-swiping="false"
data-swipe-out="false"
style="--index: 0; --offset: 0px; --initial-height: 0px;"
>
${ icon ? icon : ""}
<div class="ms-3 text-sm font-normal">
${msg}
</div>
<button onclick="ToastinTakin.remove('${id}')" type="button" class="ms-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#toast-success" aria-label="Close">
<span class="sr-only">Close</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
</button>
</div>`;
return {id, toast};
}

View File

@@ -1,52 +0,0 @@
@push('menu')
<x-menu-action tooltip="Einstellungen">
<svg class="w-5 h-5 mb-1 text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12.25V1m0 11.25a2.25 2.25 0 0 0 0 4.5m0-4.5a2.25 2.25 0 0 1 0 4.5M4 19v-2.25m6-13.5V1m0 2.25a2.25 2.25 0 0 0 0 4.5m0-4.5a2.25 2.25 0 0 1 0 4.5M10 19V7.75m6 4.5V1m0 11.25a2.25 2.25 0 1 0 0 4.5 2.25 2.25 0 0 0 0-4.5ZM16 19v-2"/>
</svg>
</x-menu-action>
<div class="flex items-center justify-center">
<x-drawer-trigger target="image-add" action="open">
<button data-tooltip-target="tooltip-new" type="button" class="inline-flex items-center justify-center w-10 h-10 font-medium bg-blue-600 rounded-full hover:bg-blue-700 group focus:ring-4 focus:ring-blue-300 focus:outline-none dark:focus:ring-blue-800">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 18">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 1v16M1 9h16"/>
</svg>
<span class="sr-only">Add Images</span>
</button>
</x-drawer-trigger>
</div>
<div id="tooltip-new" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Add images
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
@endpush
<x-layout>
<x-hero-search></x-hero-search>
<h1 class="mb-4 mx-8 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
{{ $album->name }}
</h1>
@if($album->mutations->count() > 0)
<livewire:drawer.album.progress-monitor :mutation="$album->mutations->first()"/>
@else
<div class="m-8 flex flex-wrap flex-row gap-4">
@foreach ($album->images as $image)
<figure class="relative group rounded-lg cursor-pointer h-80 flex-grow overflow-hidden">
<img class="max-h-full min-w-full align-bottom object-cover"
src="{{ $image->getThumbnail() }}" alt="{{ $album->name }} Bild">
<div class="opacity-0 group-hover:opacity-40 absolute inset-0 w-full h-full bg-black flex items-center justify-center transition-opacity">
<svg class="w-1/2 h-1/2 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
</div>
</figure>
@endforeach
</div>
<x-drawer name="image-add" >
<x-slot:title>Neue Bilder zu {{ $album->name }} hinzufügen</x-slot:title>
<x-slot:content>
<livewire:drawer.album.addImage :album="$album"></livewire:drawer.album.addImage>
</x-slot:content>
</x-drawer>
@endif
</x-layout>

View File

@@ -20,28 +20,15 @@
</div>
@endpush
<x-layout>
<x-hero-search></x-hero-search>
<h1 class="mb-4 mx-8 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
{{ $category->name }}
</h1>
<div class="m-8 flex flex-wrap flex-row gap-4">
@foreach ($albums as $album)
<figure class="relative rounded-lg cursor-pointer h-80 flex-grow overflow-hidden group">
<a href="{{ route('album.show', $album) }}" wire:navigate>
<img class="max-h-full min-w-full align-bottom object-cover"
src="{{ $album->thumbnail }}" alt="{{ $album->name }} Cover">
<figcaption class="absolute p-4 text-lg text-white top-1/2 bottom-0 bg-opacity-20 min-w-full bg-gradient-to-b from-transparent to-slate-900 flex flex-col-reverse">
<span class="z-10">{{ $album->name }}</span>
</figcaption>
<div class="opacity-0 z-0 group-hover:opacity-40 absolute inset-0 w-full h-full bg-black flex items-center justify-center transition-opacity">
<svg class="w-1/2 h-1/2 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
<x-layouts.app :title="$category->name">
<div class="flex flex-wrap flex-row gap-4">
<x-drawer-trigger target="album-add" action="open" class="relative rounded-lg cursor-pointer h-80 flex-grow flex items-center justify-center bg-gray-300 sm:w-96 dark:bg-gray-700 hover:opacity-50">
<svg class="w-24 h-24 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7 7V5"/>
</svg>
</div>
</a>
</figure>
</x-drawer-trigger>
@foreach ($albums as $album)
<x-album.element :album="$album"></x-album.element>
@endforeach
</div>
<x-drawer name="album-add" >
@@ -50,4 +37,4 @@
<livewire:drawer.album.create></livewire:drawer.album.create>
</x-slot:content>
</x-drawer>
</x-layout>
</x-layouts.app>

View File

@@ -0,0 +1,17 @@
@props(['album' => null])
<figure class="relative rounded-lg cursor-pointer h-80 flex-grow overflow-hidden group" {{ $attributes }}>
<a href="{{ route('album.show', $album) }}" wire:navigate>
<img class="max-h-full min-w-full align-bottom object-cover"
src="{{ $album->thumbnail }}" alt="{{ $album->name }} Cover">
<figcaption class="absolute p-4 text-xl text-white font-bold uppercase top-1/2 bottom-0 bg-opacity-20 min-w-full bg-gradient-to-b from-transparent to-black flex flex-col-reverse z-10">
<span class="z-1">{{ $album->name }}</span>
</figcaption>
<div class="opacity-0 z-0 group-hover:opacity-40 absolute inset-0 w-full h-full bg-black flex items-center justify-center transition-opacity">
<svg class="w-1/2 h-1/2 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
</div>
</a>
</figure>

View File

@@ -1,12 +1,14 @@
@props(['active' => false ])
@if($active)
<li {{ $attributes }} class="inline-block m-2 relative after:absolute after:left-0 after:bottom-1 after:border-yellow-pfadi after:-z-10 after:border-4 dark:after:opacity-80 after:w-full text-black dark:text-white">
<li {{ $attributes }} class="bg-yellow-pfadi text-gray-800 font-medium px-4 py-1 rounded-lg cursor-pointer">
<!--<li {{ $attributes }} class="inline-block m-2 relative after:absolute after:left-0 after:bottom-1 after:border-yellow-pfadi after:-z-10 after:border-4 dark:after:opacity-80 after:w-full text-black dark:text-white">-->
{{ $slot }}
</li>
@else
<li {{ $attributes }} class="inline-block m-2 dark:text-gray-100 dark:hover:text-white text-gray-400 relative hover:text-black after:w-0
cursor-pointer after:border-0 after:absolute after:left-0 after:bottom-1 dark:after:opacity-80 after:border-yellow-pfadi hover:after:border-4 hover:after:w-full after:transition-all after:duration-100 after:ease-out after:-z-10">
<li {{ $attributes }} class="bg-gray-100 text-gray-800 font-medium px-4 py-1 rounded-lg dark:bg-gray-700 dark:text-gray-300 cursor-pointer">
<!--<li {{ $attributes }} class="inline-block m-2 dark:text-gray-100 dark:hover:text-white text-gray-400 relative hover:text-black after:w-0
cursor-pointer after:border-0 after:absolute after:left-0 after:bottom-1 dark:after:opacity-80 after:border-yellow-pfadi hover:after:border-4 hover:after:w-full after:transition-all after:duration-100 after:ease-out after:-z-10">-->
{{ $slot }}
</li>
@endif

View File

@@ -0,0 +1,11 @@
@props(['category' => null])
<figure class="relative rounded-lg cursor-pointer h-80 flex-grow overflow-hidden" {{ $attributes }}>
<a href="{{ route('category.show', $category) }}">
<img class="max-h-full min-w-full align-bottom object-cover"
src="{{ url($category->cover) }}" alt="{{ $category->name }} Cover">
<figcaption class="absolute p-4 text-xl text-white font-bold uppercase top-1/2 bottom-0 bg-opacity-20 min-w-full bg-gradient-to-b from-transparent to-black flex flex-col-reverse">
<span>{{ $category->name }}</span>
</figcaption>
</a>
</figure>

View File

@@ -1,3 +1,3 @@
<div @click="$dispatch('drawer-{{ $action }}-{{ $target }}')" x-on:touchstart="$dispatch('drawer-{{ $action }}-{{ $target }}')">
<div @click="$dispatch('drawer-{{ $action }}-{{ $target }}')" x-on:touchstart="$dispatch('drawer-{{ $action }}-{{ $target }}')" {{ $attributes }}>
{{ $slot }}
</div>

View File

@@ -1,7 +1,7 @@
<template x-teleport="body">
<div x-cloak
x-data="{ show: false }"
class="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 justify-center items-center w-full md:inset-0 h-screen max-h-full flex backdrop-blur-md bg-white/30 dark:bg-gray-800/30"
class="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 justify-center items-center w-full md:inset-0 h-screen max-h-full flex backdrop-blur-md bg-white/30 dark:bg-gray-800/30 z-30"
:class="{ 'translate-y-full': !show }"
@click.self="$dispatch('drawer-close-{{ $name }}')"
x-on:touchstart.self="$dispatch('drawer-close-{{ $name }}')"
@@ -9,7 +9,7 @@
<div
id="drawer-{{ $name }}"
tabindex="-1"
class="fixed z-40 w-full overflow-y-auto bg-white border-t border-gray-200 rounded-t-lg dark:border-gray-700 dark:bg-gray-800 transition-all max-xl:left-0 max-xl:right-0 max-xl:bottom-0 max-h-full xl:m-auto xl:relative xl:p-4 xl:w-max xl:max-w-7xl xl:rounded-lg xl:border"
class="fixed z-40 w-full overflow-y-auto bg-white border-t border-gray-200 rounded-t-lg dark:border-gray-700 dark:bg-gray-800 transition-all max-xl:left-0 max-xl:right-0 max-xl:bottom-0 max-h-full xl:m-auto xl:relative xl:p-4 xl:w-max xl:max-w-7xl xl:rounded-lg xl:border z-50"
aria-labelledby="drawer-{{ $name }}"
@drawer-open-{{ $name }}.window="$dispatch('menu-hide'); show = true"
@drawer-close-{{ $name }}.window="show = false; $dispatch('menu-show')"

View File

@@ -1,17 +0,0 @@
@persist('search')
<form class="flex h-[300px] relative overflow-hidden mb-8">
<img class="absolute object-cover" src="/placeholder.jpg" />
<div class="relative m-auto w-1/2">
<label for="default-search" class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Suchen</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
</div>
<input type="search" id="default-search" class="block w-full p-4 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Nach Alben suchen" required />
<button type="submit" class="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Suchen</button>
</div>
</div>
</form>
@endpersist

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
x-data="{ darkMode: $persist(false) }"
:class="{'dark': darkMode}"
x-init="
if (!('darkMode' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) {
localStorage.setItem('darkMode', JSON.stringify(true));
}
darkMode = JSON.parse(localStorage.getItem('darkMode'));
$watch('darkMode', value => localStorage.setItem('darkMode', JSON.stringify(value)))"
>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@vite('resources/css/app.css')
@vite('resources/js/app.js')
<title>{{ $title ?? config('app.name') }}</title>
</head>
<body class="bg-white dark:bg-gray-800 min-h-screen flex flex-col">
@persist('theme-switcher')
<x-theme-switcher />
@endpersist
{{ $slot }}
<x-menu />
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
const darkMode = localStorage.getItem('darkMode') || matchMedia('(prefers-color-scheme: light)').matches;
document.documentElement.dataset.dark = darkMode;
</script>
@vite('resources/css/app.css')
@vite('resources/js/app.js')
<title>{{ $title ?? config('app.name') }}</title>
</head>
<body class="bg-white dark:bg-gray-800 min-h-screen flex flex-col px-12 py-12 px-24">
<livewire:menu :title="$title ?? ''"></livewire:menu>
{{ $slot }}
</body>
</html>

View File

@@ -1,9 +0,0 @@
@props(['tooltip' => 'Unknown' ])
<button type="button" class="inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group first:rounded-s-full last:rounded-e-full">
{{ $slot }}
<span class="sr-only">{{ $tooltip }}</span>
<div role="tooltip" class="absolute z-10 group-hover:visible group-hover:opacity-100 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 dark:bg-gray-700 -translate-y-full origin-top-left -mt-2 top-0">
{{ $tooltip }}
</div>
</button>

View File

@@ -1,22 +0,0 @@
<div
class="fixed z-50 w-11/12 h-16 max-w-lg -translate-x-1/2 bg-white border border-gray-200 rounded-full left-1/2 dark:bg-gray-700 dark:border-gray-600 transition-all"
x-data="{ visible: true }"
@menu-hide.window="visible = false"
@menu-show.window="visible = true"
:class="{ '-bottom-16': !visible, 'bottom-4': visible }"
x-cloak
>
<div class="grid grid-flow-col h-full max-w-lg mx-auto">
<x-menu-action tooltip="Startseite">
<svg class="w-5 h-5 mb-1 text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="m19.707 9.293-2-2-7-7a1 1 0 0 0-1.414 0l-7 7-2 2a1 1 0 0 0 1.414 1.414L2 10.414V18a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h3a2 2 0 0 0 2-2v-7.586l.293.293a1 1 0 0 0 1.414-1.414Z"/>
</svg>
</x-menu-action>
@stack('menu')
<x-menu-action tooltip="Profil">
<svg class="w-5 h-5 mb-1 text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 0a10 10 0 1 0 10 10A10.011 10.011 0 0 0 10 0Zm0 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm0 13a8.949 8.949 0 0 1-4.951-1.488A3.987 3.987 0 0 1 9 13h2a3.987 3.987 0 0 1 3.951 3.512A8.949 8.949 0 0 1 10 18Z"/>
</svg>
</x-menu-action>
</div>
</div>

View File

@@ -1,14 +0,0 @@
<button x-cloak id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button"
class="text-gray-500 inline-flex items-center justify-center dark:text-gray-400 hover:bg-gray-100 w-10 h-10 dark:hover:bg-gray-700 focus:outline-none rounded-lg text-sm p-2.5 right-0 absolute z-10"
@click="darkMode = !darkMode"
>
<svg x-show="!darkMode" id="theme-toggle-dark-icon" class="w-8 h-8" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 20">
<path d="M17.8 13.75a1 1 0 0 0-.859-.5A7.488 7.488 0 0 1 10.52 2a1 1 0 0 0 0-.969A1.035 1.035 0 0 0 9.687.5h-.113a9.5 9.5 0 1 0 8.222 14.247 1 1 0 0 0 .004-.997Z"></path>
</svg>
<svg x-show="darkMode" id="theme-toggle-light-icon" class="w-8 h-8" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0-11a1 1 0 0 0 1-1V1a1 1 0 0 0-2 0v2a1 1 0 0 0 1 1Zm0 12a1 1 0 0 0-1 1v2a1 1 0 1 0 2 0v-2a1 1 0 0 0-1-1ZM4.343 5.757a1 1 0 0 0 1.414-1.414L4.343 2.929a1 1 0 0 0-1.414 1.414l1.414 1.414Zm11.314 8.486a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414ZM4 10a1 1 0 0 0-1-1H1a1 1 0 0 0 0 2h2a1 1 0 0 0 1-1Zm15-1h-2a1 1 0 1 0 0 2h2a1 1 0 0 0 0-2ZM4.343 14.243l-1.414 1.414a1 1 0 1 0 1.414 1.414l1.414-1.414a1 1 0 0 0-1.414-1.414ZM14.95 6.05a1 1 0 0 0 .707-.293l1.414-1.414a1 1 0 1 0-1.414-1.414l-1.414 1.414a1 1 0 0 0 .707 1.707Z"></path>
</svg>
<span x-show="!darkMode" class="sr-only">Dunkle Theme aktivieren</span>
<span x-show="darkMode" class="sr-only">Helle Theme aktivieren</span>
</button>

View File

@@ -0,0 +1,60 @@
<div>
<div id="album">
<div class="flex flex-wrap flex-row gap-4" @if($album->hasProcessingMedia) wire:poll @endif>
<div class="relative rounded-lg overflow-hidden cursor-pointer h-80 flex-grow flex items-center justify-center sm:w-96 flex flex-col gap-1">
@if(!$album->hasProcessingMedia)
<x-drawer-trigger target="image-add" action="open" class="flex-grow w-full text-center bg-gray-300 dark:bg-gray-700 hover:opacity-80 flex items-center justify-center transition-opacity">
<svg class="w-24 h-24 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7 7V5"/>
</svg>
</x-drawer-trigger>
@endif
<x-drawer-trigger target="image-add" action="open" class="flex-grow w-full text-center bg-gray-300 dark:bg-gray-700 hover:opacity-80 flex items-center justify-center transition-opacity">
<svg class="w-24 h-24 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M20 6H10m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4m16 6h-2m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4m16 6H10m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4"/>
</svg>
</x-drawer-trigger>
</div>
@foreach ($images as $image)
@if($image->isProcessing)
<div wire:transition wire:key="placeholder_{{ $image->id }}" role="status" class="flex items-center justify-center h-80 flex-grow bg-gray-300 rounded-lg animate-pulse dark:bg-gray-700 min-w-80">
<div class="min-w-lg">
<svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 18">
<path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z"/>
</svg>
<span class="sr-only">Vorschaubild wird generiert, bitte warten...</span>
</div>
</div>
@else
<a
class="relative group rounded-lg cursor-pointer h-80 flex-grow overflow-hidden"
href="{{ $image->lightbox['location'] }}"
data-pswp-width="{{ $image->lightbox['width'] }}"
data-pswp-download="{{ $image->getDownload() }}"
data-pswp-height="{{ $image->lightbox['height'] }}"
data-cropped="true"
data-is-cover="{{ $image->isCover ? 'true' : 'false' }}"
data-id="{{ $image->id }}"
wire:key="image_{{ $image->id }}"
wire:transition
>
<img class="max-h-full min-w-full align-bottom object-cover"
src="{{ $image->getThumbnail() }}" alt="Album image">
<div class="opacity-0 group-hover:opacity-40 absolute inset-0 w-full h-full bg-black flex items-center justify-center transition-opacity">
<svg class="w-1/2 h-1/2 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
</div>
</a>
@endif
@endforeach
</div>
</div>
<x-drawer name="image-add" >
<x-slot:title>Neue Bilder zu {{ $album->name }} hinzufügen</x-slot:title>
<x-slot:content>
<livewire:drawer.album.addImage :album="$album"></livewire:drawer.album.addImage>
</x-slot:content>
</x-drawer>
</div>

View File

@@ -1,6 +1,6 @@
<div class="p-2 mt-8">
<div class="text-center">
<ul class="uppercase font-medium">
<div>
<div class="mb-8">
<ul class="uppercase font-medium flex gap-12 justify-center">
<x-category-filter-pill wire:click="setFilter(-1)" :active="$filter == null">
Alle
</x-category-filter-pill>
@@ -13,19 +13,9 @@
</ul>
</div>
<div class="m-8 flex flex-wrap flex-row gap-4">
@foreach ($categories as $category)
@if($filter == null || $category->tags->contains($filter))
<figure wire:transition wire:key="{{ $category->id }}" class="relative rounded-lg cursor-pointer h-80 flex-grow overflow-hidden">
<a href="{{ route('category.show', $category) }}">
<img class="max-h-full min-w-full align-bottom object-cover"
src="{{ url($category->cover) }}" alt="{{ $category->name }} Cover">
<figcaption class="absolute p-4 text-lg text-white top-1/2 bottom-0 bg-opacity-20 min-w-full bg-gradient-to-b from-transparent to-slate-900 flex flex-col-reverse">
<span>{{ $category->name }}</span>
</figcaption>
</a>
</figure>
@endif
<div class="flex flex-wrap flex-row gap-4 transition-opacity" wire:loading.class="opacity-0">
@foreach ($this->categories as $category)
<x-category.element :category="$category" wire:key="{{ $category->id }}"></x-category.element>
@endforeach
</div>
</div>

View File

@@ -0,0 +1,21 @@
<div id="album" class="m-8 flex flex-wrap flex-row gap-4">
@foreach ($images as $image)
<a
class="relative group rounded-lg cursor-pointer h-80 flex-grow overflow-hidden"
href="{{ $image->lightbox['location'] }}"
data-pswp-width="{{ $image->lightbox['width'] }}"
data-pswp-download="{{ $image->getDownload() }}"
data-pswp-height="{{ $image->lightbox['height'] }}"
data-cropped="true"
>
<img class="max-h-full min-w-full align-bottom object-cover"
src="{{ $image->getThumbnail() }}" alt="Album image">
<div class="opacity-0 group-hover:opacity-40 absolute inset-0 w-full h-full bg-black flex items-center justify-center transition-opacity">
<svg class="w-1/2 h-1/2 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
</div>
</a>
@endforeach
</div>

View File

@@ -0,0 +1,43 @@
<div class="w-full flex flex-row items-center justify-between mb-12">
<div class="flex flex-row gap-4">
<a href="javascript:history.back();">
<svg class="w-8 h-8 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 19-7-7 7-7"/>
</svg>
</a>
<a href="/" wire:navigate>
<svg class="w-8 h-8 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 12 8-8 8 8M6 10.5V19a1 1 0 0 0 1 1h3v-3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3h3a1 1 0 0 0 1-1v-8.5"/>
</svg>
</a>
</div>
<h1 class="text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white inline-block relative after:absolute after:left-0 after:bottom-1 after:border-yellow-pfadi after:-z-10 after:border-4 dark:after:opacity-80 after:w-full text-black dark:text-white">
{{ $title }}
</h1>
<div class="flex flex-row gap-4">
<form class="relative text-right w-48">
<input type="search" name="" placeholder="Search for..." class="peer absolute -top-1 right-0 block p-2.5 w-8 focus:w-48 text-sm text-gray-900 bg-gray-50 rounded-lg focus:ring-0 focus:border-2 focus:border-yellow-pfadi dark:bg-gray-700 dark:border-s-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white opacity-0 focus:opacity-100 transition-all">
<button type="submit" class="absolute top-0 right-0 pointer-events-none transition-colors peer-focus:opacity-0">
<svg class="w-8 h-8 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
<span class="sr-only">Search</span>
</button>
</form>
<div @click="darkMode = !darkMode"
x-data="{darkMode: $persist(matchMedia('(prefers-color-scheme: dark)').matches).as('darkMode')}"
x-init="$watch('darkMode', darkMode => document.documentElement.dataset.dark = darkMode)"
class="w-8 h-8">
<svg x-show="!darkMode" class="w-8 h-8 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" x-cloak>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 21a9 9 0 0 1-.5-17.986V3c-.354.966-.5 1.911-.5 3a9 9 0 0 0 9 9c.239 0 .254.018.488 0A9.004 9.004 0 0 1 12 21Z"/>
</svg>
<svg x-show="darkMode" class="w-8 h-8 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" x-cloak>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5V3m0 18v-2M7.05 7.05 5.636 5.636m12.728 12.728L16.95 16.95M5 12H3m18 0h-2M7.05 16.95l-1.414 1.414M18.364 5.636 16.95 7.05M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>
</svg>
<span x-show="!darkMode" class="sr-only">Dunkle Theme aktivieren</span>
<span x-show="darkMode" class="sr-only">Helle Theme aktivieren</span>
</div>
</div>
</div>

View File

@@ -1,5 +1,3 @@
<x-layout>
<x-hero-search></x-hero-search>
<x-layouts.app title="Pfadi Säuliamt Galerie">
<livewire:category-filter />
</x-layout>
</x-layouts.app>

View File

@@ -3,9 +3,11 @@
use App\Http\Controllers\ImageController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\AlbumController;
use App\Livewire\Album\Show as AlbumShow;
Route::get('/', [CategoryController::class, 'index'])->name('index');
Route::get('/category/{category}', [CategoryController::class, 'show'])->name('category.show');
Route::get('/album/{album}', [AlbumController::class, 'show'])->name('album.show');
Route::get('/image/{image}', [ImageController::class, 'show'])->name('image.show');
Route::get('/album/{album}', AlbumShow::class)->name('album.show');
Route::get('/image/{image}/thumbnail', [ImageController::class, 'thumbnail'])->name('image.thumbnail');
Route::get('/image/{image}/lightbox', [ImageController::class, 'lightbox'])->name('image.lightbox');
Route::get('/image/{image}/download', [ImageController::class, 'download'])->name('image.download');

View File

@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'selector',
darkMode: ['selector', '[data-dark="true"]'],
content: [
"./resources/**/*.blade.php",
"./resources/**/*.js",
@@ -10,6 +10,7 @@ export default {
extend: {
colors: {
'yellow-pfadi': '#ffdd00',
'yellow-pfadi-dark': '#f2d40d',
},
},
},