Compare commits

...

7 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
dd341ed642 WIP 2024-06-01 03:10:49 +02:00
26551964b1 WIP 2024-06-01 03:10:30 +02:00
78 changed files with 3686 additions and 528 deletions

View File

@@ -62,3 +62,15 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
REVERB_APP_ID=937919
REVERB_APP_KEY=5ljogsvykrp9zocjl63r
REVERB_APP_SECRET=urnthocgauio2cfzdlu7
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

View File

@@ -9,6 +9,12 @@
## About Laravel ## About Laravel
Requires the php gd extensions
Todo:
Cleanup deleted Photos Job
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing). - [Simple, fast routing engine](https://laravel.com/docs/routing).

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreImageRequest; use App\Http\Requests\StoreImageRequest;
use App\Http\Requests\UpdateImageRequest; use App\Http\Requests\UpdateImageRequest;
use App\Models\Image; use App\Models\Image;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -37,9 +38,59 @@ class ImageController extends Controller
/** /**
* Display the specified resource. * 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 . '/original/' . $image->id)); $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');
} }
/** /**

39
app/Importers/Image.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
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 {
public static function supports(UploadedFile $file): bool {
$supportedMimes = config('gallery.image.suffixes', ['png', 'gif', 'bmp', 'svg', 'avi', 'jpg', 'jpeg']);
return in_array($file->guessExtension(), $supportedMimes);
}
public function __construct(private UploadedFile $file, private Album $location)
{
}
public function import(): void {
$image = ImageModel::create([
'album_id' => $this->location->id
]);
$imageId = $image->id;
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

@@ -0,0 +1,53 @@
<?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 GenerateFullscreen implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;
public string $source;
public string $destination;
public function __construct(public Image $image)
{
$this->source = Storage::disk('images')->path($this->image->album_id . '/original/' . $this->image->id . '.avif');
$this->destination = $this->image->album_id . '/lightbox/' . $this->image->id . '.avif';
}
public function handle(): void
{
if (method_exists($this, 'batch') && $this->batch()?->cancelled()) {
return;
}
$lightbox = InterventionImage::read($this->source);
$lightbox = $lightbox->scaleDown(height: config('gallery.image.fullscreen.height', 2000));
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
{
$this->image->delete();
Log::error($exception, [
'image' => $this->image,
'source' => $this->source,
'destination' => $this->destination
]);
}
}

View File

@@ -0,0 +1,52 @@
<?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 GenerateThumbnail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;
public string $source;
public string $destination;
public function __construct(public Image $image)
{
$this->source = Storage::disk('images')->path($this->image->album_id . '/original/' . $this->image->id . '.avif');
$this->destination = $this->image->album_id . '/thumbnail/' . $this->image->id . '.avif';
}
public function handle(): void
{
if (method_exists($this, 'batch') && $this->batch()?->cancelled()) {
return;
}
$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, $thumbnail->toAvif(config('gallery.image.quality', 80)));
}
public function failed(?Throwable $exception): void
{
$this->image->delete();
Log::error($exception, [
'image' => $this->image,
'source' => $this->source,
'destination' => $this->destination
]);
}
}

View File

@@ -0,0 +1,40 @@
<?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 Illuminate\Support\Facades\Log;
use Intervention\Image\Laravel\Facades\Image as InterventionImage;
use \Throwable;
class ImportImage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;
public function __construct(public string $tmpFile, public Image $image)
{
}
public function handle(): void
{
if (method_exists($this, 'batch') && $this->batch()?->cancelled()) {
return;
}
$encoded = InterventionImage::read($this->tmpFile)->toAvif(config('gallery.image.quality', 80));
Storage::disk('images')->put("{$this->image->album->id}/original/{$this->image->id}.avif", $encoded);
}
public function failed(?Throwable $exception): void
{
Log::error($exception, ['image' => $this->image, 'tmpFile' => $this->tmpFile]);
$this->image->delete();
}
}

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
]);
}
}

13
app/ImportsMedia.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
namespace App;
use App\Models\Album;
use Illuminate\Http\UploadedFile;
interface ImportsMedia
{
public static function supports(UploadedFile $file): bool;
public function __construct(UploadedFile $file, Album $location);
public function import(): void;
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Jobs;
use App\Models\Album;
use App\Models\Image;
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 Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ImportMediaJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(private TemporaryUploadedFile $file, private Album $album)
{
}
public function handle(): void
{
if($this->isImage()) {
$image = Image::create([
'album' => $this->album,
'isCover' => false
]);
$encoded = InterventionImage::read($this->file)->toAvif(config('gallery.original.quality', 80));
Storage::disk('images')->put("{$$this->album->id}/original/{$image->id}.avif");
}
}
}

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; namespace App\Livewire;
use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Category; use App\Models\Category;
use Livewire\Attributes\Computed;
class CategoryFilter extends Component class CategoryFilter extends Component
{ {
public ?Tag $filter = null; 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); $this->filter = Tag::find($filter);
} }
@@ -18,7 +25,6 @@ class CategoryFilter extends Component
{ {
return view('livewire.category-filter', [ return view('livewire.category-filter', [
'tags' => Tag::all(), 'tags' => Tag::all(),
'categories' => Category::all(),
]); ]);
} }
} }

View File

@@ -2,6 +2,14 @@
namespace App\Livewire\Drawer\Album; namespace App\Livewire\Drawer\Album;
use App\Models\BatchMutation;
use App\Services\MediaImporter;
use App\Models\Album;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Bus;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportFileUploads\WithFileUploads; use Livewire\Features\SupportFileUploads\WithFileUploads;
@@ -9,9 +17,29 @@ class AddImage extends Component
{ {
use WithFileUploads; use WithFileUploads;
#[Locked]
public Album $album;
#[Locked]
public bool $processing = false;
#[Validate(['media.*' => 'image|max:8192'])] // max:8MB
public $media = []; public $media = [];
public function render() public function save(MediaImporter $importer) : void {
$this->validate();
foreach ($this->media as $file) {
$importer->import($file, $this->album);
}
$this->redirect(route('album.show', $this->album), navigate: true);
}
public function mount(Album $album) : void {
$this->album = $album;
}
public function render() : View|Factory
{ {
return view('livewire.drawer.album.add-image'); return view('livewire.drawer.album.add-image');
} }

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Drawer\Album;
use App\Models\BatchMutation;
use App\Models\MutationBatch;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Bus;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ProgressMonitor extends Component
{
#[Locked]
public BatchMutation $mutation;
#[Locked]
public float $progress;
#[Locked]
public int $processedJobs;
#[Locked]
public int $totalJobs;
#[Locked]
public int $failedJobs;
#[Locked]
public string $currentUrl;
public function mounted(BatchMutation $mutation) : void {
$this->mutation = $mutation;
$this->currentUrl = url()->current();
}
public function monitorProgress() : void {
$batch = Bus::findBatch($this->mutation->batch_id);
$this->progress = $batch->progress();
$this->totalJobs = $batch->totalJobs;
$this->processedJobs = $batch->processedJobs();
$this->failedJobs = $batch->failedJobs;
if($batch == null || $batch->finished()) {
$this->mutation->delete();
$this->redirect($this->currentUrl, navigate: true);
}
}
public function render(): View|Factory
{
$this->monitorProgress();
return view('livewire.drawer.album.progress-monitor');
}
}

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

@@ -20,10 +20,22 @@ class Album extends Model
return $this->hasMany(Image::class); return $this->hasMany(Image::class);
} }
public function mutations() : HasMany {
return $this->hasMany(BatchMutation::class);
}
public function media() : Collection { public function media() : Collection {
return $this->images; 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 { public function getThumbnailAttribute() : ?string {
return $this->images()->where('isCover', 1)->first()?->getThumbnail(); return $this->images()->where('isCover', 1)->first()?->getThumbnail();
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Bus\Batch;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Bus;
class BatchMutation extends Model
{
use HasFactory;
public $fillable = ['batch_id', 'album_id'];
public function album() : BelongsTo {
return $this->belongsTo(Album::class);
}
public function getBatchAttribute() : Batch {
return Bus::findBatch($this->batchId);
}
}

View File

@@ -11,12 +11,55 @@ class Image extends Model implements HasThumbnail
{ {
use HasFactory; use HasFactory;
/**
* The model's default values for attributes.
*
* @var array
*/
protected $attributes = [
'isCover' => false,
'isProcessing' => true,
'lightboxWidth' => 0,
'lightboxHeight' => 0,
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['album_id', 'lightboxWidth', 'lightboxHeight', 'isCover', 'isProcessing'];
public function album(): BelongsTo public function album(): BelongsTo
{ {
return $this->belongsTo(Album::class); return $this->belongsTo(Album::class);
} }
public function getThumbnail() : string { 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

@@ -11,7 +11,10 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// if ($this->app->environment('local')) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
$this->app->register(TelescopeServiceProvider::class);
}
} }
/** /**

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user) {
return in_array($user->email, [
//
]);
});
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Providers;
use App\Services\MediaImporter;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
class MediaImporterServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(MediaImporter::class, function (Application $app) {
return new MediaImporter();
});
}
public function boot(MediaImporter $service): void
{
$importers = config('gallery.importers');
foreach ($importers as $importer) {
$service->register($importer);
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Services;
use App\ImportsMedia;
use App\Models\Album;
use Illuminate\Http\UploadedFile;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
class MediaImporter {
private array $importers = [];
public function register(string $importer) : void {
if(in_array(ImportsMedia::class, class_implements($importer))) {
array_push($this->importers, $importer);
}
}
public function import(UploadedFile $file, Album $location): void {
foreach ($this->importers as $importer) {
if($importer::supports($file)) {
(new $importer($file, $location))->import();
return;
}
}
throw new UnsupportedMediaTypeHttpException("The provided MediaType is not supported");
}
}

View File

@@ -8,6 +8,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {

View File

@@ -2,6 +2,7 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\LivewireAssetProvider::class, App\Providers\LivewireAssetProvider::class,
App\Providers\TelescopeServiceProvider::class, App\Providers\MediaImporterServiceProvider::class,
]; ];

View File

@@ -6,7 +6,10 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"intervention/image-laravel": "^1.2",
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/horizon": "^5.24",
"laravel/reverb": "@beta",
"laravel/telescope": "^5.0", "laravel/telescope": "^5.0",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"livewire/livewire": "^3.4" "livewire/livewire": "^3.4"
@@ -51,7 +54,9 @@
}, },
"extra": { "extra": {
"laravel": { "laravel": {
"dont-discover": [] "dont-discover": [
"laravel/telescope"
]
} }
}, },
"config": { "config": {

1867
composer.lock generated

File diff suppressed because it is too large Load Diff

82
config/broadcasting.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@@ -39,7 +39,7 @@ return [
'images' => [ 'images' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/images'), 'root' => storage_path('app/images'),
'throw' => false, 'throw' => true,
], ],
'public' => [ 'public' => [

11
config/gallery.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
return [
'importers' => [
\App\Importers\Image::class,
],
'image' => [
'suffixes' => ['png', 'gif', 'bmp', 'svg', 'avi', 'jpg', 'jpeg'],
'quality' => 80,
]
];

213
config/horizon.php Normal file
View File

@@ -0,0 +1,213 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
],
],
];

21
config/image.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Image Driver
|--------------------------------------------------------------------------
|
| Intervention Image supports “GD Library” and “Imagick” to process images
| internally. Depending on your PHP setup, you can choose one of them.
|
| Included options:
| - \Intervention\Image\Drivers\Gd\Driver::class
| - \Intervention\Image\Drivers\Imagick\Driver::class
|
*/
'driver' => \Intervention\Image\Drivers\Gd\Driver::class
];

91
config/reverb.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
],
],
],
];

View File

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

View File

@@ -25,6 +25,6 @@ return new class extends Migration
*/ */
public function down(): void public function down(): void
{ {
Schema::dropIfExists('categories_tags'); Schema::dropIfExists('category_tag');
} }
}; };

View File

@@ -16,6 +16,9 @@ return new class extends Migration
$table->id(); $table->id();
$table->timestamps(); $table->timestamps();
$table->boolean('isCover'); $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); $table->foreignIdFor(Album::class);
}); });
} }

View File

@@ -0,0 +1,31 @@
<?php
use App\Models\Album;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('batch_mutations', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('batch_id');
$table->integer('files');
$table->foreignIdFor(Album::class);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('import_batch');
}
};

44
package-lock.json generated
View File

@@ -5,12 +5,13 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@marcreichel/alpine-auto-animate": "^1.1.0",
"filepond": "^4.31.1", "filepond": "^4.31.1",
"filepond-plugin-file-validate-size": "^2.2.8", "filepond-plugin-file-validate-size": "^2.2.8",
"filepond-plugin-file-validate-type": "^1.2.9", "filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-exif-orientation": "^1.0.11", "filepond-plugin-image-exif-orientation": "^1.0.11",
"filepond-plugin-image-preview": "^4.6.12", "filepond-plugin-image-preview": "^4.6.12",
"filepond-plugin-image-transform": "^3.8.7" "photoswipe": "^5.4.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
@@ -402,6 +403,23 @@
"node": ">=12" "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": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -467,6 +485,14 @@
"@jridgewell/sourcemap-codec": "^1.4.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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1202,14 +1228,6 @@
"filepond": ">=4.x <5.x" "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": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -1671,6 +1689,14 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/picocolors": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",

View File

@@ -15,11 +15,12 @@
"vite": "^5.0" "vite": "^5.0"
}, },
"dependencies": { "dependencies": {
"@marcreichel/alpine-auto-animate": "^1.1.0",
"filepond": "^4.31.1", "filepond": "^4.31.1",
"filepond-plugin-file-validate-size": "^2.2.8", "filepond-plugin-file-validate-size": "^2.2.8",
"filepond-plugin-file-validate-type": "^1.2.9", "filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-exif-orientation": "^1.0.11", "filepond-plugin-image-exif-orientation": "^1.0.11",
"filepond-plugin-image-preview": "^4.6.12", "filepond-plugin-image-preview": "^4.6.12",
"filepond-plugin-image-transform": "^3.8.7" "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/dist/filepond.min.css';
@import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css'; @import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
@import 'photoswipe/dist/photoswipe.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -30,3 +31,67 @@
.filepond--drop-label { .filepond--drop-label {
@apply text-gray-900 dark:text-white; @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,25 +1,8 @@
import './bootstrap'; import './bootstrap';
import * as FilePond from 'filepond'; import './lightbox';
import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation'; import './filepond';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview'; import './notification';
import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
import FilePondPluginImageTransform from 'filepond-plugin-image-transform';
FilePond.registerPlugin(FilePondPluginImageExifOrientation); document.addEventListener('livewire:init', () => {
FilePond.registerPlugin(FilePondPluginImagePreview); ToastinTakin.init();
FilePond.registerPlugin(FilePondPluginFileValidateSize);
FilePond.registerPlugin(FilePondPluginFileValidateType);
FilePond.registerPlugin(FilePondPluginImageTransform);
window.FilePond = FilePond;
document.addEventListener('alpine:init', () => {
Alpine.store('uploader', {
states: {},
setState(state, value) {
console.log(state, value);
this.states[state] = value;
},
})
}); });

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,48 +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>
<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></livewire:drawer.album.addImage>
</x-slot:content>
</x-drawer>
</x-layout>

View File

@@ -20,28 +20,15 @@
</div> </div>
@endpush @endpush
<x-layout> <x-layouts.app :title="$category->name">
<x-hero-search></x-hero-search> <div class="flex flex-wrap flex-row gap-4">
<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"> <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">
{{ $category->name }} <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">
</h1> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7 7V5"/>
<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"/>
</svg> </svg>
</div> </x-drawer-trigger>
</a> @foreach ($albums as $album)
</figure> <x-album.element :album="$album"></x-album.element>
@endforeach @endforeach
</div> </div>
<x-drawer name="album-add" > <x-drawer name="album-add" >
@@ -50,4 +37,4 @@
<livewire:drawer.album.create></livewire:drawer.album.create> <livewire:drawer.album.create></livewire:drawer.album.create>
</x-slot:content> </x-slot:content>
</x-drawer> </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 ]) @props(['active' => false ])
@if($active) @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 }} {{ $slot }}
</li> </li>
@else @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 <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">
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="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 }} {{ $slot }}
</li> </li>
@endif @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 }} {{ $slot }}
</div> </div>

View File

@@ -1,31 +1,36 @@
<template x-teleport="body"> <template x-teleport="body">
<div x-cloak <div x-cloak
x-data="{ show: false }" 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 }" :class="{ 'translate-y-full': !show }"
@click="$dispatch('drawer-close-{{ $name }}')" @click.self="$dispatch('drawer-close-{{ $name }}')"
x-on:touchstart="$dispatch('drawer-close-{{ $name }}')" x-on:touchstart.self="$dispatch('drawer-close-{{ $name }}')"
> >
<div <div
id="drawer-{{ $name }}" id="drawer-{{ $name }}"
tabindex="-1" 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 }}" aria-labelledby="drawer-{{ $name }}"
@drawer-open-{{ $name }}.window="$dispatch('menu-hide'); show = true" @drawer-open-{{ $name }}.window="$dispatch('menu-hide'); show = true"
@drawer-close-{{ $name }}.window="show = false; $dispatch('menu-show')" @drawer-close-{{ $name }}.window="show = false; $dispatch('menu-show')"
:class="{ 'translate-y-full': !show, 'xl:-bottom-full': !show }" :class="{ 'translate-y-full': !show, 'xl:-bottom-full': !show }"
> >
<div <div
class="p-4 cursor-pointer max-xl:hover:bg-gray-50 max-xl:dark:hover:bg-gray-700 xl:border-b dark:border-gray-600 xl:flex xl:items-center xl:justify-between xl:p-4" class="p-4 cursor-pointer max-xl:hover:bg-gray-50 max-xl:dark:hover:bg-gray-700 xl:border-b dark:border-gray-600 xl:flex xl:items-center xl:justify-between xl:p-4">
<span
class="xl:hidden absolute w-8 h-1 -translate-x-1/2 bg-gray-300 rounded-lg top-3 left-1/2 dark:bg-gray-600"
@click="$dispatch('drawer-close-{{ $name }}')" @click="$dispatch('drawer-close-{{ $name }}')"
x-on:touchstart="$dispatch('drawer-close-{{ $name }}')" x-on:touchstart="$dispatch('drawer-close-{{ $name }}')"
> >
<span class="xl:hidden absolute w-8 h-1 -translate-x-1/2 bg-gray-300 rounded-lg top-3 left-1/2 dark:bg-gray-600">
</span> </span>
<h5 class="inline-flex items-center text-base text-gray-500 dark:text-gray-400 font-medium"> <h5 class="inline-flex items-center text-base text-gray-500 dark:text-gray-400 font-medium">
{{ $title }} {{ $title }}
</h5> </h5>
<button class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"> <button
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
@click="$dispatch('drawer-close-{{ $name }}')"
x-on:touchstart="$dispatch('drawer-close-{{ $name }}')"
>
<svg class="max-xl:hidden w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"> <svg class="max-xl:hidden 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"/> <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> </svg>

View File

@@ -68,6 +68,28 @@
load(); load();
}, },
}, },
imageResizeTargetWidth: 600,
imageCropAspectRatio: 1,
imageTransformVariants: {
thumb_medium_: (transforms) => {
transforms.resize = {
size: {
width: 384,
height: 384,
},
};
return transforms;
},
thumb_small_: (transforms) => {
transforms.resize = {
size: {
width: 128,
height: 128,
},
};
return transforms;
},
},
allowImagePreview: {{ $preview ? 'true' : 'false' }}, allowImagePreview: {{ $preview ? 'true' : 'false' }},
styleItemPanelAspectRatio: '0.5625', styleItemPanelAspectRatio: '0.5625',
allowFileTypeValidation: {{ $validate ? 'true' : 'false' }}, allowFileTypeValidation: {{ $validate ? 'true' : 'false' }},

View File

@@ -1,14 +0,0 @@
<form class="flex h-1/3 bg-cover relative">
<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>

View File

@@ -0,0 +1,3 @@
<svg {{ $attributes->merge(['class' => "flex-shrink-0 text-green-500 dark:text-green-400"]) }} 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>

After

Width:  |  Height:  |  Size: 370 B

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">
@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,7 @@
<div role="status">
<svg aria-hidden="true" {{ $attributes->merge(['class' => "animate-spin fill-blue-600 text-gray-200 dark:text-gray-600"]) }} viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
</svg>
<span class="sr-only">Loading...</span>
</div>

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>
<div class="text-center"> <div class="mb-8">
<ul class="uppercase font-medium"> <ul class="uppercase font-medium flex gap-12 justify-center">
<x-category-filter-pill wire:click="setFilter(-1)" :active="$filter == null"> <x-category-filter-pill wire:click="setFilter(-1)" :active="$filter == null">
Alle Alle
</x-category-filter-pill> </x-category-filter-pill>
@@ -13,19 +13,9 @@
</ul> </ul>
</div> </div>
<div class="m-8 flex flex-wrap flex-row gap-4"> <div class="flex flex-wrap flex-row gap-4 transition-opacity" wire:loading.class="opacity-0">
@foreach ($categories as $category) @foreach ($this->categories as $category)
@if($filter == null || $category->tags->contains($filter)) <x-category.element :category="$category" wire:key="{{ $category->id }}"></x-category.element>
<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
@endforeach @endforeach
</div> </div>
</div> </div>

View File

@@ -1,4 +1,9 @@
<form wire:submit="save" class="p-4"> <form
wire:submit="save"
class="p-4"
x-data="{loading: false, message: ''}"
x-on:import-progress-report="message = $event.detail.message"
>
<x-form.upload wire:model="media" name="media" label="Medien" multiple /> <x-form.upload wire:model="media" name="media" label="Medien" multiple />
<button x-transition x-show="$store.uploader.states && $store.uploader.states.media == 3" disabled type="button" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 inline-flex items-center"> <button x-transition x-show="$store.uploader.states && $store.uploader.states.media == 3" disabled type="button" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 inline-flex items-center">
@@ -17,5 +22,5 @@
<span class="font-medium">Dateien mit Fehlern vorhanden!</span> Du kannst nicht speichern, bis die Fehler behoben sind. <span class="font-medium">Dateien mit Fehlern vorhanden!</span> Du kannst nicht speichern, bis die Fehler behoben sind.
</div> </div>
</div> </div>
<button x-transition.delay.500ms x-show="$store.uploader.states && $store.uploader.states.media == 4" type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">Speichern</button> <button x-transition.delay.500ms x-show="$store.uploader.states && $store.uploader.states.media == 4" type="submit" @click="loading = true" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">Speichern</button>
</div> </form>

View File

@@ -0,0 +1,20 @@
<div class="m-auto min-w-md text-center" wire:poll>
<img class="inline" src="https://placehold.co/200x200" />
<div class="p-5">
<h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
Album wird verarbeitet
</h5>
<p class="text-start mb-3 font-normal text-gray-700 dark:text-gray-400">
Die Dateien in diesem Album wurden geändert. <br>
Bitte warte einen moment bis Verarbeitung abgeschlossen ist.
</p>
<div class="flex justify-between mb-1">
<span class="text-base font-medium text-blue-700 dark:text-white">Aufgabe {{ $processedJobs }} von {{ $totalJobs }}</span>
<span class="text-sm font-medium text-blue-700 dark:text-white">{{ $progress }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div class="bg-blue-600 h-2.5 rounded-full" style="width: {{ $progress }}%"></div>
</div>
</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-layouts.app title="Pfadi Säuliamt Galerie">
<x-hero-search></x-hero-search>
<livewire:category-filter /> <livewire:category-filter />
</x-layout> </x-layouts.app>

7
routes/channels.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});

View File

@@ -1,8 +1,7 @@
<?php <?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Schedule::command('queue:prune-batches')->daily();
$this->comment(Inspiring::quote()); Schedule::command('horizon:snapshot')->everyFiveMinutes();
})->purpose('Display an inspiring quote')->hourly();

View File

@@ -3,9 +3,11 @@
use App\Http\Controllers\ImageController; use App\Http\Controllers\ImageController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\CategoryController; 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('/', [CategoryController::class, 'index'])->name('index');
Route::get('/category/{category}', [CategoryController::class, 'show'])->name('category.show'); Route::get('/category/{category}', [CategoryController::class, 'show'])->name('category.show');
Route::get('/album/{album}', [AlbumController::class, 'show'])->name('album.show'); Route::get('/album/{album}', AlbumShow::class)->name('album.show');
Route::get('/image/{image}', [ImageController::class, 'show'])->name('image.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} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: 'selector', darkMode: ['selector', '[data-dark="true"]'],
content: [ content: [
"./resources/**/*.blade.php", "./resources/**/*.blade.php",
"./resources/**/*.js", "./resources/**/*.js",
@@ -10,6 +10,7 @@ export default {
extend: { extend: {
colors: { colors: {
'yellow-pfadi': '#ffdd00', 'yellow-pfadi': '#ffdd00',
'yellow-pfadi-dark': '#f2d40d',
}, },
}, },
}, },