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 = ` +