This commit is contained in:
2024-06-12 19:51:59 +02:00
parent 0ce904a7d8
commit 813874a847
16 changed files with 2113 additions and 0 deletions

682
resources/css/sonner.css Normal file
View File

@@ -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%);
}

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

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

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

@@ -0,0 +1,76 @@
import PhotoSwipeLightbox from 'photoswipe/lightbox';
document.addEventListener('livewire:navigated', () => {
const lightbox = new PhotoSwipeLightbox({
gallery: '#album',
children: 'a',
bgOpacity: 1,
arrowPrevSVG: '<svg class="w-12 h-12 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 19-7-7 7-7"/></svg>',
arrowNextSVG: '<svg class="w-12 h-12 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 5 7 7-7 7"/></svg>',
closeSVG: '<div class="bg-transparent p-2 inline-flex items-center me-2 hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/></svg></div>',
zoomSVG: '<div class="bg-transparent p-2 inline-flex items-center me-2 hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg class="pswp__zoom-icn-bar-v w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m21 21-3.5-3.5M10 7v6m-3-3h6m4 0a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/></svg><svg class="pswp__zoom-icn-bar-b hidden w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m21 21-3.5-3.5M7 10h6m4 0a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/></svg></div>',
pswpModule: () => import('photoswipe')
});
lightbox.on('uiRegister', function() {
lightbox.pswp.ui.registerElement({
name: 'download-button',
order: 9,
isButton: true,
tagName: 'button',
html: '<div class="bg-transparent p-2 inline-flex items-center me-2 hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"/></svg></div>',
onInit: (el, pswp) => {
el.setAttribute('download', '');
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener');
pswp.on('change', () => {
el.href = pswp.currSlide.data.element.dataset.pswpDownload;
});
}
});
lightbox.pswp.ui.registerElement({
name: 'rotate-button-cw',
ariaLabel: 'Rotate clockwise',
order: 8,
isButton: true,
html: '<div class="bg-transparent p-2 inline-flex items-center me-2 hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" class="w-6 h-6 text-gray-800 dark:text-white" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M19.407 11.376a7.552 7.552 0 1 1-2.212-5.34"/><path d="M17.555 2.612v4h-4"/></svg></div>',
onClick: (event, el) => {
pswp.close();
Livewire.dispatch(`image.rotate`, {
image_id: pswp.currSlide.data.element.dataset.id,
direction: 'cw'
});
}
});
lightbox.pswp.ui.registerElement({
name: 'rotate-button-ccw',
ariaLabel: 'Rotate counter-clockwise',
order: 7,
isButton: true,
html: '<div class="bg-transparent p-2 inline-flex items-center me-2 hover:bg-gray-100 rounded-lg dark:bg-gray-800 dark:hover:bg-gray-700"><svg xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" class="w-6 h-6 text-gray-800 dark:text-white scale-x-[-1]" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M19.407 11.376a7.552 7.552 0 1 1-2.212-5.34"/><path d="M17.555 2.612v4h-4"/></svg></div>',
onClick: (event, el) => {
pswp.close();
Livewire.dispatch(`image.rotate`, {
image_id: pswp.currSlide.data.element.dataset.id,
direction: 'cw'
});
}
});
});
lightbox.init();
document.addEventListener('livewire:init', () => {
Livewire.hook('morph.added', ({ el }) => {
if(el.dataset.pswpWidth !== undefined) {
if(lightbox.pswp !== undefined) {
lightbox.pswp.options.dataSource.items.push(el);
}
}
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

626
resources/js/sonner.js Normal file
View File

@@ -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<T>} 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 = `
<div class="sonner-loading-wrapper" data-visible='${true}'>
<div class="sonner-spinner">
${bars.map((_, i) => `<div class="sonner-loading-bar" key="spinner-bar-${i}"></div>`).join('\n')}
</div>
</div>
`;
const SuccessIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/>
</svg>`;
const WarningIcon = `
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
height="20"
width="20"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
/>
</svg>`;
const InfoIcon = `
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
height="20"
width="20"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>`;
const ErrorIcon = `
<svg
xmlns="http://www.w3.org/2000/svg"
viewbox="0 0 20 20"
fill="currentColor"
height="20"
width="20"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>`;
////////////////////////
// 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 = `<li
aria-live="polite"
aria-atomic="true"
role="status"
tabindex="0"
data-id="${id}"
data-type="${opts.type}"
data-sonner-toast=""
data-mounted="false"
data-styled="true"
data-promise="false"
data-removed="false"
data-visible="true"
data-y-position="${list.getAttribute("data-y-position")}"
data-x-position="${list.getAttribute("data-x-position")}"
data-index="${0}"
data-front="true"
data-swiping="false"
data-dismissible="true"
data-swipe-out="false"
data-expanded="false"
style="--index: 0; --toasts-before: ${0}; --z-index: ${count}; --offset: 0px; --initial-height: 0px;"
>
${list.getAttribute("data-close-button") === "true"
? `<button
aria-label="Close"
data-disabled=""
class="absolute top-0.5 right-0.5 border border-neutral-800 text-neutral-800 bg-neutral-100 rounded-sm"
onclick="Sonner.remove('${id}')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`
: ""
}
${asset
? `<div data-icon="" class="">${asset}</div>`
: `<div data-icon="" class=""></div>`
}
<div
data-content=""
class="">
<div data-title="" class="">
${msg}
</div>
${opts.description
? `<div data-description="" class="">${opts.description}</div>`
: ""
}
</div>
</li>
`;
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 = `
<section aria-label="Notifications alt+T" tabindex="-1">
<ol
dir="ltr"
tabindex="-1"
data-sonner-toaster="true"
data-theme="light"
data-close-button="${closeButton}"
data-rich-colors="${richColors}"
data-y-position="${position[0]}"
data-x-position="${position[1]}"
style="--front-toast-height: 0px; --offset: ${VIEWPORT_OFFSET}; --width: ${TOAST_WIDTH}px; --gap: ${GAP}px;"
id="sonner-toaster-list"
></ol>
</section>
`;
}
/**
* 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;
}
});
}

View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
x-data="{ darkMode: $persist(false) }"
:class="{'dark': darkMode}"
x-init="
if (!('darkMode' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) {
localStorage.setItem('darkMode', JSON.stringify(true));
}
darkMode = JSON.parse(localStorage.getItem('darkMode'));
$watch('darkMode', value => localStorage.setItem('darkMode', JSON.stringify(value)))"
>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@vite('resources/css/app.css')
@vite('resources/js/app.js')
<title>{{ $title ?? config('app.name') }}</title>
</head>
<body class="bg-white dark:bg-gray-800 min-h-screen flex flex-col">
@persist('theme-switcher')
<x-theme-switcher />
@endpersist
{{ $slot }}
<x-menu />
<style>
#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);
}
</style>
<!--
<div id="toaster" class="fixed right-4 top-4 z-50 group">
<div class="absolute flex items-center w-full max-w-xs p-4 mb-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800" data-first="true" style="z-index: 3; --index: 0;" role="alert">
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-green-500 bg-green-100 rounded-lg dark:bg-green-800 dark:text-green-200">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z"/>
</svg>
<span class="sr-only">Check icon</span>
</div>
<div class="ms-3 text-sm font-normal">Item moved successfully.Item moved successfully.Item moved successfully.Item moved successfully.</div>
<button type="button" class="ms-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#toast-success" aria-label="Close">
<span class="sr-only">Close</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
</button>
</div>
<div class="absolute flex items-center w-full max-w-xs p-4 mb-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800" style="z-index: 2; --index: 1" role="alert">
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-red-500 bg-red-100 rounded-lg dark:bg-red-800 dark:text-red-200">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 11.793a1 1 0 1 1-1.414 1.414L10 11.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L8.586 10 6.293 7.707a1 1 0 0 1 1.414-1.414L10 8.586l2.293-2.293a1 1 0 0 1 1.414 1.414L11.414 10l2.293 2.293Z"/>
</svg>
<span class="sr-only">Error icon</span>
</div>
<div class="ms-3 text-sm font-normal">Item has been deleted.</div>
<button type="button" class="ms-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#toast-danger" aria-label="Close">
<span class="sr-only">Close</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
</button>
</div>
<div class="flex items-center w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800" style="z-index: 1; --index: 2" role="alert">
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-orange-500 bg-orange-100 rounded-lg dark:bg-orange-700 dark:text-orange-200">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z"/>
</svg>
<span class="sr-only">Warning icon</span>
</div>
<div class="ms-3 text-sm font-normal">Improve password difficulty.</div>
<button type="button" class="ms-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#toast-warning" aria-label="Close">
<span class="sr-only">Close</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
</button>
</div>
</div>-->
</body>
</html>

View File

@@ -0,0 +1,74 @@
@push('menu')
<x-menu-action tooltip="Einstellungen">
<svg class="w-5 h-5 mb-1 text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12.25V1m0 11.25a2.25 2.25 0 0 0 0 4.5m0-4.5a2.25 2.25 0 0 1 0 4.5M4 19v-2.25m6-13.5V1m0 2.25a2.25 2.25 0 0 0 0 4.5m0-4.5a2.25 2.25 0 0 1 0 4.5M10 19V7.75m6 4.5V1m0 11.25a2.25 2.25 0 1 0 0 4.5 2.25 2.25 0 0 0 0-4.5ZM16 19v-2"/>
</svg>
</x-menu-action>
<div class="flex items-center justify-center">
<x-drawer-trigger target="image-add" action="open">
<button data-tooltip-target="tooltip-new" type="button" class="inline-flex items-center justify-center w-10 h-10 font-medium bg-blue-600 rounded-full hover:bg-blue-700 group focus:ring-4 focus:ring-blue-300 focus:outline-none dark:focus:ring-blue-800">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 18">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 1v16M1 9h16"/>
</svg>
<span class="sr-only">Add Images</span>
</button>
</x-drawer-trigger>
</div>
@if(!$album->hasProcessingMedia)
<div id="tooltip-new" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
Add images
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
@endif
@endpush
<div>
<x-hero-search></x-hero-search>
<h1 class="mb-4 mx-8 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
{{ $album->name }}
</h1>
<div id="album">
<div class="m-8 flex flex-wrap flex-row gap-4" @if($album->hasProcessingMedia) wire:poll @endif>
@foreach ($images as $image)
@if($image->isProcessing)
<div wire:transition wire:key="placeholder_{{ $image->id }}" role="status" class="flex items-center justify-center h-80 flex-grow bg-gray-300 rounded-lg animate-pulse dark:bg-gray-700 min-w-80">
<div class="min-w-lg">
<svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 18">
<path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z"/>
</svg>
<span class="sr-only">Vorschaubild wird generiert, bitte warten...</span>
</div>
</div>
@else
<a
class="relative group rounded-lg cursor-pointer h-80 flex-grow overflow-hidden"
href="{{ $image->lightbox['location'] }}"
data-pswp-width="{{ $image->lightbox['width'] }}"
data-pswp-download="{{ $image->getDownload() }}"
data-pswp-height="{{ $image->lightbox['height'] }}"
data-cropped="true"
data-id="{{ $image->id }}"
wire:key="image_{{ $image->id }}"
wire:transition
>
<img class="max-h-full min-w-full align-bottom object-cover"
src="{{ $image->getThumbnail() }}" alt="Album image">
<div class="opacity-0 group-hover:opacity-40 absolute inset-0 w-full h-full bg-black flex items-center justify-center transition-opacity">
<svg class="w-1/2 h-1/2 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
</div>
</a>
@endif
@endforeach
</div>
</div>
<x-drawer name="image-add" >
<x-slot:title>Neue Bilder zu {{ $album->name }} hinzufügen</x-slot:title>
<x-slot:content>
<livewire:drawer.album.addImage :album="$album"></livewire:drawer.album.addImage>
</x-slot:content>
</x-drawer>
</div>

View File

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