diff --git a/app/Importers/Image/Jobs/FinishImageModification.php b/app/Importers/Image/Jobs/FinishImageModification.php new file mode 100644 index 0000000..3104a51 --- /dev/null +++ b/app/Importers/Image/Jobs/FinishImageModification.php @@ -0,0 +1,51 @@ +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, + ]); + } +} diff --git a/app/Importers/Image/Jobs/RotateImage.php b/app/Importers/Image/Jobs/RotateImage.php new file mode 100644 index 0000000..c08f576 --- /dev/null +++ b/app/Importers/Image/Jobs/RotateImage.php @@ -0,0 +1,50 @@ +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 + ]); + } +} diff --git a/app/Livewire/Album/Show.php b/app/Livewire/Album/Show.php new file mode 100644 index 0000000..1841d89 --- /dev/null +++ b/app/Livewire/Album/Show.php @@ -0,0 +1,65 @@ +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); + } + + 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(); + } + + #[Title('Show Album')] + public function render(): View|Factory + { + return view('livewire.album.show'); + } +} diff --git a/app/Livewire/Image/Grid.php b/app/Livewire/Image/Grid.php new file mode 100644 index 0000000..a619c0d --- /dev/null +++ b/app/Livewire/Image/Grid.php @@ -0,0 +1,32 @@ + -90, + 'cw' => 90, + default => 0, + } + } + + public function mount(Collection $images): void { + $this->images = $images; + } + + public function render(): View|Factory + { + return view('livewire.image.grid'); + } +} diff --git a/resources/css/sonner.css b/resources/css/sonner.css new file mode 100644 index 0000000..56e9dd5 --- /dev/null +++ b/resources/css/sonner.css @@ -0,0 +1,682 @@ +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/filepond.js b/resources/js/filepond.js new file mode 100644 index 0000000..57bda1e --- /dev/null +++ b/resources/js/filepond.js @@ -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; + }, + }); +}); \ No newline at end of file diff --git a/resources/js/lightbox.js b/resources/js/lightbox.js new file mode 100644 index 0000000..a960025 --- /dev/null +++ b/resources/js/lightbox.js @@ -0,0 +1,76 @@ +import PhotoSwipeLightbox from 'photoswipe/lightbox'; + +document.addEventListener('livewire:navigated', () => { + const lightbox = new PhotoSwipeLightbox({ + gallery: '#album', + children: 'a', + bgOpacity: 1, + arrowPrevSVG: '', + arrowNextSVG: '', + closeSVG: '
', + zoomSVG: '
', + pswpModule: () => import('photoswipe') + }); + + lightbox.on('uiRegister', function() { + lightbox.pswp.ui.registerElement({ + name: 'download-button', + order: 9, + isButton: true, + tagName: 'button', + html: '
', + + 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: 'rotate-button-cw', + ariaLabel: 'Rotate clockwise', + order: 8, + isButton: true, + html: '
', + 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: 7, + isButton: true, + html: '
', + 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); + } + } + }); + }); +}); \ No newline at end of file diff --git a/resources/js/notification.js b/resources/js/notification.js new file mode 100644 index 0000000..8ccb7d6 --- /dev/null +++ b/resources/js/notification.js @@ -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`); +} \ No newline at end of file diff --git a/resources/js/notification/helpers.js b/resources/js/notification/helpers.js new file mode 100644 index 0000000..0ccbc10 --- /dev/null +++ b/resources/js/notification/helpers.js @@ -0,0 +1,6 @@ +export function genId() { + return ( + Date.now().toString(36) + + Math.random().toString(36).substring(2, 12).padStart(12, 0) + ); +} \ No newline at end of file diff --git a/resources/js/notification/icons.js b/resources/js/notification/icons.js new file mode 100644 index 0000000..0c7302c --- /dev/null +++ b/resources/js/notification/icons.js @@ -0,0 +1,41 @@ +const SuccessIcon = ` +
+ + Check icon +
`; + +const WarningIcon = ` +
+ + Warning icon +
`; + +const InfoIcon = ` +
+ + Error icon +
`; + +const ErrorIcon = ` +
+ + Error icon +
`; + +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; + } +}; \ No newline at end of file diff --git a/resources/js/notification/touch.js b/resources/js/notification/touch.js new file mode 100644 index 0000000..fdd7186 --- /dev/null +++ b/resources/js/notification/touch.js @@ -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; + } + }); +} \ No newline at end of file diff --git a/resources/js/notification/ui.js b/resources/js/notification/ui.js new file mode 100644 index 0000000..67d707f --- /dev/null +++ b/resources/js/notification/ui.js @@ -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 = ` +
`; + 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 = ` + `; + + return {id, toast}; +} \ No newline at end of file diff --git a/resources/js/sonner.js b/resources/js/sonner.js new file mode 100644 index 0000000..f2b57e7 --- /dev/null +++ b/resources/js/sonner.js @@ -0,0 +1,626 @@ +//////////////////////// +// 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/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php new file mode 100644 index 0000000..4f8627e --- /dev/null +++ b/resources/views/components/layouts/app.blade.php @@ -0,0 +1,112 @@ + + + + + + @vite('resources/css/app.css') + @vite('resources/js/app.js') + {{ $title ?? config('app.name') }} + + + @persist('theme-switcher') + + @endpersist + {{ $slot }} + + + + + + diff --git a/resources/views/livewire/album/show.blade.php b/resources/views/livewire/album/show.blade.php new file mode 100644 index 0000000..054bc21 --- /dev/null +++ b/resources/views/livewire/album/show.blade.php @@ -0,0 +1,74 @@ +@push('menu') + + + +
      + + + +
      + @if(!$album->hasProcessingMedia) + + @endif +@endpush + +
      + +

      + {{ $album->name }} +

      + +
      +
      hasProcessingMedia) wire:poll @endif> + @foreach ($images as $image) + @if($image->isProcessing) +
      +
      + + Vorschaubild wird generiert, bitte warten... +
      +
      + @else + + Album image +
      + +
      +
      + @endif + @endforeach +
      +
      + + Neue Bilder zu {{ $album->name }} hinzufügen + + + + +
      \ No newline at end of file diff --git a/resources/views/livewire/image/grid.blade.php b/resources/views/livewire/image/grid.blade.php new file mode 100644 index 0000000..6f54206 --- /dev/null +++ b/resources/views/livewire/image/grid.blade.php @@ -0,0 +1,21 @@ +
      + @foreach ($images as $image) + + Album image +
      + +
      +
      + @endforeach +
      \ No newline at end of file