diff --git a/app/Importers/Image/Jobs/RotateImage.php b/app/Importers/Image/Jobs/RotateImage.php index c08f576..5dee8b6 100644 --- a/app/Importers/Image/Jobs/RotateImage.php +++ b/app/Importers/Image/Jobs/RotateImage.php @@ -27,8 +27,7 @@ class RotateImage implements ShouldQueue $this->destination = $this->image->album_id . '/original/' . $this->image->id . '.avif'; } - public function handle(): void - { + public function handle(): void { if (method_exists($this, 'batch') && $this->batch()?->cancelled()) { return; } diff --git a/app/Livewire/Album/Show.php b/app/Livewire/Album/Show.php index 1841d89..a3f601d 100644 --- a/app/Livewire/Album/Show.php +++ b/app/Livewire/Album/Show.php @@ -8,7 +8,6 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Bus; use Livewire\Attributes\Locked; use Livewire\Attributes\On; -use Livewire\Attributes\Title; use Livewire\Component; use App\Models\Album; use App\Models\Image; @@ -42,6 +41,11 @@ class Show extends Component $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, @@ -57,9 +61,9 @@ class Show extends Component ])->dispatch(); } - #[Title('Show Album')] public function render(): View|Factory { - return view('livewire.album.show'); + return view('livewire.album.show') + ->title($this->album->name); } } diff --git a/app/Livewire/CategoryFilter.php b/app/Livewire/CategoryFilter.php index ab5299d..4fbe181 100644 --- a/app/Livewire/CategoryFilter.php +++ b/app/Livewire/CategoryFilter.php @@ -2,15 +2,22 @@ namespace App\Livewire; +use Illuminate\Support\Collection; use Livewire\Component; use App\Models\Tag; use App\Models\Category; +use Livewire\Attributes\Computed; class CategoryFilter extends Component { public ?Tag $filter = null; - public function setFilter(int $filter) { + #[Computed] + public function categories() : Collection { + return $this->filter?->categories ?? Category::all(); + } + + public function setFilter(int $filter) : void { $this->filter = Tag::find($filter); } @@ -18,7 +25,6 @@ class CategoryFilter extends Component { return view('livewire.category-filter', [ 'tags' => Tag::all(), - 'categories' => Category::all(), ]); } } diff --git a/app/Livewire/Menu.php b/app/Livewire/Menu.php new file mode 100644 index 0000000..91b943a --- /dev/null +++ b/app/Livewire/Menu.php @@ -0,0 +1,19 @@ +title = $title ?? config('app.name'); + } + + public function render() + { + return view('livewire.menu'); + } +} diff --git a/app/Models/Image.php b/app/Models/Image.php index e66d42b..6fbbdab 100644 --- a/app/Models/Image.php +++ b/app/Models/Image.php @@ -49,6 +49,12 @@ class Image extends Model implements HasThumbnail $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, diff --git a/package-lock.json b/package-lock.json index fbc6e90..f5f0184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@marcreichel/alpine-auto-animate": "^1.1.0", "filepond": "^4.31.1", "filepond-plugin-file-validate-size": "^2.2.8", "filepond-plugin-file-validate-type": "^1.2.9", @@ -402,6 +403,23 @@ "node": ">=12" } }, + "node_modules/@formkit/auto-animate": { + "version": "1.0.0-pre-alpha.3", + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-1.0.0-pre-alpha.3.tgz", + "integrity": "sha512-lMVZ3LFUIu0RIxCEwmV8nUUJQ46M2bv2NDU3hrhZivViuR1EheC8Mj5sx/ACqK5QLK8XB8z7GDIZBUGdU/9OZQ==", + "peerDependencies": { + "react": "^16.8.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -467,6 +485,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@marcreichel/alpine-auto-animate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@marcreichel/alpine-auto-animate/-/alpine-auto-animate-1.1.0.tgz", + "integrity": "sha512-ulKU3TAmZ/YkpO34j+tpGFSNSyvXAcprWkO6laFuLODx28zfJBi61TPkD3Tw9BQAl57jL91yybMFhRT3VHRPlQ==", + "dependencies": { + "@formkit/auto-animate": "^1.0.0-beta.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index f860761..de52ff8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "vite": "^5.0" }, "dependencies": { + "@marcreichel/alpine-auto-animate": "^1.1.0", "filepond": "^4.31.1", "filepond-plugin-file-validate-size": "^2.2.8", "filepond-plugin-file-validate-type": "^1.2.9", diff --git a/resources/assets/Wildsau.svg b/resources/assets/Wildsau.svg new file mode 100644 index 0000000..b8ebd3d --- /dev/null +++ b/resources/assets/Wildsau.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index c293f4a..7fcce12 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -37,7 +37,7 @@ } .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; + @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 { @@ -47,3 +47,51 @@ .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); +} \ No newline at end of file diff --git a/resources/css/sonner.css b/resources/css/sonner.css deleted file mode 100644 index 56e9dd5..0000000 --- a/resources/css/sonner.css +++ /dev/null @@ -1,682 +0,0 @@ -html[dir="ltr"], -[data-sonner-toaster][dir="ltr"] { - --toast-icon-margin-start: -3px; - --toast-icon-margin-end: 4px; - --toast-svg-margin-start: -1px; - --toast-svg-margin-end: 0px; - --toast-button-margin-start: auto; - --toast-button-margin-end: 0; - --toast-close-button-start: 0; - --toast-close-button-end: unset; - --toast-close-button-transform: translate(-35%, -35%); -} - -html[dir="rtl"], -[data-sonner-toaster][dir="rtl"] { - --toast-icon-margin-start: 4px; - --toast-icon-margin-end: -3px; - --toast-svg-margin-start: 0px; - --toast-svg-margin-end: -1px; - --toast-button-margin-start: 0; - --toast-button-margin-end: auto; - --toast-close-button-start: unset; - --toast-close-button-end: 0; - --toast-close-button-transform: translate(35%, -35%); -} - -[data-sonner-toaster] { - position: fixed; - width: var(--width); - font-family: - ui-sans-serif, - system-ui, - -apple-system, - BlinkMacSystemFont, - Segoe UI, - Roboto, - Helvetica Neue, - Arial, - Noto Sans, - sans-serif, - Apple Color Emoji, - Segoe UI Emoji, - Segoe UI Symbol, - Noto Color Emoji; - --gray1: hsl(0, 0%, 99%); - --gray2: hsl(0, 0%, 97.3%); - --gray3: hsl(0, 0%, 95.1%); - --gray4: hsl(0, 0%, 93%); - --gray5: hsl(0, 0%, 90.9%); - --gray6: hsl(0, 0%, 88.7%); - --gray7: hsl(0, 0%, 85.8%); - --gray8: hsl(0, 0%, 78%); - --gray9: hsl(0, 0%, 56.1%); - --gray10: hsl(0, 0%, 52.3%); - --gray11: hsl(0, 0%, 43.5%); - --gray12: hsl(0, 0%, 9%); - --border-radius: 8px; - box-sizing: border-box; - padding: 0; - margin: 0; - list-style: none; - outline: none; - z-index: 999999999; -} - -[data-sonner-toaster][data-x-position="right"] { - right: max(var(--offset), env(safe-area-inset-right)); -} - -[data-sonner-toaster][data-x-position="left"] { - left: max(var(--offset), env(safe-area-inset-left)); -} - -[data-sonner-toaster][data-x-position="center"] { - left: 50%; - transform: translateX(-50%); -} - -[data-sonner-toaster][data-y-position="top"] { - top: max(var(--offset), env(safe-area-inset-top)); -} - -[data-sonner-toaster][data-y-position="bottom"] { - bottom: max(var(--offset), env(safe-area-inset-bottom)); -} - -[data-sonner-toast] { - --y: translateY(100%); - --lift-amount: calc(var(--lift) * var(--gap)); - z-index: var(--z-index); - position: absolute; - opacity: 0; - transform: var(--y); - /* https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not */ - touch-action: none; - will-change: transform, opacity, height; - transition: - transform 400ms, - opacity 400ms, - height 400ms, - box-shadow 200ms; - box-sizing: border-box; - outline: none; - overflow-wrap: anywhere; -} - -[data-sonner-toast][data-styled="true"] { - padding: 16px; - background: var(--normal-bg); - border: 1px solid var(--normal-border); - color: var(--normal-text); - border-radius: var(--border-radius); - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); - width: var(--width); - font-size: 13px; - display: flex; - align-items: center; - gap: 6px; -} - -[data-sonner-toast]:focus-visible { - box-shadow: - 0px 4px 12px rgba(0, 0, 0, 0.1), - 0 0 0 2px rgba(0, 0, 0, 0.2); -} - -[data-sonner-toast][data-y-position="top"] { - top: 0; - --y: translateY(-100%); - --lift: 1; - --lift-amount: calc(1 * var(--gap)); -} - -[data-sonner-toast][data-y-position="bottom"] { - bottom: 0; - --y: translateY(100%); - --lift: -1; - --lift-amount: calc(var(--lift) * var(--gap)); -} - -[data-sonner-toast] [data-description] { - font-weight: 400; - line-height: 1.4; - color: inherit; -} - -[data-sonner-toast] [data-title] { - font-weight: 500; - line-height: 1.5; - color: inherit; -} - -[data-sonner-toast] [data-icon] { - display: flex; - height: 16px; - width: 16px; - position: relative; - justify-content: flex-start; - align-items: center; - flex-shrink: 0; - margin-left: var(--toast-icon-margin-start); - margin-right: var(--toast-icon-margin-end); -} - -[data-sonner-toast][data-promise="true"] [data-icon] > svg { - opacity: 0; - transform: scale(0.8); - transform-origin: center; - animation: sonner-fade-in 300ms ease forwards; -} - -[data-sonner-toast] [data-icon] > * { - flex-shrink: 0; -} - -[data-sonner-toast] [data-icon] svg { - margin-left: var(--toast-svg-margin-start); - margin-right: var(--toast-svg-margin-end); -} - -[data-sonner-toast] [data-content] { - display: flex; - flex-direction: column; - gap: 2px; -} - -[data-sonner-toast] [data-button] { - border-radius: 4px; - padding-left: 8px; - padding-right: 8px; - height: 24px; - font-size: 12px; - color: var(--normal-bg); - background: var(--normal-text); - margin-left: var(--toast-button-margin-start); - margin-right: var(--toast-button-margin-end); - border: none; - cursor: pointer; - outline: none; - display: flex; - align-items: center; - flex-shrink: 0; - transition: - opacity 400ms, - box-shadow 200ms; -} - -[data-sonner-toast] [data-button]:focus-visible { - box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4); -} - -[data-sonner-toast] [data-button]:first-of-type { - margin-left: var(--toast-button-margin-start); - margin-right: var(--toast-button-margin-end); -} - -[data-sonner-toast] [data-cancel] { - color: var(--normal-text); - background: rgba(0, 0, 0, 0.08); -} - -[data-sonner-toast][data-theme="dark"] [data-cancel] { - background: rgba(255, 255, 255, 0.3); -} - -[data-sonner-toast] [data-close-button] { - position: absolute; - left: var(--toast-close-button-start); - right: var(--toast-close-button-end); - top: 0; - height: 20px; - width: 20px; - display: flex; - justify-content: center; - align-items: center; - padding: 0; - background: var(--gray1); - color: var(--gray12); - border: 1px solid var(--gray4); - transform: var(--toast-close-button-transform); - border-radius: 50%; - cursor: pointer; - z-index: 1; - transition: - opacity 100ms, - background 200ms, - border-color 200ms; -} - -[data-sonner-toast] [data-close-button]:focus-visible { - box-shadow: - 0px 4px 12px rgba(0, 0, 0, 0.1), - 0 0 0 2px rgba(0, 0, 0, 0.2); -} - -[data-sonner-toast] [data-disabled="true"] { - cursor: not-allowed; -} - -[data-sonner-toast]:hover [data-close-button]:hover { - background: var(--gray2); - border-color: var(--gray5); -} - -/* Leave a ghost div to avoid setting hover to false when swiping out */ -[data-sonner-toast][data-swiping="true"]:before { - content: ""; - position: absolute; - left: 0; - right: 0; - height: 100%; - z-index: -1; -} - -[data-sonner-toast][data-y-position="top"][data-swiping="true"]:before { - /* y 50% needed to distribute height additional height evenly */ - bottom: 50%; - transform: scaleY(3) translateY(50%); -} - -[data-sonner-toast][data-y-position="bottom"][data-swiping="true"]:before { - /* y -50% needed to distribute height additional height evenly */ - top: 50%; - transform: scaleY(3) translateY(-50%); -} - -/* Leave a ghost div to avoid setting hover to false when transitioning out */ -[data-sonner-toast][data-swiping="false"][data-removed="true"]:before { - content: ""; - position: absolute; - inset: 0; - transform: scaleY(2); -} - -/* Needed to avoid setting hover to false when inbetween toasts */ -[data-sonner-toast]:after { - content: ""; - position: absolute; - left: 0; - height: calc(var(--gap) + 1px); - bottom: 100%; - width: 100%; -} - -[data-sonner-toast][data-mounted="true"] { - --y: translateY(0); - opacity: 1; -} - -[data-sonner-toast][data-expanded="false"][data-front="false"] { - --scale: var(--toasts-before) * 0.05; - --y: translateY(calc(var(--lift-amount) * var(--toasts-before))) - scale(calc(1 - var(--scale))); - height: var(--front-toast-height); -} - -[data-sonner-toast] > * { - transition: opacity 400ms; -} - -[data-sonner-toast][data-expanded="false"][data-front="false"][data-styled="true"] - > * { - opacity: 0; -} - -[data-sonner-toast][data-visible="false"] { - opacity: 0; - pointer-events: none; -} - -[data-sonner-toast][data-mounted="true"][data-expanded="true"] { - --y: translateY(calc(var(--lift) * var(--offset))); - height: var(--initial-height); -} - -[data-sonner-toast][data-removed="true"][data-front="true"][data-swipe-out="false"] { - --y: translateY(calc(var(--lift) * -100%)); - opacity: 0; -} - -[data-sonner-toast][data-removed="true"][data-front="false"][data-swipe-out="false"][data-expanded="true"] { - --y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%)); - opacity: 0; -} - -[data-sonner-toast][data-removed="true"][data-front="false"][data-swipe-out="false"][data-expanded="false"] { - --y: translateY(40%); - opacity: 0; - transition: - transform 500ms, - opacity 200ms; -} - -/* Bump up the height to make sure hover state doesn't get set to false */ -[data-sonner-toast][data-removed="true"][data-front="false"]:before { - height: calc(var(--initial-height) + 20%); -} - -[data-sonner-toast][data-swiping="true"] { - transform: var(--y) translateY(var(--swipe-amount, 0px)); - transition: none; -} - -[data-sonner-toast][data-swipe-out="true"][data-y-position="bottom"], -[data-sonner-toast][data-swipe-out="true"][data-y-position="top"] { - animation: swipe-out 200ms ease-out forwards; -} - -@keyframes swipe-out { - from { - transform: translateY( - calc(var(--lift) * var(--offset) + var(--swipe-amount)) - ); - opacity: 1; - } - - to { - transform: translateY( - calc( - var(--lift) * var(--offset) + var(--swipe-amount) + var(--lift) * -100% - ) - ); - opacity: 0; - } -} - -@media (max-width: 600px) { - [data-sonner-toaster] { - position: fixed; - --mobile-offset: 16px; - right: var(--mobile-offset); - left: var(--mobile-offset); - width: 100%; - } - - [data-sonner-toaster] [data-sonner-toast] { - left: 0; - right: 0; - width: calc(100% - 32px); - } - - [data-sonner-toaster][data-x-position="left"] { - left: var(--mobile-offset); - } - - [data-sonner-toaster][data-y-position="bottom"] { - bottom: 20px; - } - - [data-sonner-toaster][data-y-position="top"] { - top: 20px; - } - - [data-sonner-toaster][data-x-position="center"] { - left: var(--mobile-offset); - right: var(--mobile-offset); - transform: none; - } -} - -[data-sonner-toaster][data-theme="light"] { - --normal-bg: #fff; - --normal-border: var(--gray4); - --normal-text: var(--gray12); - - --success-bg: hsl(143, 85%, 96%); - --success-border: hsl(145, 92%, 91%); - --success-text: hsl(140, 100%, 27%); - - --info-bg: hsl(208, 100%, 97%); - --info-border: hsl(221, 91%, 91%); - --info-text: hsl(210, 92%, 45%); - - --warning-bg: hsl(49, 100%, 97%); - --warning-border: hsl(49, 91%, 91%); - --warning-text: hsl(31, 92%, 45%); - - --error-bg: hsl(359, 100%, 97%); - --error-border: hsl(359, 100%, 94%); - --error-text: hsl(360, 100%, 45%); -} - -[data-sonner-toaster][data-theme="light"] - [data-sonner-toast][data-invert="true"] { - --normal-bg: #000; - --normal-border: hsl(0, 0%, 20%); - --normal-text: var(--gray1); -} - -[data-sonner-toaster][data-theme="dark"] - [data-sonner-toast][data-invert="true"] { - --normal-bg: #fff; - --normal-border: var(--gray3); - --normal-text: var(--gray12); -} - -[data-sonner-toaster][data-theme="dark"] { - --normal-bg: #000; - --normal-border: hsl(0, 0%, 20%); - --normal-text: var(--gray1); - - --success-bg: hsl(150, 100%, 6%); - --success-border: hsl(147, 100%, 12%); - --success-text: hsl(150, 86%, 65%); - - --info-bg: hsl(215, 100%, 6%); - --info-border: hsl(223, 100%, 12%); - --info-text: hsl(216, 87%, 65%); - - --warning-bg: hsl(64, 100%, 6%); - --warning-border: hsl(60, 100%, 12%); - --warning-text: hsl(46, 87%, 65%); - - --error-bg: hsl(358, 76%, 10%); - --error-border: hsl(357, 89%, 16%); - --error-text: hsl(358, 100%, 81%); -} - -[data-rich-colors="true"] [data-sonner-toast][data-type="success"] { - background: var(--success-bg); - border-color: var(--success-border); - color: var(--success-text); -} - -[data-rich-colors="true"] - [data-sonner-toast][data-type="success"] - [data-close-button] { - background: var(--success-bg); - border-color: var(--success-border); - color: var(--success-text); -} - -[data-rich-colors="true"] [data-sonner-toast][data-type="info"] { - background: var(--info-bg); - border-color: var(--info-border); - color: var(--info-text); -} - -[data-rich-colors="true"] - [data-sonner-toast][data-type="info"] - [data-close-button] { - background: var(--info-bg); - border-color: var(--info-border); - color: var(--info-text); -} - -[data-rich-colors="true"] [data-sonner-toast][data-type="warning"] { - background: var(--warning-bg); - border-color: var(--warning-border); - color: var(--warning-text); -} - -[data-rich-colors="true"] - [data-sonner-toast][data-type="warning"] - [data-close-button] { - background: var(--warning-bg); - border-color: var(--warning-border); - color: var(--warning-text); -} - -[data-rich-colors="true"] [data-sonner-toast][data-type="error"] { - background: var(--error-bg); - border-color: var(--error-border); - color: var(--error-text); -} - -[data-rich-colors="true"] - [data-sonner-toast][data-type="error"] - [data-close-button] { - background: var(--error-bg); - border-color: var(--error-border); - color: var(--error-text); -} - -.sonner-loading-wrapper { - --size: 16px; - height: var(--size); - width: var(--size); - position: absolute; - inset: 0; - z-index: 10; -} - -.sonner-loading-wrapper[data-visible="false"] { - transform-origin: center; - animation: sonner-fade-out 0.2s ease forwards; -} - -.sonner-spinner { - position: relative; - top: 50%; - left: 50%; - height: var(--size); - width: var(--size); -} - -.sonner-loading-bar { - animation: sonner-spin 1.2s linear infinite; - background: var(--gray11); - border-radius: 6px; - height: 8%; - left: -10%; - position: absolute; - top: -3.9%; - width: 24%; -} - -.sonner-loading-bar:nth-child(1) { - animation-delay: -1.2s; - transform: rotate(0.0001deg) translate(146%); -} - -.sonner-loading-bar:nth-child(2) { - animation-delay: -1.1s; - transform: rotate(30deg) translate(146%); -} - -.sonner-loading-bar:nth-child(3) { - animation-delay: -1s; - transform: rotate(60deg) translate(146%); -} - -.sonner-loading-bar:nth-child(4) { - animation-delay: -0.9s; - transform: rotate(90deg) translate(146%); -} - -.sonner-loading-bar:nth-child(5) { - animation-delay: -0.8s; - transform: rotate(120deg) translate(146%); -} - -.sonner-loading-bar:nth-child(6) { - animation-delay: -0.7s; - transform: rotate(150deg) translate(146%); -} - -.sonner-loading-bar:nth-child(7) { - animation-delay: -0.6s; - transform: rotate(180deg) translate(146%); -} - -.sonner-loading-bar:nth-child(8) { - animation-delay: -0.5s; - transform: rotate(210deg) translate(146%); -} - -.sonner-loading-bar:nth-child(9) { - animation-delay: -0.4s; - transform: rotate(240deg) translate(146%); -} - -.sonner-loading-bar:nth-child(10) { - animation-delay: -0.3s; - transform: rotate(270deg) translate(146%); -} - -.sonner-loading-bar:nth-child(11) { - animation-delay: -0.2s; - transform: rotate(300deg) translate(146%); -} - -.sonner-loading-bar:nth-child(12) { - animation-delay: -0.1s; - transform: rotate(330deg) translate(146%); -} - -@keyframes sonner-fade-in { - 0% { - opacity: 0; - transform: scale(0.8); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -@keyframes sonner-fade-out { - 0% { - opacity: 1; - transform: scale(1); - } - 100% { - opacity: 0; - transform: scale(0.8); - } -} - -@keyframes sonner-spin { - 0% { - opacity: 1; - } - 100% { - opacity: 0.15; - } -} - -@media (prefers-reduced-motion) { - [data-sonner-toast], - [data-sonner-toast] > *, - .sonner-loading-bar { - transition: none !important; - animation: none !important; - } -} - -.sonner-loader { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - transform-origin: center; - transition: - opacity 200ms, - transform 200ms; -} - -.sonner-loader[data-visible="false"] { - opacity: 0; - transform: scale(0.8) translate(-50%, -50%); -} diff --git a/resources/js/app.js b/resources/js/app.js index cb49050..c29286d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,20 +3,6 @@ import './lightbox'; import './filepond'; import './notification'; -ToastinTakin.init(); - -window.setTimeout(() => { - ToastinTakin.error('Bilder speichern fehlgeschlagen.'); -}, 100); -window.setTimeout(() => { - ToastinTakin.info('Bilder werden geladen, bitte warten.'); -}, 2000); -window.setTimeout(() => { - ToastinTakin.success('Bilder erfolgreich gespeichert'); -}, 3000); -window.setTimeout(() => { - ToastinTakin.error('Bilder doch nicht erfolgreich gespeichert'); -}, 4000); -window.setTimeout(() => { - ToastinTakin.info('Diese Nachrichten sind komisch ;).'); -}, 5000); \ No newline at end of file +document.addEventListener('livewire:init', () => { + ToastinTakin.init(); +}); \ No newline at end of file diff --git a/resources/js/lightbox.js b/resources/js/lightbox.js index a960025..7b69fb4 100644 --- a/resources/js/lightbox.js +++ b/resources/js/lightbox.js @@ -7,8 +7,8 @@ document.addEventListener('livewire:navigated', () => { bgOpacity: 1, arrowPrevSVG: '', arrowNextSVG: '', - closeSVG: '
', - zoomSVG: '
', + closeSVG: '
', + zoomSVG: '
', pswpModule: () => import('photoswipe') }); @@ -18,7 +18,7 @@ document.addEventListener('livewire:navigated', () => { order: 9, isButton: true, tagName: 'button', - html: '
', + html: '
', onInit: (el, pswp) => { el.setAttribute('download', ''); @@ -32,11 +32,35 @@ document.addEventListener('livewire:navigated', () => { }); lightbox.pswp.ui.registerElement({ - name: 'rotate-button-cw', - ariaLabel: 'Rotate clockwise', + name: 'makeCover', + ariaLabel: 'Make this Image the Album Cover', order: 8, isButton: true, - html: '
', + html: '
', + 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: '
', onClick: (event, el) => { pswp.close(); Livewire.dispatch(`image.rotate`, { @@ -49,9 +73,9 @@ document.addEventListener('livewire:navigated', () => { lightbox.pswp.ui.registerElement({ name: 'rotate-button-ccw', ariaLabel: 'Rotate counter-clockwise', - order: 7, + order: 6, isButton: true, - html: '
', + html: '
', onClick: (event, el) => { pswp.close(); Livewire.dispatch(`image.rotate`, { diff --git a/resources/js/sonner.js b/resources/js/sonner.js deleted file mode 100644 index f2b57e7..0000000 --- a/resources/js/sonner.js +++ /dev/null @@ -1,626 +0,0 @@ -//////////////////////// -// Sonner -//////////////////////// - -//////////////////////// -// Constants -//////////////////////// -const VISIBLE_TOASTS_AMOUNT = 4; -const VIEWPORT_OFFSET = "32px"; -const TOAST_LIFETIME = 4000; -const TOAST_WIDTH = 356; -const GAP = 14; -const SWIPE_THRESHOLD = 20; -const TIME_BEFORE_UNMOUNT = 200; - -//////////////////////// -// Sonner -// The Sonner object is a singleton that provides methods to show different types of toasts. -//////////////////////// -window.Sonner = { - /** - * Initializes the toasters in the DOM. - * The function creates a new section element and an ordered list element inside it. - * @param {Object} options - An object with the following properties: - * @param {boolean} options.closeButton - A boolean to control the visibility of the close button on the toasts. - * @param {boolean} options.richColors - A boolean to control the use of rich colors for the toasts. - * @param {string} options.position - A string to control the position of the toasts. The string is a combination of two values: the vertical position (top or bottom) and the horizontal position (left or right). - * @returns {void} - * @example - * Sonner.init({ closeButton: true, richColors: true, position: "bottom-right" }); - */ - init({ - closeButton = false, - richColors = false, - position = "bottom-right", - } = {}) { - if (reinitializeToaster()) { - return; - } - - renderToaster({ closeButton, richColors, position }); - // loadSonnerStyles(); - - const ol = document.getElementById("sonner-toaster-list"); - registerMouseOver(ol); - registerKeyboardShortcuts(ol); - }, - /** - * Shows a new success toast with a specific message. - * @param {string} msg - The message to display in the toast. - * @returns {void} - */ - success(msg, opts = {}) { - return Sonner.show(msg, { icon: 'success', type: "success", ...opts }); - }, - /** - * Shows a new error toast with a specific message. - * @param {string} msg - The message to display in the toast. - * @returns {void} - */ - error(msg, opts = {}) { - return Sonner.show(msg, { icon: 'error', type: "error", ...opts }); - }, - /** - * Shows a new info toast with a specific message. - * @param {string} msg - The message to display in the toast. - * @returns {void} - */ - info(msg, opts = {}) { - return Sonner.show(msg, { icon: 'info', type: "info", ...opts }); - }, - /** - * Shows a new warning toast with a specific message. - * @param {string} msg - The message to display in the toast. - * @returns {void} - */ - warning(msg, opts = {}) { - return Sonner.show(msg, { icon: 'warning', type: "warning", ...opts }); - }, - /** - * Shows a promise loading toast - * @template T promise data type - * @param {Promise} promise - * @param {Object} opts options - * @param {string} opts.loading message to display while loading - * @param {string|(data : T) => string} opts.success function callback / message to show when loaded - * @param {string|(data : Error) => string} opts.success function callback / message to show when errored - */ - promise(promise, opts = {}) { - const toast = Sonner.show(opts.loading ?? 'Loading...', { - icon: 'loading', - type: 'loading', - ...opts, - duration: -1, - }); - - promise - .then(result => { - // Update the message and start the timeout - const msg = typeof opts.success === 'string' ? opts.success : opts.success(result); - toast.setTitle(msg).setIcon('success').setDuration(opts.duration ?? TOAST_LIFETIME); - return result; - }) - .catch(err => { - const msg = typeof opts.error === 'string' ? opts.error : opts.error(err); - toast.setTitle(msg).setIcon('error').setDuration(opts.duration ?? TOAST_LIFETIME); - throw err; - }); - - return promise; - }, - - /** - * Shows a new toast with a specific message, description, and type. - * @param {string} msg - The message to display in the toast. - * @param {Object} options - An object with the following properties: - * @param {string} options.type - The type of the toast. The type can be one of the following values: "success", "error", "info", "warning", or "neutral". - * @param {string} options.description - The description to display in the toast. - * @returns {void} - */ - show(msg, opts = {}) { - const list = document.getElementById("sonner-toaster-list"); - const { toast, id } = renderToast(list, msg, opts); - - // Wait for the toast to be mounted before registering swipe events - //window.setTimeout(function () { - const el = list.children[0]; - const height = el.getBoundingClientRect().height; - - el.setAttribute("data-mounted", "true"); - el.setAttribute("data-initial-height", height); - el.style.setProperty("--initial-height", `${height}px`); - list.style.setProperty("--front-toast-height", `${height}px`); - - registerSwipe(id); - refreshProperties(); - toast.setDuration(opts.duration ?? TOAST_LIFETIME); - //}, 16); - return toast; - }, - /** - * Removes an element with a specific id from the DOM after a delay. - * The element is marked as removed and any previous unmount timeout is cleared. - * A new timeout is set to remove the element from its parent. - * The timeout ensures that all CSS transitions complete before the element is removed. - * - * @param {string} id - The data-id attribute of the element to remove. - */ - remove(id) { - const el = document.querySelector(`[data-id="${id}"]`); - if (!el) return; - - el.setAttribute("data-removed", "true"); - refreshProperties(); - - const previousTid = el.getAttribute("data-unmount-tid"); - if (previousTid) window.clearTimeout(previousTid); - - const tid = window.setTimeout(function () { - el.parentElement?.removeChild(el); - }, TIME_BEFORE_UNMOUNT); - el.setAttribute("data-unmount-tid", tid); - }, -}; - -//////////////////////// -// Assets -//////////////////////// - -const getIcon = (type) => { - switch (type) { - case "success": - return SuccessIcon; - - case "info": - return InfoIcon; - - case "warning": - return WarningIcon; - - case "error": - return ErrorIcon; - - case 'loading': - return Loader; - - default: - return undefined; - } -}; - -const bars = Array(12).fill(0); - -const Loader = ` -
-
- ${bars.map((_, i) => `
`).join('\n')} -
-
-`; - - -const SuccessIcon = ` - - - `; - -const WarningIcon = ` - - - `; - -const InfoIcon = ` - - - `; - -const ErrorIcon = ` - - - `; - -//////////////////////// -// Auxiliary functions -//////////////////////// - -/** - * Generates a unique id for a toast. - * The function generates a unique id by combining the current timestamp with a random string. - * The function returns the unique id as a string. - * @returns {string} - The unique id. - * @example - * const id = genid(); - */ -function genid() { - return ( - Date.now().toString(36) + - Math.random().toString(36).substring(2, 12).padStart(12, 0) - ); -} - -/** - * Creates a new toast element and returns it along with its id. - * The function creates a new list item element and sets its outerHTML to a string containing the toast structure. - * The function also generates a unique id for the toast and returns it along with the toast element. - * @param {Element} list - The list element to append the toast to. - * @param {string} msg - The message to display in the toast. - * @param {Object} options - An object with the following properties: - * @param {string} options.type - The type of the toast. The type can be one of the following values: "success", "error", "info", "warning", or "neutral". - * @param {string} options.description - The description to display in the toast. - * @returns {Object} - An object with the following properties: - * @returns {Element} toast - The toast element. - * @returns {string} id - The unique id of the toast. - */ -function renderToast(list, msg, opts = {}) { - const toast = document.createElement("div"); - list.prepend(toast); - const id = genid(); - const count = list.children.length; - const asset = getIcon(opts.icon) ?? opts.icon; - - - toast.outerHTML = `
  • - ${list.getAttribute("data-close-button") === "true" - ? ` - ` - : "" - } - ${asset - ? `
    ${asset}
    ` - : `
    ` - } -
    -
    - ${msg} -
    - ${opts.description - ? `
    ${opts.description}
    ` - : "" - } -
    -
  • - `; - - return { - id, - toast: { - target: document.querySelector(`[data-id="${id}"]`), - setTitle: function (msg, raw = false) { - const title = document.querySelector(`[data-sonner-toast][data-id=${id}] [data-title]`); - if (raw) title.innerHTML = msg; - else title.textContent = msg; - return this; - }, - setIcon: function (icon) { - const ico = getIcon(icon) ?? ''; - document.querySelector(`[data-sonner-toast][data-id=${id}] [data-icon]`).innerHTML = ico; - return this; - }, - setDuration: function(duration) { - this.target.setAttribute('data-duration', duration); - registerRemoveTimeout(this.target); - return this; - }, - dismiss: function() { - Sonner.remove(this.target.getAttribute("data-id")) - return this; - } - } -}; -} - -/** - * Registers a new remove timeout for a specific element. - * The function sets a new timeout to remove the element from its parent after a delay. - * The timeout ensures that all CSS transitions complete before the element is removed. - * @param {Element} el - The element to register the remove timeout for. - * @param {number} lifetime - How long the toast will last for - * @returns {void} - */ -function registerRemoveTimeout(el) { - if (!el.getAttribute("data-id")) - throw new Error('invalid target for removal'); - - const lifetime = el.getAttribute('data-duration') ?? TOAST_LIFETIME; - if (lifetime < 0) - return; - - // Clear previous duration - if (el.getAttribute("data-remove-tid")) - window.clearTimeout(el.getAttribute("data-remove-tid")); - - // Set new timeout - const tid = window.setTimeout(() => { - Sonner.remove(el.getAttribute("data-id")); - }, lifetime); - el.setAttribute("data-remove-tid", tid); -} - -/** - * Reinitializes the toaster and its children in the DOM. - * @returns {Element} - The ordered list element with the sonner-toaster-list id. - */ -function reinitializeToaster() { - const ol = document.getElementById("sonner-toaster-list"); - if (!ol) return; - for (let i = 0; i < ol.children.length; i++) { - const el = ol.children[i]; - const id = el.getAttribute("data-id"); - registerSwipe(id); - refreshProperties(); - registerRemoveTimeout(el); - } - return ol; -} - -/** - * Creates the toaster in the DOM. - * @param {Object} options - An object with the following properties: - * @param {boolean} options.closeButton - A boolean to control the visibility of the close button on the toasts. - * @param {boolean} options.richColors - A boolean to control the use of rich colors for the toasts. - * @param {string} options.position - A string to control the position of the toasts. The string is a combination of two values: the vertical position (top or bottom) and the horizontal position (left or right). - * @returns {void} - */ -function renderToaster({ closeButton, richColors, position }) { - const el = document.createElement("div"); - document.body.appendChild(el); - position = position.split("-"); - el.outerHTML = ` -
    -
      -
      -`; -} - -/** - * Loads the Sonner styles in the DOM. - * @returns {void} - */ -function loadSonnerStyles() { - var link = document.createElement("link"); - link.href = "./sonner.css"; - link.type = "text/css"; - link.rel = "stylesheet"; - link.media = "screen"; - - document.getElementsByTagName("head")[0].appendChild(link); -} - -/** - * Registers mouse over events on a specific ordered list element in the DOM. - * @param {Element} ol - The ordered list element to register mouse over events on. - * @returns {void} - */ -function registerMouseOver(ol) { - ol.addEventListener("mouseenter", function () { - for (let i = 0; i < ol.children.length; i++) { - const el = ol.children[i]; - if (el.getAttribute("data-expanded") === "true") continue; - el.setAttribute("data-expanded", "true"); - - clearRemoveTimeout(el); - } - }); - ol.addEventListener("mouseleave", function () { - for (let i = 0; i < ol.children.length; i++) { - const el = ol.children[i]; - if (el.getAttribute("data-expanded") === "false") continue; - el.setAttribute("data-expanded", "false"); - - registerRemoveTimeout(el); - } - }); -} - -/** - * Registers keyboard shortcuts for the ordered list element in the DOM. - * The function listens for the Alt+T key combination to expand or collapse the toasts. - * @param {Element} ol - The ordered list element to register keyboard shortcuts for. - * @returns {void} - */ -function registerKeyboardShortcuts(ol) { - window.addEventListener("keydown", function (e) { - if (e.altKey && e.code === "KeyT") { - if (ol.children.length === 0) return; - const expanded = ol.children[0].getAttribute("data-expanded"); - const newExpanded = expanded === "true" ? "false" : "true"; - for (let i = 0; i < ol.children.length; i++) { - ol.children[i].setAttribute("data-expanded", newExpanded); - } - } - }); -} - -/** - * Clears the remove timeout for a specific element. - * @param {Element} el - The element to clear the remove timeout for. - * @returns {void} - */ -function clearRemoveTimeout(el) { - const tid = el.getAttribute("data-remove-tid"); - if (tid) window.clearTimeout(tid); -} - -/** - * Refreshes the properties of the children of a specific list element in the DOM. - * The function iterates over each child of the list, skipping those marked as removed. - * For each remaining child, it updates various data attributes and CSS properties related to its index, visibility, offset, and z-index. - * The function also keeps track of the cumulative height of the elements processed so far to calculate the offset for each element. - */ -function refreshProperties() { - const list = document.getElementById("sonner-toaster-list"); - let heightsBefore = 0; - let removed = 0; - for (let i = 0; i < list.children.length; i++) { - const el = list.children[i]; - if (el.getAttribute("data-removed") === "true") { - removed++; - continue; - } - const idx = i - removed; - el.setAttribute("data-index", idx); - el.setAttribute("data-front", idx === 0 ? "true" : "false"); - el.setAttribute( - "data-visible", - idx < VISIBLE_TOASTS_AMOUNT ? "true" : "false", - ); - el.style.setProperty("--index", idx); - el.style.setProperty("--toasts-before", idx); - el.style.setProperty("--offset", `${GAP * idx + heightsBefore}px`); - el.style.setProperty("--z-index", list.children.length - i); - heightsBefore += Number(el.getAttribute("data-initial-height")); - } -} - -/** - * Registers swipe events on an element with a specific id. - * The element is selected using the id and event listeners are added for pointerdown, pointerup, and pointermove events. - * The swipe gesture is calculated based on the movement of the pointer and the time taken for the swipe. - * If the swipe meets a certain threshold or velocity, the element is marked for removal. - * If the swipe does not meet the threshold, the swipe is reset. - * For more information on the swipe gesture, see the following article: - * https://emilkowal.ski/ui/building-a-toast-component - * - * @param {string} id - The data-id attribute of the element to register swipe events on. - */ -function registerSwipe(id) { - const el = document.querySelector(`[data-id="${id}"]`); - if (!el) return; - let dragStartTime = null; - let pointerStart = null; - const y = el.getAttribute("data-y-position"); - el.addEventListener("pointerdown", function (event) { - dragStartTime = new Date(); - event.target.setPointerCapture(event.pointerId); - if (event.target.tagName === "BUTTON") return; - el.setAttribute("data-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) { - el.setAttribute("data-swipe-out", "true"); - Sonner.remove(id); - return; - } - - el.style.setProperty("--swipe-amount", "0px"); - el.setAttribute("data-swiping", "false"); - }); - - el.addEventListener("pointermove", function (event) { - if (!pointerStart) return; - const yPosition = event.clientY - pointerStart.y; - const xPosition = event.clientX - pointerStart.x; - - const clamp = y === "top" ? Math.min : Math.max; - const clampedY = clamp(0, yPosition); - const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2; - const isAllowedToSwipe = Math.abs(clampedY) > swipeStartThreshold; - - if (isAllowedToSwipe) { - el.style.setProperty("--swipe-amount", `${yPosition}px`); - } else if (Math.abs(xPosition) > swipeStartThreshold) { - // User is swiping in wrong direction so we disable swipe gesture - // for the current pointer down interaction - pointerStart = null; - } - }); -} diff --git a/resources/views/album/show.blade.php b/resources/views/album/show.blade.php deleted file mode 100644 index f528390..0000000 --- a/resources/views/album/show.blade.php +++ /dev/null @@ -1,39 +0,0 @@ -@push('menu') - - - -
      - - - -
      - -@endpush - - - -

      - {{ $album->name }} -

      - @if($album->mutations->count() > 0) - - @else - - - Neue Bilder zu {{ $album->name }} hinzufügen - - - - - @endif -
      \ No newline at end of file diff --git a/resources/views/category/show.blade.php b/resources/views/category/show.blade.php index 3532f56..2d79482 100644 --- a/resources/views/category/show.blade.php +++ b/resources/views/category/show.blade.php @@ -20,28 +20,15 @@ @endpush - - -

      - {{ $category->name }} -

      -
      + +
      + + + @foreach ($albums as $album) -
      - - {{ $album->name }} Cover -
      - {{ $album->name }} -
      -
      - -
      -
      -
      + @endforeach
      diff --git a/resources/views/components/album/element.blade.php b/resources/views/components/album/element.blade.php new file mode 100644 index 0000000..0f6829c --- /dev/null +++ b/resources/views/components/album/element.blade.php @@ -0,0 +1,17 @@ +@props(['album' => null]) + +
      + + {{ $album->name }} Cover +
      + {{ $album->name }} +
      +
      + +
      +
      +
      \ No newline at end of file diff --git a/resources/views/components/category-filter-pill.blade.php b/resources/views/components/category-filter-pill.blade.php index cae5741..6c9dd92 100644 --- a/resources/views/components/category-filter-pill.blade.php +++ b/resources/views/components/category-filter-pill.blade.php @@ -1,12 +1,14 @@ @props(['active' => false ]) @if($active) -
    1. +
    2. + {{ $slot }}
    3. @else -
    4. +
    5. + {{ $slot }}
    6. @endif diff --git a/resources/views/components/category/element.blade.php b/resources/views/components/category/element.blade.php new file mode 100644 index 0000000..d34c877 --- /dev/null +++ b/resources/views/components/category/element.blade.php @@ -0,0 +1,11 @@ +@props(['category' => null]) + +
      + + {{ $category->name }} Cover +
      + {{ $category->name }} +
      +
      +
      \ No newline at end of file diff --git a/resources/views/components/drawer-trigger.blade.php b/resources/views/components/drawer-trigger.blade.php index 5cba602..73818fb 100644 --- a/resources/views/components/drawer-trigger.blade.php +++ b/resources/views/components/drawer-trigger.blade.php @@ -1,3 +1,3 @@ -
      +
      {{ $slot }}
      diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php index 5cbfedd..0517866 100644 --- a/resources/views/components/drawer.blade.php +++ b/resources/views/components/drawer.blade.php @@ -1,7 +1,7 @@