mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-15 16:27:26 +02:00
Initial open source release
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* @import url('https://fonts.googleapis.com/css2?family=Phudu:wght@700;900&family=Titillium+Web:wght@400;600&display=swap'); */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Titillium Web';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/TitilliumWeb-Regular.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Titillium Web';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/TitilliumWeb-SemiBold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Titillium Web';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/TitilliumWeb-Bold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* For Phudu */
|
||||
@font-face {
|
||||
font-family: 'Phudu';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/Phudu-Bold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Phudu';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url('/Phudu-Black.ttf') format('truetype');
|
||||
}
|
||||
|
||||
body {
|
||||
@apply [&::-webkit-scrollbar]:w-2
|
||||
[&::-webkit-scrollbar-track]:bg-gray-100
|
||||
[&::-webkit-scrollbar-thumb]:rounded-md
|
||||
[&::-webkit-scrollbar-thumb]:bg-pc-dusty-light-blue;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
import { API } from '$lib/api/api.js';
|
||||
import { immediateResponseHandler } from '$lib/api/middleware';
|
||||
|
||||
// api singleton where each response is proxyed to the responseHandler
|
||||
export const api = new Proxy(API.instance, {
|
||||
get: function(target, prop) {
|
||||
const apiSection = target[prop];
|
||||
const apiSectionProxy = new Proxy(apiSection, {
|
||||
get: function(target, prop) {
|
||||
const method = new Proxy(target[prop], {
|
||||
apply: async (target, _, argumentsList) => {
|
||||
return immediateResponseHandler(await target(...argumentsList));
|
||||
}
|
||||
});
|
||||
return method
|
||||
}
|
||||
});
|
||||
return apiSectionProxy;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Represents the response object returned by the API functions.
|
||||
* @typedef {Object} ApiResponse
|
||||
* @property {boolean} success - Indicates whether the request was successful.
|
||||
* @property {number} statusCode - The status code of the response.
|
||||
* @property {string} error - The error message, if any.
|
||||
* @property {any} data - The data returned by the request.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches JSON data from the specified URL using the GET method.
|
||||
* @param {string} url - The URL to fetch the JSON data from.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the response object containing the JSON data.
|
||||
*/
|
||||
export const getJSON = async (url) => {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET'
|
||||
});
|
||||
const body = await res.json();
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends JSON data to the specified URL using the POST method.
|
||||
* @param {string} url - The URL to send the JSON data to.
|
||||
* @param {Object} data - The JSON data to send.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the response object containing the JSON data.
|
||||
*/
|
||||
export const postJSON = async (url, data) => {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
let body = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch (e) {
|
||||
body = {
|
||||
success: false,
|
||||
error: 'invalid JSON in response'
|
||||
};
|
||||
}
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends JSON data to the specified URL using the POST method.
|
||||
* @param {string} url - The URL to send the JSON data to.
|
||||
* @param {Object} data - The JSON data to send.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the response object containing the JSON data.
|
||||
*/
|
||||
export const patchJSON = async (url, data) => {
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
let body = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch (e) {
|
||||
body = {
|
||||
success: false,
|
||||
error: 'invalid JSON in response'
|
||||
};
|
||||
}
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends JSON data to the specified URL using the PUT method.
|
||||
* @param {string} url - The URL to send the JSON data to.
|
||||
* @param {Object} data - The JSON data to send.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the response object containing the JSON data.
|
||||
*/
|
||||
export const putJSON = async (url, data) => {
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
let body = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch (e) {
|
||||
body = {
|
||||
success: false,
|
||||
error: 'invalid JSON in response'
|
||||
};
|
||||
}
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends JSON data to the specified URL using the DELETE method.
|
||||
* @param {string} url - The URL to send the JSON data to.
|
||||
* @param {Object} data - The JSON data to send.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the response object containing the JSON data.
|
||||
*/
|
||||
export const deleteJSON = async (url, data) => {
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
let body = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch (e) {
|
||||
body = {
|
||||
success: false,
|
||||
error: 'invalid JSON in response',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends multipart form data to the specified URL using the POST method.
|
||||
* @param {string} url - The URL to send the multipart data to.
|
||||
* @param {FormData} formData - The FormData object containing the data to send.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the response object containing the JSON data.
|
||||
*/
|
||||
export const postMultipart = async (url, formData) => {
|
||||
console.log(formData);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
let body = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch (e) {
|
||||
body = {
|
||||
success: false,
|
||||
error: 'invalid JSON in response'
|
||||
};
|
||||
}
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a DELETE request to the specified URL.
|
||||
* @param {string} url - The URL to send the DELETE request to.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the response object containing the JSON data.
|
||||
*/
|
||||
export const deleteReq = async (url) => {
|
||||
// Function implementation
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
try {
|
||||
const body = await res.json();
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
} catch (e) {
|
||||
return newResponse(false, res.status, 'invalid JSON in response', null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new response object.
|
||||
* @param {boolean} success - Indicates whether the request was successful.
|
||||
* @param {number} statusCode - The status code of the response.
|
||||
* @param {string} error - The error message, if any.
|
||||
* @param {any} data - The data returned by the request.
|
||||
* @returns {Object} - The response object.
|
||||
*/
|
||||
export function newResponse(success, statusCode, error, data) {
|
||||
return {
|
||||
success: success,
|
||||
statusCode: statusCode,
|
||||
error: error,
|
||||
data: data
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// handle not ok typical responses such as unauthenticated, renew password and such
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
/**
|
||||
* @param {import("./client").ApiResponse} apiResponse
|
||||
* @returns {import("./client").ApiResponse} apiResponse
|
||||
**/
|
||||
export const immediateResponseHandler = (apiResponse) => {
|
||||
// Unauthenticated move the user to the login page
|
||||
if (apiResponse.statusCode === 401) {
|
||||
goto('/login');
|
||||
window.location.reload();
|
||||
}
|
||||
// If the user must renew their password, move them to the renew password page
|
||||
if (apiResponse.statusCode === 400 && apiResponse.error === 'New password required') {
|
||||
goto('/login/reset-password');
|
||||
return;
|
||||
}
|
||||
return apiResponse;
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
<script>
|
||||
import { onMount, tick } from 'svelte';
|
||||
import FormButton from './FormButton.svelte';
|
||||
import Input from './Input.svelte';
|
||||
import TextField from './TextField.svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import FormError from './FormError.svelte';
|
||||
|
||||
export let visible = true;
|
||||
export let verification = null;
|
||||
export let noCancel = false;
|
||||
export let headline = 'Confirm';
|
||||
export let cancel = 'Cancel';
|
||||
export let ok = 'Yes, proceed';
|
||||
export let onConfirm;
|
||||
|
||||
let isLoading = false;
|
||||
let error = '';
|
||||
let alertElement;
|
||||
let previousActiveElement;
|
||||
let focusableElements = [];
|
||||
let firstFocusableElement;
|
||||
let lastFocusableElement;
|
||||
let alertInitialized = false;
|
||||
|
||||
$: {
|
||||
if (visible && !alertInitialized) {
|
||||
window.addEventListener('keydown', keyHandler);
|
||||
// Prevent body scrolling when alert is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
handleAlertOpen();
|
||||
alertInitialized = true;
|
||||
} else if (!visible && alertInitialized) {
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Restore body scrolling when alert is closed
|
||||
document.body.style.overflow = 'auto';
|
||||
handleAlertClose();
|
||||
alertInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
} else if (e.key === 'Tab') {
|
||||
// Check if the focused element is an input field or within a complex component
|
||||
const focusedElement = document.activeElement;
|
||||
const isInputField =
|
||||
focusedElement?.tagName === 'INPUT' || focusedElement?.tagName === 'TEXTAREA';
|
||||
const isWithinTextField = focusedElement?.closest('.relative');
|
||||
|
||||
if (isInputField || isWithinTextField) {
|
||||
// Allow normal tab behavior within input fields and TextFields
|
||||
return;
|
||||
}
|
||||
|
||||
handleTabKey(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabKey = (e) => {
|
||||
// Store the current focused element before updating the list
|
||||
const currentlyFocused = document.activeElement;
|
||||
|
||||
// Update focusable elements before handling tab to account for dynamic changes
|
||||
updateFocusableElements();
|
||||
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
// Always prevent default tab behavior to keep focus within alert
|
||||
e.preventDefault();
|
||||
|
||||
let currentIndex = focusableElements.indexOf(currentlyFocused);
|
||||
|
||||
// If current element is not found (-1), try to find a related element
|
||||
if (currentIndex === -1) {
|
||||
// Check if the current element is inside a TextField or similar component
|
||||
const parentComponent = currentlyFocused?.closest('.relative');
|
||||
if (parentComponent) {
|
||||
// Look for the input element within the same component
|
||||
const inputInComponent = parentComponent.querySelector('input');
|
||||
if (inputInComponent) {
|
||||
const inputIndex = focusableElements.indexOf(inputInComponent);
|
||||
if (inputIndex !== -1) {
|
||||
// Use the input's position for navigation
|
||||
currentIndex = inputIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still can't find the element, handle it gracefully
|
||||
if (currentIndex === -1) {
|
||||
if (e.shiftKey) {
|
||||
lastFocusableElement?.focus();
|
||||
} else {
|
||||
firstFocusableElement?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Now handle normal tab navigation
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab - go to previous element
|
||||
if (currentIndex <= 0) {
|
||||
lastFocusableElement?.focus();
|
||||
} else {
|
||||
focusableElements[currentIndex - 1]?.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab - go to next element
|
||||
if (currentIndex >= focusableElements.length - 1) {
|
||||
firstFocusableElement?.focus();
|
||||
} else {
|
||||
focusableElements[currentIndex + 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFocusableElements = () => {
|
||||
if (!alertElement) return [];
|
||||
|
||||
const focusableSelectors = [
|
||||
'button:not([disabled])',
|
||||
'[href]',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"]):not([disabled])',
|
||||
'details',
|
||||
'summary'
|
||||
];
|
||||
|
||||
const elements = alertElement.querySelectorAll(focusableSelectors.join(', '));
|
||||
return Array.from(elements).filter((el) => {
|
||||
return el.offsetWidth > 0 && el.offsetHeight > 0 && !el.hasAttribute('hidden');
|
||||
});
|
||||
};
|
||||
|
||||
const updateFocusableElements = () => {
|
||||
focusableElements = getFocusableElements();
|
||||
|
||||
// Exclude the close button from being the first focusable element
|
||||
const closeButton = alertElement?.querySelector('[data-close-button]');
|
||||
if (closeButton && focusableElements.length > 1) {
|
||||
focusableElements = focusableElements.filter((el) => el !== closeButton);
|
||||
focusableElements.push(closeButton); // Add close button to the end
|
||||
}
|
||||
|
||||
firstFocusableElement = focusableElements[0] || null;
|
||||
lastFocusableElement = focusableElements[focusableElements.length - 1] || null;
|
||||
};
|
||||
|
||||
const handleAlertOpen = async () => {
|
||||
// Store the currently focused element
|
||||
previousActiveElement = document.activeElement;
|
||||
|
||||
// Wait for the DOM to update
|
||||
await tick();
|
||||
|
||||
updateFocusableElements();
|
||||
|
||||
// Focus priority: 1) Input fields (for confirmation), 2) Cancel button, 3) Other elements
|
||||
const firstInput = focusableElements.find(
|
||||
(el) => el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
|
||||
);
|
||||
const cancelButton = focusableElements.find(
|
||||
(el) =>
|
||||
el.tagName === 'BUTTON' &&
|
||||
(el.textContent?.toLowerCase().includes('cancel') ||
|
||||
el.textContent?.toLowerCase().includes('no') ||
|
||||
el.type === 'reset')
|
||||
);
|
||||
|
||||
if (firstInput) {
|
||||
firstInput.focus();
|
||||
} else if (cancelButton) {
|
||||
cancelButton.focus();
|
||||
} else if (firstFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlertClose = () => {
|
||||
// Restore focus to the previously focused element
|
||||
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Ensure body scrolling is restored when component is destroyed
|
||||
document.body.style.overflow = 'auto';
|
||||
// Restore focus if alert was open when component was destroyed
|
||||
if (visible && previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
error = '';
|
||||
};
|
||||
});
|
||||
|
||||
beforeNavigate((opts) => {
|
||||
if (!opts.from || !opts.to || !visible) {
|
||||
return;
|
||||
}
|
||||
const navigationIsNotOnSamePage = opts.from.url.pathname === opts.to.url.pathname;
|
||||
if (opts.type === 'popstate' && !navigationIsNotOnSamePage) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
const onClickConfirm = async (e) => {
|
||||
e.preventDefault();
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await onConfirm();
|
||||
if (!res?.success) {
|
||||
throw res?.error || 'An error occurred';
|
||||
}
|
||||
close();
|
||||
} catch (e) {
|
||||
if (/Type.+\ to\ delete/.test(e)) {
|
||||
/** @type {HTMLInputElement} */
|
||||
const ele = document.querySelector('#confirmDelete');
|
||||
ele.setCustomValidity(e);
|
||||
ele.reportValidity();
|
||||
return;
|
||||
}
|
||||
error = e;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
visible = false;
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Restore body scrolling when alert is closed
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed top-0 left-0 w-full h-full bg-cta-blue opacity-20 blur-xl" />
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full flex justify-center items-center backdrop-blur-sm z-20"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="alert-title"
|
||||
aria-describedby="alert-description"
|
||||
>
|
||||
<section
|
||||
bind:this={alertElement}
|
||||
class="shadow-xl w-[32rem] bg-white opacity-100 rounded-md flex flex-col"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="bg-red-700 text-white rounded-t-md py-4 px-8 flex items-center justify-between flex-shrink-0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white mr-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 id="alert-title" class="uppercase mr-8 font-semibold text-2xl">{headline}</h1>
|
||||
</div>
|
||||
<button
|
||||
class="w-4 hover:scale-110"
|
||||
on:click={close}
|
||||
disabled={isLoading}
|
||||
data-close-button
|
||||
aria-label="Close alert"
|
||||
>
|
||||
<img class="w-full" src="/close-white.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-8 py-6">
|
||||
<div id="alert-description" class="text-gray-600">
|
||||
{#if $$slots.default}
|
||||
<slot />
|
||||
{:else}
|
||||
Are you sure you want to proceed with this action?
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if verification}
|
||||
<div class="mt-4">
|
||||
<TextField>Enter '{verification}' to confirm the action</TextField>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4">
|
||||
<FormError message={error} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end px-8 bg-gray-50 rounded-b-md"
|
||||
>
|
||||
{#if !noCancel}
|
||||
<button
|
||||
type="reset"
|
||||
on:click={close}
|
||||
class="bg-slate-400 hover:bg-slate-300 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md"
|
||||
>
|
||||
{cancel}
|
||||
</button>
|
||||
{/if}
|
||||
<FormButton isSubmitting={isLoading} on:click={onClickConfirm}>{ok}</FormButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
import { autoRefreshStore, getPageAutoRefresh, setPageAutoRefresh } from '$lib/store/autoRefresh';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import TextFieldSelect from './TextFieldSelect.svelte';
|
||||
import { BiMap } from '$lib/utils/maps';
|
||||
|
||||
export let onRefresh;
|
||||
export let isLoading = false;
|
||||
export let pageId = JSON.stringify($page.route);
|
||||
|
||||
const options = new BiMap({
|
||||
Disabled: '0',
|
||||
'5s': '5000',
|
||||
'30s': '30000',
|
||||
'1m': '60000',
|
||||
'5m': '300000'
|
||||
});
|
||||
|
||||
let intervalId;
|
||||
let settings;
|
||||
|
||||
$: {
|
||||
if (pageId) {
|
||||
settings = getPageAutoRefresh(pageId);
|
||||
autoRefreshStore.set(settings);
|
||||
}
|
||||
}
|
||||
|
||||
function handleIntervalChange(optKey) {
|
||||
const value = Number(options.byKey(optKey));
|
||||
autoRefreshStore.setEnabled(value > 0);
|
||||
autoRefreshStore.setInterval(value);
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
stopAutoRefresh();
|
||||
if ($autoRefreshStore.enabled && $autoRefreshStore.interval > 0) {
|
||||
intervalId = setInterval(async () => {
|
||||
if (!$autoRefreshStore.enabled || isLoading) return;
|
||||
await onRefresh();
|
||||
}, $autoRefreshStore.interval);
|
||||
}
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
$: if ($autoRefreshStore) {
|
||||
startAutoRefresh();
|
||||
if (pageId) {
|
||||
setPageAutoRefresh(pageId, $autoRefreshStore);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopAutoRefresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-2">
|
||||
<TextFieldSelect
|
||||
id="autoRefresh"
|
||||
value={$autoRefreshStore.enabled
|
||||
? options.byValue($autoRefreshStore.interval.toString())
|
||||
: 'Disabled'}
|
||||
onSelect={handleIntervalChange}
|
||||
options={options.keys()}
|
||||
inline={true}
|
||||
size={'small'}>Auto-Refresh</TextFieldSelect
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
export let disabled = false;
|
||||
/** @type {'button'|'submit'} */
|
||||
export let type = 'button';
|
||||
</script>
|
||||
|
||||
<button
|
||||
{disabled}
|
||||
class="self-start mt-6 bg-gradient-to-b from-blue-500 to-indigo-400 px-4 w-56 py-2 hover:from-blue-400 hover:to-indigo-400 text-white font-bold uppercase rounded-md mb-10"
|
||||
on:click
|
||||
class:opacity-50={disabled}
|
||||
class:cursor-not-allowed={disabled}
|
||||
{type}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
export let backgroundColor = 'bg-cta-blue';
|
||||
/*
|
||||
linear-gradient(#64a6e6, #9198e5);
|
||||
*/
|
||||
export let css = '';
|
||||
export let size = 'medium';
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="{backgroundColor} px-6 py-2 text-white rounded-md uppercase font-semibold hover:opacity-80 text-sm {css}"
|
||||
class:w-32={size === 'small'}
|
||||
class:w-36={size === 'medium'}
|
||||
class:w-60={size === 'large'}
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
export let disabled = false;
|
||||
/**
|
||||
* @type {"submit" | "button" | "reset"}
|
||||
|
||||
*/
|
||||
export let type = 'submit';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center w-full p-4 mt-8 h-16">
|
||||
<button
|
||||
{disabled}
|
||||
{type}
|
||||
class="w-full p-2 text-white text-2xl bg-cta-blue rounded-lg font-phudu font-bold"
|
||||
>Login</button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,514 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
addDays,
|
||||
subDays,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
getDay,
|
||||
getDaysInMonth
|
||||
} from 'date-fns';
|
||||
import { scrollBarClassesHorizontal, scrollBarClassesVertical } from '$lib/utils/scrollbar';
|
||||
|
||||
/** @type * */
|
||||
export let campaigns = [];
|
||||
/** @type {Date} */
|
||||
export let start = startOfMonth(new Date());
|
||||
/** @type {Date} */
|
||||
export let end = endOfMonth(new Date());
|
||||
|
||||
export let onChangeDate;
|
||||
|
||||
let container;
|
||||
let currentMonth = startOfMonth(new Date());
|
||||
let isLoadingNewMonth = false;
|
||||
let weeks = [];
|
||||
let isInitialized = false;
|
||||
|
||||
let activeFilters = {
|
||||
SCHEDULED: true,
|
||||
ACTIVE: true,
|
||||
COMPLETED: true,
|
||||
SELF_MANAGED: true
|
||||
};
|
||||
|
||||
let isGeneratingCalendar = false;
|
||||
|
||||
const COLORS = {
|
||||
SCHEDULED: '#62aded',
|
||||
ACTIVE: '#5557f6',
|
||||
COMPLETED: '#69e1ab',
|
||||
SELF_MANAGED: '#9F7AEA'
|
||||
};
|
||||
|
||||
function sortCampaignsByPriority(campaigns, day) {
|
||||
const dayTime = startOfDay(day).getTime();
|
||||
|
||||
// Helper function to get campaign type priority
|
||||
function getTypePriority(campaign) {
|
||||
if (campaign.sendStartAt && campaign.sendStartAt > new Date().toISOString()) {
|
||||
return 0; // Scheduled gets highest priority
|
||||
}
|
||||
if (campaign.sendStartAt && !campaign.anonymizedAt && !campaign.closedAt) {
|
||||
return 1; // Active gets second priority
|
||||
}
|
||||
if (!campaign.sendStartAt || !campaign.sendEndAt) {
|
||||
return 2; // Self-managed gets third priority
|
||||
}
|
||||
return 3; // Completed gets lowest priority
|
||||
}
|
||||
|
||||
return campaigns.sort((a, b) => {
|
||||
// 1. Absolute highest priority: campaign's sendStartAt is on this day
|
||||
const aSendStartTime = a.sendStartAt ? startOfDay(new Date(a.sendStartAt)).getTime() : null;
|
||||
const bSendStartTime = b.sendStartAt ? startOfDay(new Date(b.sendStartAt)).getTime() : null;
|
||||
|
||||
if (aSendStartTime === dayTime && bSendStartTime !== dayTime) return -1;
|
||||
if (bSendStartTime === dayTime && aSendStartTime !== dayTime) return 1;
|
||||
|
||||
// 2. Second priority: type of campaign (scheduled > active > self-managed > completed)
|
||||
const aTypePriority = getTypePriority(a);
|
||||
const bTypePriority = getTypePriority(b);
|
||||
if (aTypePriority !== bTypePriority) {
|
||||
return aTypePriority - bTypePriority;
|
||||
}
|
||||
|
||||
// 3. If same type, sort by sendStartAt date (if exists) or start date
|
||||
const aTime = aSendStartTime || startOfDay(a.start).getTime();
|
||||
const bTime = bSendStartTime || startOfDay(b.start).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
}
|
||||
|
||||
function sortByName(campaigns) {
|
||||
return [...campaigns].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function updateDateRange() {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
|
||||
// Calculate first day of calendar (might be in previous month)
|
||||
let calendarStart = monthStart;
|
||||
const firstDayOfWeek = getDay(monthStart);
|
||||
if (firstDayOfWeek > 0) {
|
||||
calendarStart = subDays(monthStart, firstDayOfWeek);
|
||||
}
|
||||
|
||||
// Calculate last day of calendar (might be in next month)
|
||||
const lastDayOfMonth = endOfMonth(monthStart);
|
||||
const lastDayOfWeek = getDay(lastDayOfMonth);
|
||||
const calendarEnd = addDays(lastDayOfMonth, 6 - lastDayOfWeek);
|
||||
|
||||
start = calendarStart;
|
||||
end = calendarEnd;
|
||||
}
|
||||
|
||||
/** @type * */
|
||||
$: calendarCampaigns = campaigns.map((campaign) => {
|
||||
const campaignStart = campaign.sendStartAt
|
||||
? new Date(campaign.sendStartAt)
|
||||
: new Date(campaign.createdAt);
|
||||
|
||||
let endDate = null;
|
||||
|
||||
// 1. priority when it was closed
|
||||
if (campaign.closedAt) {
|
||||
endDate = new Date(campaign.closedAt);
|
||||
}
|
||||
// 2. .. when it should close
|
||||
else if (campaign.closeAt) {
|
||||
endDate = new Date(campaign.closeAt);
|
||||
}
|
||||
// 3. .. when it will be anonymized (also close)
|
||||
else if (campaign.anonymizeAt) {
|
||||
endDate = new Date(campaign.anonymizeAt);
|
||||
// 4. .. if not self-managed then end delivery time
|
||||
} else if (campaign.sendEndAt) {
|
||||
endDate = new Date(campaign.sendEndAt);
|
||||
} else {
|
||||
// .. else the campaign runs endlessly into the future
|
||||
const farFuture = new Date(campaignStart);
|
||||
farFuture.setFullYear(farFuture.getFullYear() + 420); // 420 years in the future
|
||||
endDate = farFuture;
|
||||
}
|
||||
|
||||
const c = {
|
||||
...campaign,
|
||||
isSelfManaged: !campaign.sendStartAt || !campaign.sendEndAt,
|
||||
start: campaignStart,
|
||||
end: endDate,
|
||||
color: getCampaignColor(campaign)
|
||||
};
|
||||
return c;
|
||||
});
|
||||
|
||||
function getCampaignColor(campaign) {
|
||||
if (campaign.anonymizedAt || campaign.closedAt) {
|
||||
return COLORS.COMPLETED;
|
||||
}
|
||||
if (!campaign.sendStartAt) {
|
||||
return COLORS.SELF_MANAGED;
|
||||
}
|
||||
if (campaign.sendStartAt && campaign.sendStartAt > new Date().toISOString()) {
|
||||
return COLORS.SCHEDULED;
|
||||
}
|
||||
return COLORS.ACTIVE;
|
||||
}
|
||||
|
||||
function truncateText(text, maxLength) {
|
||||
if (!text) return '';
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 2) + '...';
|
||||
}
|
||||
|
||||
function formatDateString(date) {
|
||||
return format(date, 'MMMM dd, yyyy');
|
||||
}
|
||||
|
||||
function calculateCampaignLayers(campaigns, calendarStart, calendarEnd) {
|
||||
const dayMap = new Map();
|
||||
|
||||
// Generate days
|
||||
let currentDay = new Date(calendarStart);
|
||||
while (currentDay < calendarEnd) {
|
||||
dayMap.set(currentDay.getTime(), []);
|
||||
currentDay = addDays(currentDay, 1);
|
||||
}
|
||||
|
||||
// Filter campaigns based on active filters
|
||||
const filteredCampaigns = campaigns.filter((campaign) => {
|
||||
if (campaign.anonymizedAt || campaign.closedAt) {
|
||||
return activeFilters.COMPLETED;
|
||||
}
|
||||
if (!campaign.sendStartAt) {
|
||||
return activeFilters.SELF_MANAGED;
|
||||
}
|
||||
if (campaign.sendStartAt && campaign.sendStartAt > new Date().toISOString()) {
|
||||
return activeFilters.SCHEDULED;
|
||||
}
|
||||
return activeFilters.ACTIVE;
|
||||
});
|
||||
|
||||
// Map filtered campaigns to days
|
||||
filteredCampaigns.forEach((campaign) => {
|
||||
const campaignStart = startOfDay(campaign.start);
|
||||
const campaignEnd = startOfDay(campaign.end);
|
||||
|
||||
let day = new Date(Math.max(campaignStart.getTime(), calendarStart.getTime()));
|
||||
const endDay = new Date(Math.min(addDays(campaignEnd, 1).getTime(), calendarEnd.getTime()));
|
||||
|
||||
while (day < endDay) {
|
||||
const dayTime = day.getTime();
|
||||
if (dayMap.has(dayTime)) {
|
||||
dayMap.get(dayTime).push(campaign);
|
||||
}
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort campaigns for each day
|
||||
const visibleDayMap = new Map();
|
||||
|
||||
dayMap.forEach((dayCampaigns, dayTime) => {
|
||||
const sortedCampaigns = sortCampaignsByPriority(dayCampaigns, new Date(Number(dayTime)));
|
||||
|
||||
visibleDayMap.set(dayTime, {
|
||||
campaigns: sortedCampaigns, // All campaigns are now visible
|
||||
total: sortedCampaigns.length
|
||||
});
|
||||
});
|
||||
|
||||
return visibleDayMap;
|
||||
}
|
||||
|
||||
async function generateCalendarData() {
|
||||
isGeneratingCalendar = true;
|
||||
|
||||
updateDateRange();
|
||||
|
||||
const dayMap = calculateCampaignLayers(calendarCampaigns, start, end);
|
||||
|
||||
// Create weeks array
|
||||
weeks = [];
|
||||
let week = [];
|
||||
let currentDate = new Date(start);
|
||||
|
||||
while (currentDate <= end) {
|
||||
// Get campaign data for this day
|
||||
const dayTime = currentDate.getTime();
|
||||
const dayData = dayMap.get(dayTime) || { campaigns: [], total: 0 };
|
||||
|
||||
// Sort campaigns
|
||||
const sortedCampaigns = sortByName(dayData.campaigns);
|
||||
|
||||
// Create day object
|
||||
const day = {
|
||||
date: new Date(currentDate),
|
||||
isToday: isSameDay(currentDate, new Date()),
|
||||
isCurrentMonth: isSameMonth(currentDate, currentMonth),
|
||||
campaigns: sortedCampaigns, // All campaigns are visible now
|
||||
totalCampaigns: dayData.total
|
||||
};
|
||||
|
||||
week.push(day);
|
||||
|
||||
// Start a new week if we've filled one
|
||||
if (week.length === 7) {
|
||||
weeks.push(week);
|
||||
week = [];
|
||||
}
|
||||
|
||||
// Move to next day
|
||||
currentDate = addDays(currentDate, 1);
|
||||
}
|
||||
|
||||
isGeneratingCalendar = false;
|
||||
}
|
||||
|
||||
async function previousMonth() {
|
||||
isLoadingNewMonth = true;
|
||||
currentMonth = subMonths(currentMonth, 1);
|
||||
updateDateRange();
|
||||
await onChangeDate();
|
||||
isLoadingNewMonth = false;
|
||||
await generateCalendarData();
|
||||
}
|
||||
|
||||
async function nextMonth() {
|
||||
isLoadingNewMonth = true;
|
||||
currentMonth = addMonths(currentMonth, 1);
|
||||
updateDateRange();
|
||||
await onChangeDate();
|
||||
isLoadingNewMonth = false;
|
||||
await generateCalendarData();
|
||||
}
|
||||
|
||||
async function toggleFilter(key) {
|
||||
activeFilters[key] = !activeFilters[key];
|
||||
await generateCalendarData();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await generateCalendarData();
|
||||
isInitialized = true;
|
||||
});
|
||||
|
||||
$: if (calendarCampaigns) {
|
||||
generateCalendarData();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-white rounded-lg shadow-sm p-4 border border-gray-200">
|
||||
<div class="space-y-4 max-w-5xl mx-auto min-h-[600px]">
|
||||
<!-- Navigation Controls -->
|
||||
<div class="flex justify-center items-center">
|
||||
<button
|
||||
class="p-2 rounded hover:bg-gray-100 mx-4"
|
||||
on:click={previousMonth}
|
||||
disabled={isLoadingNewMonth}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 class="text-lg font-semibold">
|
||||
{format(currentMonth, 'MMMM yyyy')}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
class="p-2 rounded hover:bg-gray-100 mx-4"
|
||||
on:click={nextMonth}
|
||||
disabled={isLoadingNewMonth}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex justify-center flex-wrap gap-4 text-sm">
|
||||
{#each [{ key: 'SCHEDULED', color: COLORS.SCHEDULED, label: 'Scheduled' }, { key: 'ACTIVE', color: COLORS.ACTIVE, label: 'Active' }, { key: 'COMPLETED', color: COLORS.COMPLETED, label: 'Completed' }, { key: 'SELF_MANAGED', color: COLORS.SELF_MANAGED, label: 'Self-managed' }] as item}
|
||||
<button
|
||||
class="flex items-center cursor-pointer select-none"
|
||||
on:click={() => toggleFilter(item.key)}
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 rounded mr-2 transition-opacity duration-200"
|
||||
style="background-color: {item.color}; opacity: {activeFilters[item.key] ? '1' : '0.3'}"
|
||||
></div>
|
||||
<span
|
||||
class="transition-opacity duration-200"
|
||||
style="opacity: {activeFilters[item.key] ? '1' : '0.5'}">{item.label}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div class="calendar-container min-h-[480px]">
|
||||
<!-- Day headers -->
|
||||
<div class="grid grid-cols-7 text-center mb-1">
|
||||
{#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day}
|
||||
<div class="text-sm font-medium text-gray-600">{day}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
{#if !isInitialized || isGeneratingCalendar || isLoadingNewMonth}
|
||||
<div class="min-h-[450px] flex items-center justify-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span class="text-gray-600">Loading calendar...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-rows-{weeks.length} gap-1 min-h-[450px]">
|
||||
{#each weeks as week, weekIndex}
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
{#each week as day}
|
||||
<div
|
||||
class="calendar-day relative border rounded-md overflow-hidden {scrollBarClassesHorizontal} {day.isToday
|
||||
? 'border-gray-700 bg-gray-50'
|
||||
: 'border-gray-200'}
|
||||
{day.isCurrentMonth ? 'bg-white' : 'bg-gray-50'}"
|
||||
>
|
||||
<!-- Date number -->
|
||||
<div
|
||||
class="text-sm p-1 {day.isToday
|
||||
? 'font-bold text-gray-800'
|
||||
: day.isCurrentMonth
|
||||
? 'text-gray-600'
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</div>
|
||||
<!-- Campaigns list -->
|
||||
<div
|
||||
class="campaign-container {scrollBarClassesVertical} {scrollBarClassesHorizontal}"
|
||||
>
|
||||
{#each day.campaigns as campaign}
|
||||
<a
|
||||
href={`/campaign/${campaign.id}`}
|
||||
class="block mb-1 rounded-sm text-white text-xs p-1 overflow-hidden hover:opacity-90 transition-opacity"
|
||||
style="background-color: {campaign.color};"
|
||||
title="{campaign.name} - {campaign.isSelfManaged
|
||||
? 'Self-managed'
|
||||
: campaign.end < new Date()
|
||||
? 'Completed'
|
||||
: campaign.start <= new Date()
|
||||
? 'Active'
|
||||
: 'Scheduled'}, {campaign.isSelfManaged
|
||||
? `${formatDateString(new Date(campaign.createdAt))} - ?`
|
||||
: `${formatDateString(campaign.start)} - ${formatDateString(campaign.end)}`}"
|
||||
>
|
||||
<div class="campaign-name truncate text-white">
|
||||
{truncateText(campaign.name, 18)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.calendar-container {
|
||||
--cell-aspect-ratio: 0.75; /* Height to width ratio */
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
height: 0;
|
||||
padding-bottom: calc(100% / var(--cell-aspect-ratio));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.campaign-container {
|
||||
position: absolute;
|
||||
top: 25px; /* Space for the date number */
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for campaign container
|
||||
.campaign-container::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.campaign-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.campaign-container::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 255, 255, 0.2);
|
||||
border-radius: 24px;
|
||||
}
|
||||
*/
|
||||
|
||||
/* For Firefox */
|
||||
.campaign-container {
|
||||
scrollbar-width: thin;
|
||||
/*
|
||||
scrollbar-color: rgba(0, 255, 255, 0.2) transparent;
|
||||
*/
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.calendar-container {
|
||||
--cell-aspect-ratio: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.calendar-container {
|
||||
--cell-aspect-ratio: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.calendar-container {
|
||||
--cell-aspect-ratio: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.calendar-container {
|
||||
--cell-aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
|
||||
export let bindTo = null;
|
||||
export let defaultValue = false;
|
||||
export let value = defaultValue;
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
export let id = null;
|
||||
|
||||
let parentForm = null;
|
||||
let parentFormResetListener = null;
|
||||
|
||||
onMount(() => {
|
||||
if (value === null) {
|
||||
value = defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (!parentForm) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<label class="flex flex-col py-2 w-60">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<label class="relative flex items-center cursor-pointer">
|
||||
<input
|
||||
{id}
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
bind:this={bindTo}
|
||||
bind:checked={value}
|
||||
on:change
|
||||
/>
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-slate-300 rounded
|
||||
peer-checked:border-cta-blue peer-checked:bg-cta-blue
|
||||
transition-all duration-200 ease-in-out
|
||||
flex items-center justify-center
|
||||
bg-slate-50
|
||||
focus-within:ring-2 focus-within:ring-cta-blue focus-within:ring-offset-2"
|
||||
>
|
||||
{#if value}
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script>
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
|
||||
export let confirm_text = '';
|
||||
export let visible = true;
|
||||
export let onConfirm = () => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
let confirmElement;
|
||||
let previousActiveElement;
|
||||
let focusableElements = [];
|
||||
let firstFocusableElement;
|
||||
let lastFocusableElement;
|
||||
let confirmInitialized = false;
|
||||
|
||||
$: {
|
||||
if (visible && !confirmInitialized) {
|
||||
window.addEventListener('keydown', keyHandler);
|
||||
// Prevent body scrolling when confirm prompt is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
handleConfirmOpen();
|
||||
confirmInitialized = true;
|
||||
} else if (!visible && confirmInitialized) {
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Restore body scrolling when confirm prompt is closed
|
||||
document.body.style.overflow = 'auto';
|
||||
handleConfirmClose();
|
||||
confirmInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
} else if (e.key === 'Tab') {
|
||||
handleTabKey(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabKey = (e) => {
|
||||
const currentlyFocused = document.activeElement;
|
||||
updateFocusableElements();
|
||||
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let currentIndex = focusableElements.indexOf(currentlyFocused);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// If we can't find the element, handle it gracefully
|
||||
if (e.shiftKey) {
|
||||
lastFocusableElement?.focus();
|
||||
} else {
|
||||
firstFocusableElement?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab - go to previous element
|
||||
if (currentIndex <= 0) {
|
||||
lastFocusableElement?.focus();
|
||||
} else {
|
||||
focusableElements[currentIndex - 1]?.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab - go to next element
|
||||
if (currentIndex >= focusableElements.length - 1) {
|
||||
firstFocusableElement?.focus();
|
||||
} else {
|
||||
focusableElements[currentIndex + 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFocusableElements = () => {
|
||||
if (!confirmElement) return [];
|
||||
|
||||
const focusableSelectors = [
|
||||
'button:not([disabled])',
|
||||
'[href]',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"]):not([disabled])',
|
||||
'details',
|
||||
'summary'
|
||||
];
|
||||
|
||||
const elements = confirmElement.querySelectorAll(focusableSelectors.join(', '));
|
||||
return Array.from(elements).filter((el) => {
|
||||
return el.offsetWidth > 0 && el.offsetHeight > 0 && !el.hasAttribute('hidden');
|
||||
});
|
||||
};
|
||||
|
||||
const updateFocusableElements = () => {
|
||||
focusableElements = getFocusableElements();
|
||||
firstFocusableElement = focusableElements[0] || null;
|
||||
lastFocusableElement = focusableElements[focusableElements.length - 1] || null;
|
||||
};
|
||||
|
||||
const handleConfirmOpen = async () => {
|
||||
// Store the currently focused element
|
||||
previousActiveElement = document.activeElement;
|
||||
|
||||
// Wait for the DOM to update
|
||||
await tick();
|
||||
|
||||
updateFocusableElements();
|
||||
|
||||
// Focus the first focusable element
|
||||
if (firstFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmClose = () => {
|
||||
// Restore focus to the previously focused element
|
||||
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Ensure body scrolling is restored when component is destroyed
|
||||
document.body.style.overflow = 'auto';
|
||||
// Restore focus if confirm was open when component was destroyed
|
||||
if (visible && previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
beforeNavigate((opts) => {
|
||||
if (!opts.from || !opts.to || !visible) {
|
||||
return;
|
||||
}
|
||||
const navigationIsNotOnSamePage = opts.from.url.pathname === opts.to.url.pathname;
|
||||
if (opts.type === 'popstate' && !navigationIsNotOnSamePage) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
visible = false;
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Restore body scrolling when confirm prompt is closed
|
||||
document.body.style.overflow = 'auto';
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
onConfirm();
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed top-0 left-0 w-full h-full bg-cta-blue opacity-20 blur-xl" />
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full flex justify-center items-center backdrop-blur-sm z-20"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-description"
|
||||
>
|
||||
<section
|
||||
bind:this={confirmElement}
|
||||
class="flex flex-col items-center w-1/3 bg-slate-100 shadow-xl rounded-md"
|
||||
>
|
||||
<div class="bg-cta-orange2 text-white rounded-tl-md rounded-tr-md w-full">
|
||||
<p id="confirm-title" class="uppercase font-bold text-center py">Confirm action</p>
|
||||
</div>
|
||||
<h1 class="uppercase font-bold text-center text-gray-500 text-4xl pt-10">Are you sure?</h1>
|
||||
<p id="confirm-description" class="text-center">{confirm_text}</p>
|
||||
<div class="flex pt-4 pb-8">
|
||||
<button
|
||||
class="mt-6 bg-grayblue-dark w-40 py-2 mr-2 hover:bg-slate-300 text-white font-bold uppercase rounded-md"
|
||||
on:click={close}
|
||||
aria-label="Cancel action"
|
||||
>
|
||||
no
|
||||
</button>
|
||||
<button
|
||||
class="mt-6 bg-cta-blue w-56 py-2 hover:bg-blue-500 text-white font-bold uppercase rounded-md"
|
||||
on:click={confirm}
|
||||
aria-label="Confirm action"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,97 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { utc_yyyy_mm_dd } from '$lib/utils/api-utils';
|
||||
|
||||
// bind a element to this component input field
|
||||
// use like <DateField bind:bindToDate={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindTo = null;
|
||||
export let defaultValue = null; // default checkbox value
|
||||
export let value = defaultValue; // for binding value - this will contain the ISO string
|
||||
export let resets = true; // reset value on parent form reset
|
||||
export let onChange = (value) => {};
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
export let disabled = false;
|
||||
export let required = false;
|
||||
export let min = '';
|
||||
export let noLabel = false;
|
||||
export let labelWidth = 'large';
|
||||
export let inputWidth = 'medium';
|
||||
export let textAlign = 'left';
|
||||
|
||||
// bind to parent form element, if there is one
|
||||
let parentForm = null;
|
||||
// listen to parent form reset event, if one exists
|
||||
let parentFormResetListener = null;
|
||||
|
||||
if (value instanceof Date) {
|
||||
value = utc_yyyy_mm_dd(value);
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
// handle reset
|
||||
if (!parentForm && resets) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener && resets) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !noLabel}
|
||||
<label
|
||||
class="flex flex-col py-2"
|
||||
class:w-20={labelWidth === 'small'}
|
||||
class:w-32={labelWidth === 'medium'}
|
||||
class:w-60={labelWidth === 'large'}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="flex flex-row">
|
||||
<input
|
||||
type="date"
|
||||
{min}
|
||||
bind:this={bindTo}
|
||||
bind:value
|
||||
on:change={onChange}
|
||||
on:select={onChange}
|
||||
{required}
|
||||
{disabled}
|
||||
autocomplete="off"
|
||||
class="rounded-md text-center py-2 pl-2 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class:text-left={textAlign == 'left'}
|
||||
class:text-center={textAlign == 'center'}
|
||||
class:text-right={textAlign == 'right'}
|
||||
class:w-30={inputWidth === 'small'}
|
||||
class:w-44={inputWidth === 'medium'}
|
||||
class:w-60={inputWidth === 'large'}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
|
||||
// bind a element to this component input field
|
||||
// use like <DateTimeField bind:bindToDate={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindToDate = null;
|
||||
// use like <DateTimeField bind:bindToTime={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindToTime = null;
|
||||
export let defaultValue = ''; // default checkbox value
|
||||
// for binding value, however this will contain the local value
|
||||
// for the intial input, the value is expected in a UTC string format, so is converted to
|
||||
// a locale string for the input field. The consumer must convert the from local to UTC or etc
|
||||
// when using the value, but should not mutate this value.
|
||||
export let value = defaultValue;
|
||||
export let resets = true; // reset value on parent form reset
|
||||
export let onChange = (value) => {};
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
export let disabled = false;
|
||||
export let readonly = false;
|
||||
export let required = false;
|
||||
export let min = new Date();
|
||||
/** @type {'small'|'medium'|'large'} */
|
||||
export let labelWidth = 'large';
|
||||
let minDate = '';
|
||||
let minTime = '';
|
||||
|
||||
// bind to parent form element, if there is one
|
||||
let parentForm = null;
|
||||
// listen to parent form reset event, if one exists
|
||||
let parentFormResetListener = null;
|
||||
let dateValue = '';
|
||||
let timeValue = '';
|
||||
|
||||
$: {
|
||||
if (!!value) {
|
||||
let x = new Date(value);
|
||||
const mm = (x.getMonth() + 1).toString().padStart(2, '0');
|
||||
const dd = x.getDate().toString().padStart(2, '0');
|
||||
const yyyy = x.getFullYear();
|
||||
const hours = x.getHours().toString().padStart(2, '0');
|
||||
const minutes = x.getMinutes().toString().padStart(2, '0');
|
||||
const timeString = (dateValue = `${yyyy}-${mm}-${dd}`);
|
||||
timeValue = `${hours}:${minutes}`;
|
||||
value = x.toString();
|
||||
} else {
|
||||
value = null;
|
||||
dateValue = '';
|
||||
timeValue = '';
|
||||
}
|
||||
}
|
||||
$: {
|
||||
if (!!min) {
|
||||
minDate = `${min.getFullYear()}-${(min.getMonth() + 1).toString().padStart(2, '0')}-${min
|
||||
.getDate()
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
const hours = min.getHours().toString().padStart(2, '0');
|
||||
const minutes = min.getMinutes().toString().padStart(2, '0');
|
||||
minTime = `${hours}:${minutes}`;
|
||||
// if there selected value is a different date then remove the min time
|
||||
if (dateValue && new Date(dateValue).getDate() !== min.getDate()) {
|
||||
minTime = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
// handle reset
|
||||
if (!parentForm && resets) {
|
||||
parentForm = bindToDate.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
dateValue = '';
|
||||
timeValue = '';
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener && resets) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
|
||||
const onChangeDate = () => {
|
||||
setValue();
|
||||
onChange(value);
|
||||
};
|
||||
const onChangeTime = () => {
|
||||
setValue();
|
||||
onChange(value);
|
||||
};
|
||||
const setValue = () => {
|
||||
if (!dateValue || !timeValue) {
|
||||
return;
|
||||
}
|
||||
value = `${dateValue}T${timeValue}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="flex flex-col py-2"
|
||||
class:w-20={labelWidth === 'small'}
|
||||
class:w-32={labelWidth === 'medium'}
|
||||
class:w-60={labelWidth === 'large'}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<div class="flex flex-row">
|
||||
<input
|
||||
type="date"
|
||||
min={minDate}
|
||||
bind:this={bindToDate}
|
||||
bind:value={dateValue}
|
||||
on:change={onChangeDate}
|
||||
on:select={onChangeDate}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
autocomplete="off"
|
||||
class="w-44 rounded-md py-2 pl-2 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class:opacity-90={readonly}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
min={minTime}
|
||||
bind:this={bindToTime}
|
||||
bind:value={timeValue}
|
||||
on:select={onChangeTime}
|
||||
on:change={onChangeTime}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
autocomplete="off"
|
||||
class="ml-2 rounded-md py-2 pl-2 text-gray-600 text-center border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class:bg-yellow-200={readonly}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script>
|
||||
/** @type {String | Date} */
|
||||
export let value;
|
||||
export let hideHours = false;
|
||||
let date = null;
|
||||
$: {
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > 0) {
|
||||
date = new Date(value);
|
||||
}
|
||||
} else {
|
||||
date = value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if date}
|
||||
{#if !hideHours}
|
||||
{date.toLocaleString()}
|
||||
{:else}
|
||||
{date.toLocaleDateString()}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
|
||||
// servies
|
||||
const appState = AppStateService.instance;
|
||||
|
||||
// local state
|
||||
let state = {};
|
||||
let visible = false;
|
||||
|
||||
onMount(() => {
|
||||
appState.subscribe((s) => {
|
||||
state = s;
|
||||
});
|
||||
// on unmount
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const triggerToast = (type) => {
|
||||
addToast('Test toast with message.', type);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if import.meta.env.DEV}
|
||||
<div>
|
||||
<div>
|
||||
<button
|
||||
class="fixed border-2 border-slate-500 bg-white w-8 h-8 left-2 pb-1 bottom-4 text-white rounded-full hover:right-3 hover:bottom-3 hover:w-10 hover:h-10 transition-all"
|
||||
on:click={() => (visible = !visible)}
|
||||
>
|
||||
{#if visible}
|
||||
🎣
|
||||
{:else}
|
||||
🎣
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if visible}
|
||||
<div
|
||||
transition:fade={{ duration: 100 }}
|
||||
class="absolute right-0 h-auto bg-black text-white p-4 z-40"
|
||||
>
|
||||
<h1 class="text-xl m-4">Developer Panel</h1>
|
||||
<h2 class="text-lg font-bold m-4">Links</h2>
|
||||
<ul class="m-4">
|
||||
<li>
|
||||
<a href="https://localhost:8009" target="_blank">CRM / Update server</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:8101" target="_blank">Database</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:8102" target="_blank">Mailbox</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:8103" target="_blank">Container logs</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:8104" target="_blank">Container stats</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 class="text-lg font-bold m-4">Toast</h2>
|
||||
<ul class="m-4">
|
||||
<li>
|
||||
<button on:click={() => triggerToast('Success')}>Trigger toast - Success</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => triggerToast('Info')}>Trigger toast - Info</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => triggerToast('Warning')}>Trigger toast - Warning</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => triggerToast('Error')}>Trigger toast - Error</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="pt-4">
|
||||
<h2 class="text-lg font-bold">Global State</h2>
|
||||
<table class="border-2">
|
||||
{#each Object.entries(state) as [key, value]}
|
||||
<tr class="flex flex-col border-2 border-white">
|
||||
<td class="p-4 font-bold border-1 border-white">{key}</td>
|
||||
<td class="p-4 border-1 border-white w-full">
|
||||
{#if typeof value === 'object'}
|
||||
<pre class="whitespace-pre-wrap">{JSON.stringify(value, null, 2)}</pre>
|
||||
{:else}
|
||||
<p>
|
||||
{value}
|
||||
</p>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,529 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import * as d3 from 'd3';
|
||||
import { toEvent } from '$lib/utils/events';
|
||||
|
||||
export let events = [];
|
||||
export let isGhost = false;
|
||||
|
||||
let svg;
|
||||
let tooltip;
|
||||
let container;
|
||||
let timelineGroup;
|
||||
let width;
|
||||
let height = 200;
|
||||
let margin = { top: 20, right: 40, bottom: 50, left: 40 };
|
||||
let currentCenterDate = '';
|
||||
let zoom;
|
||||
let currentTransform = null;
|
||||
let xScale;
|
||||
let use24Hour = true;
|
||||
let initialized = false;
|
||||
|
||||
// Constants for zoom limits
|
||||
const MAX_ZOOM_IN = 1000 * 10; // Milliseconds minimum visible on screen
|
||||
const MIN_ZOOM_OUT_PADDING = 0.1; // 10% padding on each side when fully zoomed out
|
||||
|
||||
$: if (container) {
|
||||
width = container.offsetWidth - margin.left - margin.right;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up D3 event listeners
|
||||
if (svg) {
|
||||
d3.select(svg).on('.zoom', null);
|
||||
}
|
||||
|
||||
// Clean up tooltip event listeners
|
||||
if (timelineGroup) {
|
||||
timelineGroup.selectAll('circle').on('mouseover', null).on('mouseout', null);
|
||||
}
|
||||
});
|
||||
|
||||
function initializeTimeline() {
|
||||
if (isGhost) {
|
||||
handleGhost();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!svg) return;
|
||||
|
||||
const g = d3
|
||||
.select(svg)
|
||||
.append('g')
|
||||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
||||
|
||||
g.append('defs')
|
||||
.append('clipPath')
|
||||
.attr('id', 'clip')
|
||||
.append('rect')
|
||||
.attr('width', width)
|
||||
.attr('height', height - margin.top - margin.bottom);
|
||||
|
||||
timelineGroup = g.append('g').attr('clip-path', 'url(#clip)');
|
||||
|
||||
// Base timeline
|
||||
timelineGroup
|
||||
.append('line')
|
||||
.attr('x1', 0)
|
||||
.attr('x2', width)
|
||||
.attr('y1', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('y2', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('stroke', '#E2E8F0')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// X Axis
|
||||
g.append('g')
|
||||
.attr('class', 'x-axis')
|
||||
.attr('transform', `translate(0,${height - margin.bottom})`);
|
||||
|
||||
// Setup default time domain based on events
|
||||
if (events.length > 0) {
|
||||
const timestamps = events.map((d) => new Date(d.createdAt).getTime());
|
||||
const timeRange = d3.extent(timestamps);
|
||||
const timeSpan = timeRange[1] - timeRange[0];
|
||||
const padding = Math.max(timeSpan * MIN_ZOOM_OUT_PADDING, 60000);
|
||||
|
||||
const domainStart = new Date(timeRange[0] - padding);
|
||||
const domainEnd = new Date(timeRange[1] + padding);
|
||||
|
||||
xScale = d3.scaleTime().domain([domainStart, domainEnd]).range([0, width]);
|
||||
} else {
|
||||
const now = new Date();
|
||||
const defaultStart = new Date(now);
|
||||
defaultStart.setHours(now.getHours() - 24);
|
||||
xScale = d3.scaleTime().domain([defaultStart, now]).range([0, width]);
|
||||
}
|
||||
|
||||
// Initialize the zoom behavior with calculated scale limits
|
||||
setupZoom();
|
||||
|
||||
updateAxes(xScale);
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function setupZoom() {
|
||||
// Create a custom zoom behavior that handles the limits smoothly
|
||||
zoom = d3
|
||||
.zoom()
|
||||
.scaleExtent([0.1, 1000000]) // Use wide bounds
|
||||
.extent([
|
||||
[0, 0],
|
||||
[width, height]
|
||||
])
|
||||
.filter((event) => {
|
||||
// Check if this zoom operation would exceed our limit
|
||||
if (event.type === 'wheel' || event.type === 'touchmove' || event.type === 'mousemove') {
|
||||
// Get current visible timespan
|
||||
if (!currentTransform || !xScale) return true;
|
||||
|
||||
const newX = currentTransform.rescaleX(xScale);
|
||||
const visibleDomain = newX.domain();
|
||||
const visibleTimeSpan = visibleDomain[1] - visibleDomain[0];
|
||||
|
||||
// If we're already at 1 second and trying to zoom in further, block it
|
||||
if (
|
||||
visibleTimeSpan <= MAX_ZOOM_IN &&
|
||||
((event.type === 'wheel' && event.deltaY < 0) ||
|
||||
(event.type !== 'wheel' &&
|
||||
event.sourceEvent?.type === 'wheel' &&
|
||||
event.sourceEvent?.deltaY < 0))
|
||||
) {
|
||||
return false; // Block zoom in when already at max zoom
|
||||
}
|
||||
}
|
||||
return true; // Allow all other zoom operations
|
||||
})
|
||||
.on('zoom', handleZoom);
|
||||
|
||||
d3.select(svg).call(zoom);
|
||||
}
|
||||
|
||||
function updateTimeline() {
|
||||
if (!initialized) {
|
||||
initializeTimeline();
|
||||
}
|
||||
|
||||
if (!svg || !timelineGroup) return;
|
||||
|
||||
// Update scales based on events
|
||||
const timestamps = events.map((d) => new Date(d.createdAt));
|
||||
if (timestamps.length === 0) {
|
||||
const now = new Date();
|
||||
// Create a default domain of last 24 hours to now
|
||||
const defaultStart = new Date(now);
|
||||
defaultStart.setHours(now.getHours() - 24);
|
||||
xScale = d3.scaleTime().domain([defaultStart, now]).range([0, width]);
|
||||
updateAxes(xScale);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeRange = d3.extent(timestamps);
|
||||
const timeSpan = timeRange[1] - timeRange[0];
|
||||
// Smaller minimum padding for shorter time spans
|
||||
const padding = Math.max(timeSpan * MIN_ZOOM_OUT_PADDING, 60000); // minimum 1 minute padding
|
||||
|
||||
const domainStart = new Date(timeRange[0].getTime() - padding);
|
||||
const domainEnd = new Date(timeRange[1].getTime() + padding);
|
||||
|
||||
xScale = d3.scaleTime().domain([domainStart, domainEnd]).range([0, width]);
|
||||
|
||||
// Update zoom constraints based on the new data
|
||||
setupZoom();
|
||||
|
||||
// Update dots
|
||||
const dots = timelineGroup.selectAll('circle').data(events, (d) => d.id);
|
||||
|
||||
dots.exit().remove();
|
||||
|
||||
const dotsEnter = dots
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('r', 6)
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.on('mouseover', showTooltip)
|
||||
.on('mouseout', hideTooltip);
|
||||
|
||||
dots
|
||||
.merge(dotsEnter)
|
||||
.attr('cx', (d) => xScale(new Date(d.createdAt)))
|
||||
.attr('cy', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('fill', (d) => getEventColor(d.eventName));
|
||||
|
||||
if (currentTransform) {
|
||||
const newX = currentTransform.rescaleX(xScale);
|
||||
updateAxes(newX);
|
||||
timelineGroup.selectAll('circle').attr('cx', (d) => newX(new Date(d.createdAt)));
|
||||
} else {
|
||||
updateAxes(xScale);
|
||||
// Set initial zoom to fit all events nicely
|
||||
const optimalScale = calculateOptimalScale(domainStart, domainEnd);
|
||||
const initialTranslate = (width - width * optimalScale) / 2;
|
||||
currentTransform = d3.zoomIdentity.translate(initialTranslate, 0).scale(optimalScale);
|
||||
d3.select(svg).call(zoom.transform, currentTransform);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateOptimalScale(start, end) {
|
||||
if (!start || !end) return 0.9; // Default fallback
|
||||
|
||||
const timeSpan = end - start;
|
||||
// Target visible timespan should be the actual timespan plus some padding
|
||||
const targetTimeSpan = timeSpan * (1 + MIN_ZOOM_OUT_PADDING * 2);
|
||||
const currentScale = width / targetTimeSpan;
|
||||
|
||||
// Constrain to a reasonable range for initial view
|
||||
return Math.min(Math.max(currentScale, 0.7), 0.95);
|
||||
}
|
||||
|
||||
function handleZoom(event) {
|
||||
if (!xScale) return;
|
||||
|
||||
// Store the current transform
|
||||
currentTransform = event.transform;
|
||||
|
||||
// Apply the transform to the x scale
|
||||
const newX = event.transform.rescaleX(xScale);
|
||||
|
||||
// Update the visualization with the new scale
|
||||
updateAxes(newX);
|
||||
timelineGroup.selectAll('circle').attr('cx', (d) => newX(new Date(d.createdAt)));
|
||||
timelineGroup.select('line').attr('x1', 0).attr('x2', width);
|
||||
}
|
||||
|
||||
function updateAxes(scale) {
|
||||
if (!scale || !scale.domain || typeof scale.domain !== 'function') {
|
||||
console.error('Invalid scale provided to updateAxes');
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = scale.domain();
|
||||
const timeSpan = domain[1] - domain[0];
|
||||
|
||||
// Calculate available width per tick to avoid overlap
|
||||
const minTickSpacing = 80; // Minimum pixels between ticks
|
||||
const maxTicks = Math.floor(width / minTickSpacing);
|
||||
|
||||
const xAxis = d3.select(svg).select('.x-axis');
|
||||
if (!xAxis.empty()) {
|
||||
let tickFunction;
|
||||
let tickFormat;
|
||||
|
||||
if (timeSpan < 1000) {
|
||||
// Less than 1 second
|
||||
// For very zoomed in views, use fewer time ticks with millisecond precision
|
||||
tickFunction = d3.timeMillisecond.every(Math.ceil(timeSpan / maxTicks / 10) * 10);
|
||||
tickFormat = d3.timeFormat(use24Hour ? '%H:%M:%S.%L' : '%I:%M:%S.%L %p');
|
||||
} else if (timeSpan < 60000) {
|
||||
// Less than 1 minute
|
||||
tickFunction = d3.timeSecond.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 1000)));
|
||||
tickFormat = d3.timeFormat(use24Hour ? '%H:%M:%S' : '%I:%M:%S %p');
|
||||
} else if (timeSpan < 3600000) {
|
||||
// Less than 1 hour
|
||||
tickFunction = d3.timeMinute.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 60000)));
|
||||
tickFormat = d3.timeFormat(use24Hour ? '%H:%M' : '%I:%M %p');
|
||||
} else if (timeSpan < 86400000) {
|
||||
// Less than 1 day
|
||||
tickFunction = d3.timeHour.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 3600000)));
|
||||
tickFormat = d3.timeFormat(use24Hour ? '%H:%M' : '%I:%M %p');
|
||||
} else if (timeSpan < 2592000000) {
|
||||
// Less than 30 days
|
||||
tickFunction = d3.timeDay.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 86400000)));
|
||||
tickFormat = d3.timeFormat('%d %b');
|
||||
} else if (timeSpan < 31536000000) {
|
||||
// Less than 1 year
|
||||
tickFunction = d3.timeMonth.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 2592000000)));
|
||||
tickFormat = d3.timeFormat('%b %Y');
|
||||
} else {
|
||||
// More than 1 year
|
||||
tickFunction = d3.timeYear.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 31536000000)));
|
||||
tickFormat = d3.timeFormat('%Y');
|
||||
}
|
||||
|
||||
// Apply the ticks with calculated frequency
|
||||
xAxis.call(d3.axisBottom(scale).ticks(tickFunction).tickFormat(tickFormat));
|
||||
|
||||
xAxis
|
||||
.selectAll('text')
|
||||
.style('text-anchor', 'middle')
|
||||
.attr('dy', '1em')
|
||||
.attr('dx', '0')
|
||||
.attr('transform', 'rotate(0)');
|
||||
}
|
||||
|
||||
updateCenterDate(scale);
|
||||
}
|
||||
|
||||
function updateCenterDate(scale) {
|
||||
if (!scale || !scale.invert) return;
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerDate = scale.invert(centerX);
|
||||
const timeSpan = scale.domain()[1] - scale.domain()[0];
|
||||
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: !use24Hour
|
||||
};
|
||||
|
||||
if (timeSpan < 60000) {
|
||||
options.fractionalSecondDigits = 3;
|
||||
}
|
||||
|
||||
try {
|
||||
currentCenterDate = centerDate.toLocaleDateString('en-US', options);
|
||||
} catch (e) {
|
||||
console.error('Error formatting center date:', e);
|
||||
currentCenterDate = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleGhost() {
|
||||
if (!svg) return;
|
||||
|
||||
d3.select(svg).selectAll('*').remove();
|
||||
|
||||
const g = d3
|
||||
.select(svg)
|
||||
.append('g')
|
||||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
||||
|
||||
g.append('line')
|
||||
.attr('x1', 0)
|
||||
.attr('x2', width)
|
||||
.attr('y1', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('y2', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('stroke', '#E2E8F0')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
const ghostDots = 8;
|
||||
const ghostDotsData = Array(ghostDots).fill(null);
|
||||
|
||||
g.selectAll('.ghost-dot')
|
||||
.data(ghostDotsData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('class', 'ghost-dot')
|
||||
.attr('cx', (_, i) => (width / (ghostDots - 1)) * i)
|
||||
.attr('cy', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#E2E8F0')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2);
|
||||
}
|
||||
|
||||
function showTooltip(event, d) {
|
||||
if (!tooltip || !d || !d.eventName) return;
|
||||
|
||||
const [x, y] = d3.pointer(event);
|
||||
tooltip.style.display = 'block';
|
||||
|
||||
// Ensure tooltip stays within container bounds
|
||||
const tooltipWidth = 250; // Approximate width
|
||||
const leftPosition = x + margin.left + 10;
|
||||
const rightEdge = leftPosition + tooltipWidth;
|
||||
// If tooltip would go off the right side, position it to the left of the point
|
||||
const adjustedLeft =
|
||||
rightEdge > container.offsetWidth ? x + margin.left - tooltipWidth - 10 : leftPosition;
|
||||
|
||||
tooltip.style.left = `${adjustedLeft}px`;
|
||||
tooltip.style.top = `${y + margin.top - 10}px`;
|
||||
|
||||
try {
|
||||
tooltip.innerHTML = `
|
||||
<div class="p-2">
|
||||
<div class="font-semibold text-lg text-gray-700 border-b-2">${toEvent(d.eventName).name}</div>
|
||||
<div class="">${d.recipient?.email ?? ''}</div>
|
||||
<div class="text-xs text-gray-600">${new Date(d.createdAt).toLocaleString()}</div>
|
||||
${d.data ? `<div class="text-xs mt-1">${d.data}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
console.error('Error showing tooltip:', e);
|
||||
hideTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (tooltip) {
|
||||
tooltip.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
if (!svg || !zoom || !xScale || events.length === 0) return;
|
||||
|
||||
try {
|
||||
// If we have events, zoom to fit all events
|
||||
if (events.length > 0) {
|
||||
const timestamps = events.map((d) => new Date(d.createdAt).getTime());
|
||||
const timeRange = d3.extent(timestamps);
|
||||
const timeSpan = timeRange[1] - timeRange[0];
|
||||
|
||||
// Add padding
|
||||
const padding = Math.max(timeSpan * MIN_ZOOM_OUT_PADDING, 60000);
|
||||
const domainStart = new Date(timeRange[0] - padding);
|
||||
const domainEnd = new Date(timeRange[1] + padding);
|
||||
|
||||
// Calculate optimal scale
|
||||
const optimalScale = calculateOptimalScale(domainStart, domainEnd);
|
||||
|
||||
// Create transform to center the events
|
||||
const initialTranslate = (width - width * optimalScale) / 2;
|
||||
currentTransform = d3.zoomIdentity.translate(initialTranslate, 0).scale(optimalScale);
|
||||
|
||||
// Apply transform with animation
|
||||
d3.select(svg).transition().duration(750).call(zoom.transform, currentTransform);
|
||||
} else {
|
||||
// Default view for no events
|
||||
const fullViewScale = 0.8;
|
||||
const initialX = (width - width * fullViewScale) / 2;
|
||||
currentTransform = d3.zoomIdentity.translate(initialX, 0).scale(fullViewScale);
|
||||
d3.select(svg).transition().duration(750).call(zoom.transform, currentTransform);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error resetting zoom:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getEventColor(eventName) {
|
||||
if (!eventName) return '#6B7280'; // Default gray for undefined events
|
||||
|
||||
const colorMap = {
|
||||
campaign_scheduled: '#4e68d8',
|
||||
campaign_active: '#53afe3',
|
||||
campaign_self_managed: '#303f9f',
|
||||
campaign_closed: '#9f9f9f',
|
||||
campaign_recipient_scheduled: '#4e68d8',
|
||||
campaign_recipient_message_sent: '#94cae6',
|
||||
campaign_recipient_message_failed: '#f2bb58',
|
||||
campaign_recipient_message_read: '#4cb5b5',
|
||||
campaign_recipient_before_page_visited: '#eea5fa',
|
||||
campaign_recipient_page_visited: '#f96dcf',
|
||||
campaign_recipient_after_page_visited: '#f6287b',
|
||||
campaign_recipient_submitted_data: '#f42e41',
|
||||
campaign_recipient_cancelled: '#161692',
|
||||
default: '#6B7280'
|
||||
};
|
||||
return colorMap[eventName] || colorMap.default;
|
||||
}
|
||||
|
||||
$: if (svg && events) {
|
||||
updateTimeline();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative cursor-move" bind:this={container}>
|
||||
<div class="transition-opacity duration-300" class:animate-pulse={isGhost}>
|
||||
<svg
|
||||
bind:this={svg}
|
||||
width="100%"
|
||||
height={height + 20}
|
||||
class="bg-white rounded-lg shadow-sm p-2"
|
||||
/>
|
||||
<div
|
||||
bind:this={tooltip}
|
||||
class="absolute hidden bg-white shadow-lg rounded-lg border border-gray-200 z-10 pointer-events-none"
|
||||
/>
|
||||
{#if !isGhost}
|
||||
<div class="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
on:click={() => {
|
||||
use24Hour = !use24Hour;
|
||||
updateTimeline();
|
||||
}}
|
||||
class="px-2 py-1 text-xs bg-white border border-slate-200 rounded shadow-sm hover:bg-slate-50 text-slate-600"
|
||||
>
|
||||
{use24Hour ? '12h' : '24h'}
|
||||
</button>
|
||||
<button
|
||||
on:click={resetZoom}
|
||||
class="px-2 py-1 text-xs bg-white border border-slate-200 rounded shadow-sm hover:bg-slate-50 text-slate-600"
|
||||
>
|
||||
Reset View
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 left-1/2 transform -translate-x-1/2 bg-white px-3 py-1 rounded-b-lg shadow-sm border border-t-0 text-sm font-medium text-slate-700"
|
||||
>
|
||||
{currentCenterDate}
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 p-2 text-xs text-slate-500">
|
||||
Drag to pan • Scroll to zoom
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.x-axis text) {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
fill: #4a5568;
|
||||
}
|
||||
:global(.x-axis line) {
|
||||
stroke: #e2e8f0;
|
||||
}
|
||||
:global(.x-axis path) {
|
||||
stroke: #e2e8f0;
|
||||
}
|
||||
:global(.ghost-dot) {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
|
||||
// bind a element to this component input field
|
||||
// use like <TextField bind:bindTo={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindTo = null;
|
||||
// placeholder
|
||||
export let placeholder = '';
|
||||
export let resets = true; // reset value on parent form reset
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
export let required = false;
|
||||
export let multiple = false;
|
||||
export let name = 'files';
|
||||
export let accept = '*';
|
||||
export let bgColor = '';
|
||||
// bind to parent form element, if there is one
|
||||
let parentForm = null;
|
||||
// listen to parent form reset event, if one exists
|
||||
let parentFormResetListener = null;
|
||||
|
||||
onMount(() => {});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (!parentForm && resets) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener && resets) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<label class="flex flex-col py-2 w-56">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
id="files"
|
||||
type="file"
|
||||
{name}
|
||||
{accept}
|
||||
bind:this={bindTo}
|
||||
on:change
|
||||
autocomplete="off"
|
||||
{multiple}
|
||||
{required}
|
||||
{placeholder}
|
||||
class="border-solid border-2 py-2 px-2 rounded-md file:px-4 file:py-2 file:text-white file:cursor-pointer file:text-sm file:font-semibold bg-white file:bg-cta-green hover:cursor-pointer file:hover:bg-teal-300 file:border-hidden file:rounded-md"
|
||||
/>
|
||||
</label>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
export let bindTo = null;
|
||||
export let fullHeight = false;
|
||||
export let fullWidth = false;
|
||||
</script>
|
||||
|
||||
<form
|
||||
on:submit|preventDefault
|
||||
class="flex w-60 flex-col"
|
||||
class:h-full={fullHeight}
|
||||
class:w-full={fullWidth}
|
||||
bind:this={bindTo}
|
||||
>
|
||||
<slot />
|
||||
</form>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
export let size = 'large';
|
||||
export let isSubmitting = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
class:w-32={size === 'small'}
|
||||
class:w-36={size === 'medium'}
|
||||
class:w-60={size === 'large'}
|
||||
class="bg-cta-blue px-2 py-2 self-end hover:bg-blue-500 text-white text-sm font-bold flex justify-center items-center uppercase rounded-md"
|
||||
on:click
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div
|
||||
class="mr-2 w-4 h-4 border-t-2 border-t-white border-r-2 border-r-white border-b-white border-b-2 border-l-transparent border-l-2 rounded-xl animate-spin"
|
||||
></div>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
export let overflowX = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="px-6 flex flex-col items-start sm:items-start md:items-start lg:items-start xl:items-start 2xl:items-start"
|
||||
class:overflow-x-scroll={overflowX}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
export let id = null;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="scrollX col-start-1 col-span-3 row-start-1 row-span-5 py-8 flex-col flex sm:flex-col md:flex-col lg:flex-row justify-center"
|
||||
{id}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script>
|
||||
export let message = '';
|
||||
import { slide } from 'svelte/transition';
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div class="flex col-span-12 justify-center pb-4">
|
||||
<div class="w-80 flex col-span-12 justify-center">
|
||||
<div
|
||||
class="flex items-center w-full bg-pleasant-gray rounded-md border text-center p-2 font-titilium"
|
||||
>
|
||||
<svg class="w-12 ml-4" transition:slide viewBox="0 0 32.94 32.94">
|
||||
<circle
|
||||
style="stroke-width: 2px; fill: none; stroke: #e06e94;"
|
||||
cx="16.47"
|
||||
cy="16.47"
|
||||
r="15.47"
|
||||
/>
|
||||
<g>
|
||||
<line
|
||||
style="stroke-width: 3px; stroke-linecap: round; fill: none; stroke: #e06e94;"
|
||||
x1="16.45"
|
||||
y1="6.06"
|
||||
x2="16.45"
|
||||
y2="19.82"
|
||||
/>
|
||||
<line
|
||||
style="stroke-width: 3px; stroke-linecap: round; fill: none; stroke: #e06e94;"
|
||||
x1="16.45"
|
||||
y1="24.4"
|
||||
x2="16.45"
|
||||
y2="26.22"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="pl-4">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
export let bindTo = null;
|
||||
</script>
|
||||
|
||||
<form
|
||||
on:submit|preventDefault
|
||||
class="col-start-1 col-span-3 row-start-1 row-span-5 py-8 flex-col flex sm:flex-col md:flex-col lg:flex-row justify-start"
|
||||
bind:this={bindTo}
|
||||
>
|
||||
<slot />
|
||||
</form>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import FormButton from './FormButton.svelte';
|
||||
|
||||
export let isSubmitting = false;
|
||||
export let closeModal = () => {};
|
||||
export let okText = 'Save';
|
||||
export let closeText = 'Cancel';
|
||||
|
||||
export const onCloseModal = () => {
|
||||
closeModal();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
isSubmitting = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end"
|
||||
>
|
||||
<button
|
||||
type="reset"
|
||||
on:click|preventDefault={onCloseModal}
|
||||
class="bg-slate-400 hover:bg-slate-300 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md"
|
||||
>{closeText}</button
|
||||
>
|
||||
<FormButton {isSubmitting}>{okText}</FormButton>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
export let bindTo = null;
|
||||
export let isSubmitting = false;
|
||||
export let novalidate = false;
|
||||
</script>
|
||||
|
||||
<form
|
||||
on:submit|preventDefault
|
||||
inert={isSubmitting}
|
||||
class="grid grid-cols-3 grid-rows-1 w-full h-full flex-col"
|
||||
class:opacity-70={isSubmitting}
|
||||
bind:this={bindTo}
|
||||
{novalidate}
|
||||
>
|
||||
<slot />
|
||||
</form>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
export let center = false;
|
||||
export let square = false;
|
||||
const widths = ['w-16', 'w-20', 'w-24'];
|
||||
let height = 'h-4';
|
||||
let width = widths[Math.floor(Math.random() * widths.length)];
|
||||
let gradient = 'from-gray-300 to-transparent bg-gradient-to-r';
|
||||
if (square) {
|
||||
width = 'h-4';
|
||||
height = 'w-4';
|
||||
gradient = 'bg-gray-300';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if center}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="{width} {height} {gradient} "> </div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="{width} {height} {gradient}"> </div>
|
||||
{/if}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
export let title = '';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title ?? ''} - Phishing Club</title>
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="bg-gray-300 w-full h-1/10 flex justify-center">
|
||||
<img alt="logo" src="/logo-white.svg" class="w-1/3 sm:w-1/4 lg:w-2/10 xl:w-1/10 2xl:w-1/10" />
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h1 class="text-3xl text-gray-600 font-bold uppercase">
|
||||
<slot />
|
||||
</h1>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script>
|
||||
export let name = 'World';
|
||||
console.log(name)
|
||||
</script>
|
||||
|
||||
<main>
|
||||
Hello {name}!
|
||||
</main>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script>
|
||||
// TODO consider renaming component this to ? as it is more than just an input
|
||||
|
||||
// props
|
||||
export let fieldName = '';
|
||||
export let type = 'text';
|
||||
export let value = '';
|
||||
export let element = {};
|
||||
export let submitOnEnter = false; // new prop to control Enter behavior
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-full p-4 h-24">
|
||||
<label for={fieldName} class="text-md font-semibold font-titilium text-pc-darkblue"
|
||||
>{fieldName}</label
|
||||
>
|
||||
<input
|
||||
bind:this={element}
|
||||
on:keyup={(event) => {
|
||||
const t = /** @type {HTMLInputElement} */ (event.target);
|
||||
value = t.value;
|
||||
// If submitOnEnter is true and Enter was pressed, submit the closest form
|
||||
if (submitOnEnter && event.key === 'Enter') {
|
||||
const form = /** @type {HTMLElement} */ (event.target).closest('form');
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
{value}
|
||||
required
|
||||
autocomplete="off"
|
||||
{type}
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
class="w-full p-2 rounded bg-pc-lightblue focus:outline-none focus:ring-0 focus:border-cta-blue focus:border-2"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { isLoading } from '$lib/store/loading.js';
|
||||
import { blur } from 'svelte/transition';
|
||||
|
||||
let isAnimating = false;
|
||||
const duration = 250; //ms
|
||||
|
||||
isLoading.subscribe((s) => {
|
||||
const throttle = setTimeout(() => {
|
||||
isAnimating = s;
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(throttle);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $isLoading && isAnimating}
|
||||
<div class="fixed top-0 left-0 w-full h-full opacity-[0.5]" transition:blur={{ duration }} />
|
||||
<div
|
||||
transition:blur={{ duration }}
|
||||
class="fixed top-0 left-0 w-full h-full flex justify-center items-center backdrop-blur-sm z-50"
|
||||
>
|
||||
<div
|
||||
class="w-20 h-20 border-t-8 border-t-cta-blue border-r-8 border-r-cta-blue border-b-cta-blue border-b-8 border-l-transparent border-l-8 rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,281 @@
|
||||
<script>
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { scrollBarClassesVertical } from '$lib/utils/scrollbar';
|
||||
|
||||
export let headerText = '';
|
||||
export let description = '';
|
||||
export let visible = false;
|
||||
export let isSubmitting = false;
|
||||
export let onClose = () => {};
|
||||
export let bindTo = null;
|
||||
export let resetTabFocus = () => {};
|
||||
|
||||
let modalElement;
|
||||
let previousActiveElement;
|
||||
let focusableElements = [];
|
||||
let firstFocusableElement;
|
||||
let lastFocusableElement;
|
||||
|
||||
let modalInitialized = false;
|
||||
|
||||
$: {
|
||||
if (visible && !modalInitialized) {
|
||||
window.addEventListener('keydown', keyHandler);
|
||||
// Prevent body scrolling when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
handleModalOpen();
|
||||
modalInitialized = true;
|
||||
} else if (!visible && modalInitialized) {
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Restore body scrolling when modal is closed
|
||||
document.body.style.overflow = 'auto';
|
||||
handleModalClose();
|
||||
modalInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
} else if (e.key === 'Tab') {
|
||||
// Check if the focused element is a dropdown option button
|
||||
const focusedElement = document.activeElement;
|
||||
const isDropdownOption =
|
||||
focusedElement?.closest('[role="listbox"]') && focusedElement?.role === 'option';
|
||||
|
||||
if (isDropdownOption) {
|
||||
// Don't intercept tab from dropdown options - let them handle it first
|
||||
return;
|
||||
}
|
||||
|
||||
handleTabKey(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabKey = (e) => {
|
||||
// Store the current focused element before updating the list
|
||||
const currentlyFocused = document.activeElement;
|
||||
|
||||
// Update focusable elements before handling tab to account for dynamic changes
|
||||
updateFocusableElements();
|
||||
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
// Always prevent default tab behavior to keep focus within modal
|
||||
e.preventDefault();
|
||||
|
||||
let currentIndex = focusableElements.indexOf(currentlyFocused);
|
||||
|
||||
// If current element is not found (-1), try to find a related element
|
||||
if (currentIndex === -1) {
|
||||
// Check if the current element is inside a TextFieldSelect or similar component
|
||||
const parentComponent = currentlyFocused?.closest('.relative');
|
||||
if (parentComponent) {
|
||||
// Look for the input element within the same component
|
||||
const inputInComponent = parentComponent.querySelector('input');
|
||||
if (inputInComponent) {
|
||||
const inputIndex = focusableElements.indexOf(inputInComponent);
|
||||
if (inputIndex !== -1) {
|
||||
// Use the input's position for navigation
|
||||
currentIndex = inputIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still can't find the element, handle it gracefully
|
||||
if (currentIndex === -1) {
|
||||
// If we can't find the element or related element, try to be smarter
|
||||
// Check if we should go forward or backward based on the shift key
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: go to last element
|
||||
lastFocusableElement?.focus();
|
||||
} else {
|
||||
// Tab: try to find the first input in the form, or fallback to first element
|
||||
const firstInput = focusableElements.find((el) => el.tagName === 'INPUT');
|
||||
if (firstInput) {
|
||||
firstInput.focus();
|
||||
} else {
|
||||
firstFocusableElement?.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Now handle normal tab navigation
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab - go to previous element
|
||||
if (currentIndex <= 0) {
|
||||
// If at first element, go to last
|
||||
lastFocusableElement?.focus();
|
||||
} else {
|
||||
// Go to previous element
|
||||
focusableElements[currentIndex - 1]?.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab - go to next element
|
||||
if (currentIndex >= focusableElements.length - 1) {
|
||||
// If at last element, go to first
|
||||
firstFocusableElement?.focus();
|
||||
} else {
|
||||
// Go to next element
|
||||
focusableElements[currentIndex + 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFocusableElements = () => {
|
||||
if (!modalElement) return [];
|
||||
|
||||
const focusableSelectors = [
|
||||
'button:not([disabled])',
|
||||
'[href]',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"]):not([disabled])',
|
||||
'details',
|
||||
'summary'
|
||||
];
|
||||
|
||||
const elements = modalElement.querySelectorAll(focusableSelectors.join(', '));
|
||||
return Array.from(elements).filter((el) => {
|
||||
// Exclude dropdown option buttons from TextFieldSelect components
|
||||
if (el.role === 'option' && el.closest('[role="listbox"]')) {
|
||||
return false;
|
||||
}
|
||||
return el.offsetWidth > 0 && el.offsetHeight > 0 && !el.hasAttribute('hidden');
|
||||
});
|
||||
};
|
||||
|
||||
const updateFocusableElements = () => {
|
||||
focusableElements = getFocusableElements();
|
||||
|
||||
// Exclude the close button from being the first focusable element
|
||||
const closeButton = modalElement?.querySelector('[data-close-button]');
|
||||
if (closeButton && focusableElements.length > 1) {
|
||||
focusableElements = focusableElements.filter((el) => el !== closeButton);
|
||||
focusableElements.push(closeButton); // Add close button to the end
|
||||
}
|
||||
firstFocusableElement = focusableElements[0] || null;
|
||||
lastFocusableElement = focusableElements[focusableElements.length - 1] || null;
|
||||
};
|
||||
|
||||
const handleModalOpen = async () => {
|
||||
// Store the currently focused element
|
||||
previousActiveElement = document.activeElement;
|
||||
|
||||
// Wait for the DOM to update
|
||||
await tick();
|
||||
|
||||
updateFocusableElements();
|
||||
|
||||
// Focus the first focusable element (excluding close button)
|
||||
if (firstFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
// Restore focus to the previously focused element
|
||||
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Exposed function to reset tab focus when modal content changes
|
||||
const handleResetTabFocus = async () => {
|
||||
// Only reset focus if no element is currently focused or if focus is outside modal
|
||||
const currentFocused = document.activeElement;
|
||||
const isInModal = modalElement?.contains(currentFocused);
|
||||
|
||||
// Check if a TextFieldSelect is currently selecting an option
|
||||
const isTextFieldSelectActive = modalElement?.querySelector('[data-selecting="true"]');
|
||||
|
||||
await tick();
|
||||
updateFocusableElements();
|
||||
|
||||
// Only focus first element if focus is not properly within the modal and no selection is happening
|
||||
if (!isInModal && !isTextFieldSelectActive && firstFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Call resetTabFocus when it changes
|
||||
$: if (resetTabFocus) {
|
||||
resetTabFocus = handleResetTabFocus;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Ensure body scrolling is restored when component is destroyed
|
||||
document.body.style.overflow = 'auto';
|
||||
// Restore focus if modal was open when component was destroyed
|
||||
if (visible && previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
beforeNavigate((opts) => {
|
||||
if (!opts.from || !opts.to || !visible) {
|
||||
return;
|
||||
}
|
||||
const navigationIsNotOnSamePage = opts.from.url.pathname === opts.to.url.pathname;
|
||||
if (opts.type === 'popstate' && !navigationIsNotOnSamePage) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
visible = false;
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
// Restore body scrolling when modal is closed
|
||||
document.body.style.overflow = 'auto';
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div bind:this={bindTo}>
|
||||
<div class="fixed top-0 left-0 w-full h-full bg-cta-blue opacity-20 blur-xl" />
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full flex justify-center items-center backdrop-blur-sm z-20"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby={description ? 'modal-description' : undefined}
|
||||
>
|
||||
<section
|
||||
bind:this={modalElement}
|
||||
class="shadow-xl w-auto ml-20 mr-8 max-h-[90vh] bg-white opacity-100 rounded-md flex flex-col"
|
||||
>
|
||||
<div
|
||||
class:opacity-20={isSubmitting}
|
||||
class="bg-cta-blue text-white rounded-t-md py-4 px-8 flex justify-between flex-shrink-0"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h1 id="modal-title" class="uppercase mr-8 font-semibold text-2xl">{headerText}</h1>
|
||||
{#if description}
|
||||
<p id="modal-description" class="mt-2 text-sm opacity-90">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="w-4 hover:scale-110 flex-shrink-0"
|
||||
on:click={close}
|
||||
disabled={isSubmitting}
|
||||
data-close-button
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<img class="w-full" src="/close-white.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-8 overflow-y-auto {scrollBarClassesVertical}">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,54 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let paginator;
|
||||
|
||||
let currentPage = paginator.currentPage;
|
||||
let urlWithoutParams = window.location.href.split('?')[0];
|
||||
|
||||
const nextPage = async () => {
|
||||
currentPage = paginator.next();
|
||||
};
|
||||
|
||||
const previousPage = async () => {
|
||||
currentPage = paginator.previous();
|
||||
};
|
||||
|
||||
const popStateHandler = () => {
|
||||
// only handle popstate if the url matches the current page
|
||||
// this is to avoid handling popstate events from other pages
|
||||
// upon a browser back/forward navigation as popstate event happens
|
||||
// before the component is unmounted
|
||||
if (window.location.href.split('?')[0] !== urlWithoutParams) {
|
||||
return;
|
||||
}
|
||||
currentPage = paginator.currentPage;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// listen for browser back/forward navigation
|
||||
window.addEventListener('popstate', popStateHandler);
|
||||
// cleanup on component unmount
|
||||
paginator.onChange((key, value) => {
|
||||
if (key === 'page') {
|
||||
currentPage = value;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('popstate', popStateHandler);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center mb-8 mt-4">
|
||||
<button
|
||||
class="bg-highlight-blue w-8 text-white hover:bg-active-blue m-1 rounded-md py-1 px-1"
|
||||
on:click|preventDefault={previousPage}><<</button
|
||||
>
|
||||
<div class="w-8 text-center bg-grayblue-light rounded-md py-1 px-1">{currentPage}</div>
|
||||
<button
|
||||
class="bg-highlight-blue w-8 text-white hover:bg-active-blue m-1 rounded-md py-1 px-1"
|
||||
on:click|preventDefault={nextPage}>>></button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
|
||||
// use like <PasswordField bind:bindTo={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindTo = null;
|
||||
// placeholder
|
||||
export let defaultValue = ''; // default checkbox value
|
||||
export let value = defaultValue; // for binding value
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
export let placeholder = '';
|
||||
export let required = false;
|
||||
export let minLength = null;
|
||||
export let maxLength = null;
|
||||
export let id = null;
|
||||
|
||||
let parentForm = null;
|
||||
let parentFormResetListener = null;
|
||||
let viewPassword = true;
|
||||
|
||||
onMount(() => {
|
||||
value = value ?? defaultValue;
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (!parentForm) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
var handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
// if this is a key event and the key is not enter
|
||||
if (e.key && (e.key === 'Enter' || e.key === ' ')) {
|
||||
viewPassword = !viewPassword;
|
||||
return;
|
||||
}
|
||||
// bug - this fixes a bug where if a user
|
||||
// clicks enter inside the button field, the password is shown
|
||||
if (e.target.tagName === 'BUTTON') {
|
||||
return;
|
||||
}
|
||||
viewPassword = !viewPassword;
|
||||
};
|
||||
</script>
|
||||
|
||||
<label class="flex flex-col py-2 w-60">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="relative flex items-center justify-end">
|
||||
{#if viewPassword}
|
||||
<input
|
||||
{id}
|
||||
type="password"
|
||||
bind:this={bindTo}
|
||||
bind:value
|
||||
on:keyup
|
||||
{placeholder}
|
||||
autocomplete="off"
|
||||
minlength={minLength}
|
||||
maxlength={maxLength}
|
||||
{required}
|
||||
class="text-ellipsis w-60 rounded-md py-2 pl-4 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
{id}
|
||||
on:keyup
|
||||
type="text"
|
||||
bind:this={bindTo}
|
||||
bind:value
|
||||
minlength={minLength}
|
||||
maxlength={maxLength}
|
||||
{placeholder}
|
||||
autocomplete="off"
|
||||
{required}
|
||||
class="text-ellipsis w-60 rounded-md py-2 pl-4 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class="absolute w-8 mr-2 hover:opacity-70"
|
||||
on:click={handleClick}
|
||||
on:keyup={handleClick}
|
||||
>
|
||||
{#if viewPassword}
|
||||
<img src="/view.svg" alt="view" />
|
||||
{:else}
|
||||
<img src="/toggle-view.svg" alt="toggle view" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
/** @type {String | Date} */
|
||||
export let value;
|
||||
export let updateInterval = 60000; // default to 1 minute updates
|
||||
|
||||
let formattedTime = '';
|
||||
let intervalId;
|
||||
|
||||
function formatRelativeTime(date) {
|
||||
if (!date) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const seconds = Math.floor(Math.abs(diff) / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const remainingMinutes = minutes % 60;
|
||||
|
||||
// future
|
||||
if (diff > 0) {
|
||||
if (days > 0) {
|
||||
return `in ${days} day${days !== 1 ? 's' : ''}${hours % 24 ? `, ${hours % 24} hour${hours % 24 !== 1 ? 's' : ''}` : ''}`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `in ${hours} hour${hours !== 1 ? 's' : ''}${remainingMinutes > 0 ? `, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}` : ''}`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `in ${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
||||
}
|
||||
return 'in less than a minute';
|
||||
}
|
||||
|
||||
// past
|
||||
if (days > 0) {
|
||||
return `${days} day${days !== 1 ? 's' : ''}${hours % 24 ? `, ${hours % 24} hour${hours % 24 !== 1 ? 's' : ''}` : ''} ago`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''}${remainingMinutes > 0 ? `, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}` : ''} ago`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
return 'now';
|
||||
}
|
||||
|
||||
function updateTime() {
|
||||
let date;
|
||||
if (typeof value === 'string') {
|
||||
date = new Date(value);
|
||||
} else if (value instanceof Date) {
|
||||
date = value;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
formattedTime = 'Invalid date';
|
||||
return;
|
||||
}
|
||||
|
||||
formattedTime = formatRelativeTime(date);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (value) {
|
||||
updateTime();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateTime();
|
||||
intervalId = setInterval(updateTime, updateInterval);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<span
|
||||
title={typeof value === 'string'
|
||||
? new Date(value).toLocaleString()
|
||||
: value instanceof Date
|
||||
? value.toLocaleString()
|
||||
: ''}
|
||||
>
|
||||
{formattedTime}
|
||||
</span>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
import { hideIsLoading, showIsLoading } from '$lib/store/loading';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { Session } from '$lib/service/session';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
|
||||
// services
|
||||
const session = Session.instance;
|
||||
const appState = AppStateService.instance;
|
||||
|
||||
onMount(() => {
|
||||
showIsLoading();
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const ok = await api.application.health();
|
||||
if (ok) {
|
||||
await session.ping();
|
||||
hideIsLoading();
|
||||
clearInterval(id);
|
||||
appState.ready();
|
||||
}
|
||||
} catch (e) {}
|
||||
}, 1000);
|
||||
return () => {
|
||||
hideIsLoading();
|
||||
clearInterval(id);
|
||||
appState.ready();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full flex border-4 border-pc-darkblue animate-pulse"></div>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/** @type {*|null} */
|
||||
export let pagination = null;
|
||||
let value = pagination?.search;
|
||||
let searchTimeoutID = null;
|
||||
|
||||
const setSearch = () => {
|
||||
clearTimeout(searchTimeoutID);
|
||||
searchTimeoutID = setTimeout(() => {
|
||||
pagination.search = value;
|
||||
}, 400);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// listen for browser back/forward navigation
|
||||
//window.addEventListener('popstate', popStateHandler);
|
||||
// cleanup on component unmount
|
||||
pagination.onChange((k, v) => {
|
||||
if (k === 'search') {
|
||||
value = v;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
//window.removeEventListener('popstate', popStateHandler);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative flex items-center">
|
||||
<img class="ml-2 w-4 h-4 absolute z-10" src="/search-icon.svg" alt="search icon" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value
|
||||
on:keyup={() => {
|
||||
if (pagination && pagination.search !== null) {
|
||||
setSearch();
|
||||
}
|
||||
}}
|
||||
class="bg-grayblue-light w-56 border text-gray-600 border-gray-300 pl-8 py-2 relative rounded-lg focus:outline-none focus:ring-0 focus:border-cta-blue focus:border"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/**
|
||||
* @type {*|null}
|
||||
*/
|
||||
export let pagination = null;
|
||||
|
||||
let value = pagination?.perPage;
|
||||
|
||||
const setSearch = () => {
|
||||
pagination.perPage = value;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
pagination.onChange((k, v) => {
|
||||
if (k === 'perPage') {
|
||||
value = v;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div >
|
||||
<label for="pet-select">Show:</label>
|
||||
<select
|
||||
class="bg-grayblue-light px-2 py-1 rounded-md text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 focus:bg-gray-100"
|
||||
name="entries"
|
||||
id="entries"
|
||||
bind:value
|
||||
on:change={() => {
|
||||
if (pagination && pagination.perPage !== null) {
|
||||
setSearch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#each [10, 25, 50] as option}
|
||||
{#if option === value}
|
||||
<option value={option} selected>{option}</option>
|
||||
{:else}
|
||||
<option value={option}>{option}</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script>
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
|
||||
export let options = [];
|
||||
export let value = null;
|
||||
export let label = '';
|
||||
export let center = true;
|
||||
export let optional = false;
|
||||
export let toolTipText = '';
|
||||
export let width = 'medium';
|
||||
/** @type {*} */
|
||||
export let onChange = () => {};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 py-2">
|
||||
{#if label}
|
||||
<div class="flex flex-row">
|
||||
<div class="font-semibold text-slate-600">{label}</div>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 py-2" class:justify-center={center}>
|
||||
{#each options as option}
|
||||
<button
|
||||
type="button"
|
||||
class:h32={option.icon && option.description}
|
||||
class:h16={!option.icon && !option.description}
|
||||
class:w-28={width === 'small'}
|
||||
class:w-40={width === 'medium'}
|
||||
class:w-64={width === 'large'}
|
||||
class={`
|
||||
p-3 rounded-lg border-2 transition-all duration-200
|
||||
flex flex-col items-center justify-center text-center
|
||||
w-40
|
||||
hover:border-blue-300 hover:bg-blue-50
|
||||
${
|
||||
value === option.value
|
||||
? 'border-green-500 bg-green-50 text-green-700 '
|
||||
: 'border-gray-200 bg-white text-gray-700 '
|
||||
}
|
||||
`}
|
||||
on:click={() => {
|
||||
value = option.value;
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
{#if option.icon}
|
||||
<span class="text-xl mb-2">{option.icon}</span>
|
||||
{/if}
|
||||
<span class="font-medium text-sm">{option.label}</span>
|
||||
{#if option.description}
|
||||
<span class="text-xs text-gray-500 mt-1">{option.description}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script>
|
||||
import { tweened } from 'svelte/motion';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
export let title = '';
|
||||
export let value = 0;
|
||||
export let borderColor = 'border-cta-blue';
|
||||
export let iconColor = 'text-cta-blue';
|
||||
export let percentages = [];
|
||||
|
||||
let initialValueSet = false;
|
||||
let previousValue = 0;
|
||||
let flash = false;
|
||||
let currentPercentageIndex = 0;
|
||||
|
||||
const displayValue = tweened(0, {
|
||||
duration: 200,
|
||||
easing: cubicOut
|
||||
});
|
||||
|
||||
$: validPercentages = percentages.filter((p) => p.baseValue && p.baseValue > 0);
|
||||
|
||||
$: {
|
||||
if (!initialValueSet && value > 0) {
|
||||
displayValue.set(value, { duration: 0 });
|
||||
initialValueSet = true;
|
||||
previousValue = value;
|
||||
} else if (initialValueSet && value !== previousValue) {
|
||||
displayValue.set(value);
|
||||
if (value > previousValue) {
|
||||
flash = true;
|
||||
setTimeout(() => (flash = false), 1000);
|
||||
}
|
||||
previousValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
function cyclePercentage() {
|
||||
if (validPercentages.length > 1) {
|
||||
currentPercentageIndex = (currentPercentageIndex + 1) % validPercentages.length;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white p-6 rounded-lg shadow-md border-l-[12px] {borderColor} hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="text-grayblue-dark text-sm font-semibold">{title}</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="text-3xl font-bold text-pc-darkblue {flash ? 'flash' : ''}">
|
||||
{Math.floor($displayValue)}
|
||||
</span>
|
||||
<div class="ml-2">
|
||||
<slot name="icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 {iconColor}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if validPercentages.length > 0}
|
||||
<button
|
||||
class="mt-2 text-sm text-gray-600 flex items-center"
|
||||
on:click={cyclePercentage}
|
||||
class:cursor-pointer={validPercentages.length > 1}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="text-pc-darkblue font-semibold">
|
||||
{validPercentages[currentPercentageIndex].value}%
|
||||
</span>
|
||||
<span class="ml-1">
|
||||
{validPercentages[currentPercentageIndex].relativeTo}
|
||||
</span>
|
||||
</div>
|
||||
{#if validPercentages.length > 1}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 ml-1 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.flash {
|
||||
animation: flash-animation 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes flash-animation {
|
||||
0% {
|
||||
color: inherit;
|
||||
}
|
||||
25% {
|
||||
color: #5dd8c4;
|
||||
}
|
||||
100% {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h2 class="text-lg text-gray-600 font-bold uppercase">
|
||||
<slot />
|
||||
</h2>
|
||||
@@ -0,0 +1,6 @@
|
||||
<span
|
||||
title="Test / Preview"
|
||||
class="select-none border-2 pl-1 pr-1 bg-white relative -top-3 rounded-xl text-xs"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
|
||||
// bind a element to this component input field
|
||||
// use like <TextField bind:bindTo={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindTo = null;
|
||||
// placeholder
|
||||
export let placeholder = '';
|
||||
export let defaultValue = ''; // default checkbox value
|
||||
export let value = defaultValue; // for binding value
|
||||
export let resets = true; // reset value on parent form reset
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
export let readonly = false;
|
||||
export let required = false;
|
||||
export let disabled = false;
|
||||
export let min = null;
|
||||
export let max = null;
|
||||
export let minLength = null;
|
||||
export let maxLength = null;
|
||||
export let width = 'medium';
|
||||
export let pattern = null;
|
||||
export let id = null;
|
||||
export let onBlur = () => {};
|
||||
// type can only be set initially
|
||||
export let type = 'text';
|
||||
let inputType = 'text';
|
||||
|
||||
// bind to parent form element, if there is one
|
||||
let parentForm = null;
|
||||
// listen to parent form reset event, if one exists
|
||||
let parentFormResetListener = null;
|
||||
|
||||
onMount(() => {
|
||||
value = value ?? defaultValue;
|
||||
inputType = type;
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (!parentForm && resets) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener && resets) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<label class="flex flex-col py-2">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
{...{ type: inputType }}
|
||||
{id}
|
||||
bind:this={bindTo}
|
||||
bind:value
|
||||
autocomplete="off"
|
||||
title={value}
|
||||
on:click
|
||||
on:blur={onBlur}
|
||||
on:keyup
|
||||
on:keydown
|
||||
{min}
|
||||
{max}
|
||||
minlength={minLength}
|
||||
maxlength={maxLength}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
{placeholder}
|
||||
{pattern}
|
||||
class="text-ellipsis row-start-1 row-span-3 justify-self-center rounded-md py-2 pl-2 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class:w-24={width === 'small'}
|
||||
class:w-60={width === 'medium'}
|
||||
class:w-95={width === 'large'}
|
||||
class:w-full={width === 'full'}
|
||||
/>
|
||||
</label>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
import { activeFormElement } from '$lib/store/activeFormElement';
|
||||
|
||||
const _id = Symbol();
|
||||
export let id;
|
||||
// bind a element to this component input field
|
||||
// use like <TextField bind:bindTo={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindTo = null;
|
||||
export let defaultValue = []; // default selected value
|
||||
// for binding value, it is an array of the selected items
|
||||
export let value = defaultValue;
|
||||
export let required = false;
|
||||
export let options = [];
|
||||
export let onSelect = (value) => {};
|
||||
export let onRemove = (value) => {};
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
|
||||
let filteredOptions = [...options];
|
||||
// the input value is only used for searching
|
||||
let inputValue = '';
|
||||
// bind to parent form element, if there is one
|
||||
let parentForm = null;
|
||||
// listen to parent form reset event, if one exists
|
||||
let parentFormResetListener = null;
|
||||
let showSelection = false;
|
||||
|
||||
onMount(() => {
|
||||
value = value ?? defaultValue;
|
||||
const unsubscribe = activeFormElement.subscribe((activeId) => {
|
||||
showSelection = activeId === _id;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (options.length && inputValue === '') {
|
||||
filteredOptions = [...options];
|
||||
}
|
||||
if (!parentForm) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
|
||||
const removeSelection = (event) => {
|
||||
const v = event.target.dataset.value;
|
||||
// svelte remove the item from the selected array if it exists
|
||||
value = value.filter((item) => item !== v);
|
||||
onRemove(v);
|
||||
};
|
||||
|
||||
const closeSelection = () => {
|
||||
showSelection = false;
|
||||
// stop listening for a click
|
||||
document.removeEventListener('click', closeSelection);
|
||||
};
|
||||
|
||||
const onFocus = (e) => {
|
||||
inputValue = '';
|
||||
showSelection = true;
|
||||
activeFormElement.set(_id);
|
||||
// when we focus in the input field, we add a listener for a click anywhere
|
||||
// we add a small timeout to ensure the closeSelection is not closed at once
|
||||
// when we have focus in the box
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeSelection);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const onKeyUp = () => {
|
||||
if (inputValue === '') {
|
||||
filteredOptions = [...options];
|
||||
return;
|
||||
}
|
||||
filteredOptions = filteredOptions.filter((opt) => opt.includes(inputValue));
|
||||
};
|
||||
|
||||
/** @type {(event: Event) => void} */
|
||||
const onChange = (event) => {
|
||||
// check if the value already exists in the selected array
|
||||
const target = /** @type {HTMLInputElement} */ (event.target);
|
||||
if (!value.includes(target.value) && options.includes(target.value)) {
|
||||
value = [target.value, ...value];
|
||||
// TODO remove it from available selections and ensure the list works when removing it agian
|
||||
}
|
||||
bindTo.blur();
|
||||
};
|
||||
|
||||
const onClickSelectedOption = (option) => {
|
||||
// if the option is not already selected, we add it
|
||||
if (!value.find((v) => v === option)) {
|
||||
value = [option, ...value];
|
||||
onSelect(option);
|
||||
}
|
||||
showSelection = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-start">
|
||||
<label class="flex flex-col py-2 relative">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<div class="flex items-center relative w-60">
|
||||
<input
|
||||
type="text"
|
||||
bind:this={bindTo}
|
||||
bind:value={inputValue}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
if (!options.includes(inputValue)) {
|
||||
inputValue = '';
|
||||
}
|
||||
}}
|
||||
on:change={onChange}
|
||||
on:keyup={onKeyUp}
|
||||
on:click|stopPropagation={() => {}}
|
||||
{id}
|
||||
required={required && !value.length}
|
||||
autocomplete="off"
|
||||
class="w-full relative rounded-md py-2 pl-4 focus:pl-10 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal cursor-pointer focus:cursor-text"
|
||||
/>
|
||||
{#if showSelection}
|
||||
<img
|
||||
class="absolute w-4 left-4 pointer-events-none select-none"
|
||||
src="/search-icon.svg"
|
||||
alt="search"
|
||||
/>
|
||||
{/if}
|
||||
<img class="absolute pointer-events-none w-4 right-4" src="/arrow.svg" alt="drop down" />
|
||||
</div>
|
||||
{#if showSelection}
|
||||
<div class="w-60 absolute top-10 z-50">
|
||||
<ul
|
||||
class="bg-gray-100 list-none mt-4 rounded-md min-w-fit shadow-md border max-h-40 overflow-y-scroll"
|
||||
>
|
||||
{#if options.length}
|
||||
{#each filteredOptions as option}
|
||||
<li>
|
||||
<button
|
||||
class="w-full text-left bg-slate-100 rounded-md text-gray-600 hover:bg-grayblue-dark hover:text-white py-2 px-2 cursor-pointer"
|
||||
on:click={() => {
|
||||
onClickSelectedOption(option);
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
<li class="w-full bg-slate-100 rounded-md text-gray-600 py-2 px-2">List is empty</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row flex-wrap mb-4">
|
||||
{#each value as option}
|
||||
<button
|
||||
on:click|preventDefault={removeSelection}
|
||||
on:keypress|preventDefault={removeSelection}
|
||||
data-value={option}
|
||||
class="flex flex-row items-center bg-gray-100 hover:bg-gray-200 px-2 py-2 mt-2 mr-2 rounded-md"
|
||||
>
|
||||
{option}
|
||||
<img class="w-4 ml-2 pointer-events-none" src="/delete2.svg" alt="delete" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
|
||||
export let id;
|
||||
// bind a element to this component input field
|
||||
// use like <TextField bind:bindTo={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindTo = null;
|
||||
export let value = ''; // for binding value
|
||||
export let required = false;
|
||||
export let options = [];
|
||||
export let toolTipText = '';
|
||||
export let placeholder = '';
|
||||
|
||||
// (string) => void
|
||||
export let onKeyUp;
|
||||
// () => string
|
||||
export let onSelect;
|
||||
|
||||
let showSelection = false;
|
||||
|
||||
const closeSelection = () => {
|
||||
showSelection = false;
|
||||
// stop listening for a click
|
||||
document.removeEventListener('click', closeSelection);
|
||||
};
|
||||
|
||||
const onFocus = () => {
|
||||
document.addEventListener('click', closeSelection);
|
||||
};
|
||||
|
||||
const _onKeyUp = () => {
|
||||
try {
|
||||
if (value.length == 0) {
|
||||
return;
|
||||
}
|
||||
onKeyUp(value);
|
||||
showSelection = true;
|
||||
} catch (err) {
|
||||
console.error('failed to search', err);
|
||||
}
|
||||
};
|
||||
|
||||
// bind to parent form element, if there is one
|
||||
let parentForm = null;
|
||||
// listen to parent form reset event, if one exists
|
||||
let parentFormResetListener = null;
|
||||
|
||||
onMount(() => {});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (!parentForm) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = '';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-start">
|
||||
<label class="flex flex-col py-2 relative">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="flex items-center relative w-60">
|
||||
<input
|
||||
type="text"
|
||||
{placeholder}
|
||||
bind:this={bindTo}
|
||||
bind:value
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
if (!options.includes(value)) {
|
||||
value = '';
|
||||
}
|
||||
}}
|
||||
on:keyup={_onKeyUp}
|
||||
on:click|stopPropagation={() => {}}
|
||||
autocomplete="off"
|
||||
class="w-full relative rounded-md py-2 pl-4 focus:pl-10 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal cursor-pointer focus:cursor-text"
|
||||
{id}
|
||||
{required}
|
||||
/>
|
||||
{#if showSelection}
|
||||
<img class="absolute w-4 left-4" src="/search-icon.svg" alt="search" />
|
||||
{/if}
|
||||
<img class="absolute pointer-events-none w-4 right-4" src="/arrow.svg" alt="drop down" />
|
||||
</div>
|
||||
{#if options.length && showSelection}
|
||||
<div class="w-96 absolute top-10 z-50">
|
||||
<ul
|
||||
class="bg-gray-100 list-none mt-4 rounded-md min-w-fit shadow-md border max-h-40 overflow-y-scroll"
|
||||
>
|
||||
{#each options as option}
|
||||
<li class="break-words">
|
||||
<button
|
||||
class="w-full text-left bg-slate-100 rounded-md text-gray-600 hover:bg-grayblue-dark hover:text-white py-2 px-2 cursor-pointer"
|
||||
on:click|preventDefault={() => {
|
||||
value = '';
|
||||
showSelection = false;
|
||||
onSelect(option);
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,348 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
import { activeFormElement, activeFormElementSubscribe } from '$lib/store/activeFormElement';
|
||||
|
||||
export let _id = Symbol();
|
||||
export let id;
|
||||
export let bindTo = null;
|
||||
export let defaultValue = '';
|
||||
export let value = defaultValue;
|
||||
export let placeholder = 'Select...';
|
||||
export let required = false;
|
||||
export let options = [];
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
export let hidden = false;
|
||||
export let size = 'normal';
|
||||
export let inline = false;
|
||||
export let onSelect = (value) => {};
|
||||
|
||||
// Ensure options is always an array
|
||||
$: optionsArray = Array.isArray(options) ? options : Array.from(options);
|
||||
|
||||
let allOptions = [];
|
||||
let showDropdown = false;
|
||||
let inputElement;
|
||||
let dropdownElement;
|
||||
|
||||
// Simple function to filter options based on input
|
||||
const filterOptions = (searchValue) => {
|
||||
if (!searchValue) {
|
||||
return [...optionsArray];
|
||||
}
|
||||
return optionsArray.filter(
|
||||
(opt) => opt && opt.toLowerCase && opt.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
// Track if user has typed (for filtering) vs just focused (show all)
|
||||
let hasTyped = false;
|
||||
|
||||
// Update filtered options - show all on focus, filter only when typed
|
||||
$: allOptions = hasTyped ? filterOptions(value) : [...optionsArray];
|
||||
|
||||
// Show dropdown when focused and there are options
|
||||
const handleFocus = () => {
|
||||
activeFormElement.set(id);
|
||||
showDropdown = true;
|
||||
hasTyped = false; // Reset typing flag to show all options
|
||||
allOptions = [...optionsArray]; // Show all options on focus
|
||||
};
|
||||
|
||||
// Handle input changes for filtering
|
||||
const handleInput = (e) => {
|
||||
value = e.target.value;
|
||||
showDropdown = true;
|
||||
hasTyped = true; // User has typed, enable filtering
|
||||
allOptions = filterOptions(value);
|
||||
};
|
||||
|
||||
// Select an option
|
||||
const selectOption = (option) => {
|
||||
value = option;
|
||||
showDropdown = false;
|
||||
hasTyped = false; // Reset typing flag after selection
|
||||
onSelect(option);
|
||||
};
|
||||
|
||||
// Close dropdown
|
||||
const closeDropdown = () => {
|
||||
showDropdown = false;
|
||||
hasTyped = false; // Reset typing flag when closing
|
||||
};
|
||||
|
||||
// Handle blur to close dropdown when tabbing out
|
||||
const handleBlur = (e) => {
|
||||
// Use setTimeout to allow click events on options to complete first
|
||||
setTimeout(() => {
|
||||
const focusedElement = document.activeElement;
|
||||
const container = inputElement?.closest('.textfield-select-container');
|
||||
|
||||
// If focus moved outside the component, close dropdown
|
||||
if (!container?.contains(focusedElement)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e) => {
|
||||
if (!showDropdown) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (allOptions.length === 1) {
|
||||
selectOption(allOptions[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const firstOption = dropdownElement?.querySelector('button');
|
||||
firstOption?.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle option keyboard navigation
|
||||
const handleOptionKeyDown = (e, option) => {
|
||||
if (e.key === 'Tab') {
|
||||
// Let tab work normally but close dropdown
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectOption(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeDropdown();
|
||||
inputElement?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const currentButton = e.target;
|
||||
const prevButton =
|
||||
currentButton.parentElement?.previousElementSibling?.querySelector('button');
|
||||
if (prevButton) {
|
||||
prevButton.focus();
|
||||
} else {
|
||||
inputElement?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const currentButton = e.target;
|
||||
const nextButton = currentButton.parentElement?.nextElementSibling?.querySelector('button');
|
||||
if (nextButton) {
|
||||
nextButton.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clicks outside to close dropdown
|
||||
const handleOutsideClick = (e) => {
|
||||
if (!showDropdown) return;
|
||||
|
||||
const container = inputElement?.closest('.textfield-select-container');
|
||||
if (container && !container.contains(e.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
// Bind to parent form element
|
||||
let parentForm = null;
|
||||
let parentFormResetListener = null;
|
||||
|
||||
onMount(() => {
|
||||
value = value || defaultValue;
|
||||
const unsubscribe = activeFormElementSubscribe(_id, closeDropdown);
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
};
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (inputElement) {
|
||||
bindTo = inputElement;
|
||||
}
|
||||
|
||||
if (!parentForm && inputElement) {
|
||||
parentForm = inputElement.closest('form');
|
||||
if (parentForm) {
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener && parentForm) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
});
|
||||
|
||||
// Generate unique IDs for accessibility
|
||||
const comboboxId = id || `textfield-select-${_id.toString()}`;
|
||||
const listboxId = `${comboboxId}-listbox`;
|
||||
const labelId = `${comboboxId}-label`;
|
||||
|
||||
// Reactive statements for accessibility
|
||||
$: hasValue = value && value !== '';
|
||||
$: ariaExpanded = showDropdown;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex justify-start textfield-select-container"
|
||||
class:hidden
|
||||
class:flex-col={!inline}
|
||||
class:flex-row={inline}
|
||||
>
|
||||
<label class="flex flex-col py-2 relative" class:py-2={!inline} class:pr-2={inline}>
|
||||
<div class="flex items-center">
|
||||
<p id={labelId} class="font-semibold text-slate-600 py-1">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex items-center relative"
|
||||
class:w-28={size == 'small'}
|
||||
class:w-60={size == 'normal'}
|
||||
>
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
role="combobox"
|
||||
id={comboboxId}
|
||||
aria-labelledby={labelId}
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
bind:value
|
||||
on:focus={handleFocus}
|
||||
on:blur={handleBlur}
|
||||
on:input={handleInput}
|
||||
on:keydown={handleKeyDown}
|
||||
on:click={handleFocus}
|
||||
autocomplete="off"
|
||||
class="w-full relative rounded-md py-2 pr-10 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal cursor-pointer focus:cursor-text"
|
||||
class:pl-10={showDropdown}
|
||||
class:pl-4={!showDropdown}
|
||||
class:text-gray-400={!hasValue && !showDropdown}
|
||||
placeholder={!hasValue && !showDropdown ? placeholder : ''}
|
||||
{required}
|
||||
/>
|
||||
<!-- Search icon - visible when dropdown is open -->
|
||||
{#if showDropdown}
|
||||
<img
|
||||
class="absolute w-4 left-3 select-none pointer-events-none z-10"
|
||||
src="/search-icon.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Clear button for optional fields -->
|
||||
{#if optional === true && hasValue}
|
||||
<button
|
||||
class="absolute right-10 z-10"
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
value = '';
|
||||
onSelect('');
|
||||
inputElement?.focus();
|
||||
}}
|
||||
>
|
||||
<img class="w-4" src="/remove-value.svg" alt="" />
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Dropdown arrow -->
|
||||
<img
|
||||
class="absolute pointer-events-none w-4 right-3"
|
||||
class:right-12={optional === true && hasValue}
|
||||
src="/arrow.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<!-- Dropdown list -->
|
||||
{#if showDropdown}
|
||||
<div
|
||||
bind:this={dropdownElement}
|
||||
class="absolute top-10 z-50"
|
||||
class:w-28={size == 'small'}
|
||||
class:w-60={size == 'normal'}
|
||||
>
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-labelledby={labelId}
|
||||
class="bg-gray-100 list-none mt-4 z-[999] rounded-md min-w-fit shadow-md border max-h-40 overflow-y-scroll"
|
||||
>
|
||||
{#if allOptions.length}
|
||||
{#each allOptions as option, index}
|
||||
<li role="none">
|
||||
<button
|
||||
id="{listboxId}-option-{index}"
|
||||
role="option"
|
||||
aria-selected={value === option}
|
||||
class="w-full text-left bg-slate-100 rounded-md text-gray-600 hover:bg-grayblue-dark hover:text-white py-2 px-2 cursor-pointer focus:bg-grayblue-dark focus:text-white focus:outline-none"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectOption(option);
|
||||
}}
|
||||
on:keydown={(e) => handleOptionKeyDown(e, option)}
|
||||
on:blur={handleBlur}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
<li role="none" class="w-full bg-slate-100 rounded-md text-gray-600 py-2 px-2">
|
||||
No options available
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
|
||||
// bind a element to this component input field
|
||||
// use like <Textarea bind:bindTo={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindTo = null;
|
||||
// placeholder
|
||||
export let placeholder = '';
|
||||
export let defaultValue = ''; // default checkbox value
|
||||
export let value = defaultValue; // for binding value
|
||||
export let toolTipText = '';
|
||||
export let readonly = false;
|
||||
export let resize = true;
|
||||
export let required = false;
|
||||
export let minLength = null;
|
||||
export let maxLength = null;
|
||||
export let id = null;
|
||||
export let fullWidth = false;
|
||||
export let height = 'small';
|
||||
|
||||
// bind to parent form element, if there is one
|
||||
let parentForm = null;
|
||||
// listen to parent form reset event, if one exists
|
||||
let parentFormResetListener = null;
|
||||
let showToolTip = false;
|
||||
export let optional = false;
|
||||
|
||||
onMount(() => {
|
||||
value = value ?? defaultValue;
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (!parentForm) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<label class="flex flex-col py-2 w-60" class:w-full={fullWidth}>
|
||||
<div class="flex items-center">
|
||||
<p class="font-bold text-slate-600 py-2">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
{id}
|
||||
bind:this={bindTo}
|
||||
bind:value
|
||||
{required}
|
||||
minlength={minLength}
|
||||
maxlength={maxLength}
|
||||
{readonly}
|
||||
{placeholder}
|
||||
class=" focus:outline-none pl-2 border border-transparent rounded-md focus:border-solid text-gray-600 focus:bg-gray-100 font-light focus:border-slate-400 bg-grayblue-light"
|
||||
class:h-16={height === 'small'}
|
||||
class:h-28={height === 'medium'}
|
||||
class:h-48={height === 'large'}
|
||||
class:resize-none={!resize}
|
||||
/>
|
||||
</label>
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-slate-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,181 @@
|
||||
<script>
|
||||
import { toasts, removeToast } from '$lib/store/toast';
|
||||
import { slide, draw } from 'svelte/transition';
|
||||
|
||||
function preload(src) {
|
||||
return new Promise(function (resolve) {
|
||||
let img = new Image();
|
||||
img.onload = resolve;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
let src1 = '/t-succes.svg';
|
||||
</script>
|
||||
|
||||
{#if $toasts.length > 0}
|
||||
<div
|
||||
class="fixed flex flex-col items-center w-3/4 xl:w-2/6 bottom-0 mt-6 lg:bottom-auto mx-auto inset-x-0 z-[100]"
|
||||
>
|
||||
{#each $toasts as toast (toast.id)}
|
||||
{#if toast.type === 'Success'}
|
||||
{#await preload(src1) then _}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() => removeToast(toast.id)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
removeToast(toast.id);
|
||||
}
|
||||
}}
|
||||
transition:slide|global
|
||||
class="flex items-center bg-pleasant-gray text-gray-500 shadow-lg capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full"
|
||||
>
|
||||
<!-- <img class="w-9 mr-6" draggable="false" src={src1} alt="checkmark success" /> -->
|
||||
<svg class="w-11 mr-6" viewBox="0 0 32.25 32.4">
|
||||
<path
|
||||
in:draw
|
||||
style="stroke-width: 2px; fill: none; stroke: #5dd8c4; stroke-linecap: round;"
|
||||
d="M31.25,16.28c0,8.35-6.77,15.12-15.12,15.12S1,24.63,1,16.28,7.77,1.15,16.12,1.15c1.56,0,3.06.24,4.47.67"
|
||||
/>
|
||||
<path
|
||||
in:draw
|
||||
style="stroke-width: 2px; fill: none; stroke: #5dd8c4; stroke-linecap: round;"
|
||||
d="M9.25,17.71c.5.33,6.75,6.69,6.75,6.69L30.41,1.5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{toast.text}
|
||||
</div>
|
||||
{/await}
|
||||
{:else if toast.type === 'Warning'}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() => removeToast(toast.id)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
removeToast(toast.id);
|
||||
}
|
||||
}}
|
||||
transition:slide|global
|
||||
class="flex items-center bg-pleasant-gray text-gray-500 shadow-lg capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full"
|
||||
>
|
||||
<!-- <img class="w-9 mr-6" draggable="false" src="/t-warning.svg" alt="checkmark warning" /> -->
|
||||
<svg class="w-11 mr-6" viewBox="0 0 36.26 32.41">
|
||||
<path
|
||||
in:draw|global
|
||||
style="stroke-width: 2px; fill: none; stroke: #d1c643; stroke-miterlimit: 10;"
|
||||
d="M15.74,2.68L1.64,27.11c-1.06,1.84.27,4.13,2.39,4.13h28.21c2.12,0,3.45-2.3,2.39-4.13L20.51,2.68c-1.06-1.84-3.71-1.84-4.77,0Z"
|
||||
/>
|
||||
<g>
|
||||
<line
|
||||
in:draw|global
|
||||
style="stroke-width: 3px; fill: none; stroke: #d1c643; stroke-miterlimit: 10;"
|
||||
x1="17.94"
|
||||
y1="19.84"
|
||||
x2="17.94"
|
||||
y2="7.67"
|
||||
/>
|
||||
<line
|
||||
in:draw|global
|
||||
style="stroke-width: 3px; fill: none; stroke: #d1c643; stroke-miterlimit: 10;"
|
||||
x1="17.94"
|
||||
y1="26.55"
|
||||
x2="17.94"
|
||||
y2="25.5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{toast.text}
|
||||
</div>
|
||||
{:else if toast.type === 'Error'}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() => removeToast(toast.id)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
removeToast(toast.id);
|
||||
}
|
||||
}}
|
||||
transition:slide|global
|
||||
class="flex items-center bg-pleasant-gray text-gray-500 shadow-lg capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full"
|
||||
>
|
||||
<!-- <img class="w-9 mr-6" draggable="false" src="/t-error.svg" alt="checkmark error" /> -->
|
||||
<svg class="w-11 mr-6" viewBox="0 0 32.98 32.98">
|
||||
<g>
|
||||
<line
|
||||
in:draw|global
|
||||
style="stroke-width: 3px; fill: none; stroke: #e06e94; stroke-linecap: round;"
|
||||
x1="10.51"
|
||||
y1="10.42"
|
||||
x2="22.68"
|
||||
y2="22.59"
|
||||
/>
|
||||
<line
|
||||
in:draw|global
|
||||
style="stroke-width: 3px; fill: none; stroke: #e06e94; stroke-linecap: round;"
|
||||
x1="10.51"
|
||||
y1="22.59"
|
||||
x2="22.29"
|
||||
y2="10.81"
|
||||
/>
|
||||
</g>
|
||||
<circle
|
||||
in:draw|global
|
||||
style="stroke-width: 2px; fill: none; stroke: #e06e94; stroke-linecap: round;"
|
||||
cx="16.59"
|
||||
cy="16.51"
|
||||
r="15.49"
|
||||
/>
|
||||
</svg>
|
||||
{toast.text}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() => removeToast(toast.id)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
removeToast(toast.id);
|
||||
}
|
||||
}}
|
||||
class="flex items-center bg-pleasant-gray text-gray-500 shadow-lg capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full"
|
||||
>
|
||||
<!-- <img class="w-9 mr-6" draggable="false" src="/t-info.svg" alt="checkmark info" /> -->
|
||||
<svg class="w-11 mr-6" viewBox="0 0 32.79 32.79">
|
||||
<circle
|
||||
in:draw|global
|
||||
style="stroke-width: 2px; fill: none; stroke: #86b1f2; stroke-linecap: round;"
|
||||
cx="16.39"
|
||||
cy="16.39"
|
||||
r="15.39"
|
||||
/>
|
||||
<g>
|
||||
<line
|
||||
in:draw|global
|
||||
style="stroke-width: 3px; fill: none; stroke: #86b1f2; stroke-linecap: round;"
|
||||
x1="16.39"
|
||||
y1="13.71"
|
||||
x2="16.39"
|
||||
y2="25.67"
|
||||
/>
|
||||
<line
|
||||
in:draw|global
|
||||
style="stroke-width: 3px; fill: none; stroke: #86b1f2; stroke-linecap: round;"
|
||||
x1="16.39"
|
||||
y1="7.12"
|
||||
x2="16.39"
|
||||
y2="8.15"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{toast.text}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
let showToolTip = false;
|
||||
let tooltipStyle = '';
|
||||
let tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 5);
|
||||
|
||||
function updateTooltipPosition(event) {
|
||||
// Position the tooltip near the element using clientX / clientY from the event
|
||||
tooltipStyle = `position: fixed; left: ${event.clientX + 10}px; top: ${event.clientY + 10}px;`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-full bg-gray-600 text-white w-4 h-4 z-30 text-center ml-2 relative cursor-pointer hover:bg-gray-500"
|
||||
role="tooltip"
|
||||
aria-describedby={tooltipId}
|
||||
on:mouseenter={(e) => {
|
||||
updateTooltipPosition(e);
|
||||
showToolTip = true;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
showToolTip = false;
|
||||
}}
|
||||
>
|
||||
<p class="text-xs">?</p>
|
||||
</div>
|
||||
|
||||
{#if showToolTip}
|
||||
<div
|
||||
id={tooltipId}
|
||||
class="bg-gray-600 text-white w-max mt-2 px-2 py-2 rounded-md shadow-xl z-40"
|
||||
style={tooltipStyle}
|
||||
>
|
||||
<p><slot /></p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,546 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||
import { BiMap } from '$lib/utils/maps';
|
||||
import { previewQR as generateQR } from '$lib/utils/qrPreview';
|
||||
/** @type {'domain'|'page'|'email'} */
|
||||
|
||||
export let contentType;
|
||||
export let value;
|
||||
export let baseURL = 'example.test';
|
||||
export let domainMap = new BiMap({});
|
||||
export let selectedDomain = '';
|
||||
let editor = null;
|
||||
let previewFrame = null;
|
||||
let previewRenderDelayID = null;
|
||||
let previewRenderDelay = 250;
|
||||
let isRenderingPreview = false;
|
||||
let previousQRCode = '';
|
||||
let previousQRHash = 0;
|
||||
|
||||
let isPreviewVisible = false;
|
||||
let externalFrameRef = null;
|
||||
let fileInputRef;
|
||||
|
||||
const apiTemplates = [
|
||||
{ label: 'Custom Field 1', text: '{{.CustomField1}}' },
|
||||
{ label: 'Custom Field 2', text: '{{.CustomField2}}' },
|
||||
{ label: 'Custom Field 3', text: '{{.CustomField3}}' },
|
||||
{ label: 'Custom Field 4', text: '{{.CustomField4}}' }
|
||||
];
|
||||
const emailTemplates = [
|
||||
{ label: 'Tracker', text: '{{.Tracker}}' },
|
||||
{ label: 'Tracking URL', text: '{{.TrackingURL}}' }
|
||||
];
|
||||
|
||||
const templates = {
|
||||
Email: [
|
||||
{ label: 'To', text: '{{.To}}' },
|
||||
{ label: 'From', text: '{{.From}}' }
|
||||
],
|
||||
Recipient: [
|
||||
{ label: 'FirstName', text: '{{.FirstName}}' },
|
||||
{ label: 'LastName', text: '{{.LastName}}' },
|
||||
{ label: 'Email', text: '{{.Email}}' },
|
||||
{ label: 'Phone', text: '{{.Phone}}' },
|
||||
{ label: 'Position', text: '{{.Position}}' },
|
||||
{ label: 'Department', text: '{{.Department}}' },
|
||||
{ label: 'City', text: '{{.City}}' },
|
||||
{ label: 'Country', text: '{{.Country}}' },
|
||||
{ label: 'Misc', text: '{{.Misc}}' }
|
||||
],
|
||||
'URLs & Tracking': [
|
||||
{ label: 'Base URL', text: '{{.BaseURL}}' },
|
||||
{ label: 'URL', text: '{{.URL}}' }
|
||||
],
|
||||
Functions: [
|
||||
{ label: 'URL as QR HTML', text: '{{qr .URL 4}}' },
|
||||
{ label: 'URL escape', text: '{{urlEscape "content" }}' },
|
||||
{ label: 'Random alphanumeric', text: '{{randAlpha 8}}' },
|
||||
{ label: 'Random number', text: '{{randInt 1 4}}' }
|
||||
]
|
||||
};
|
||||
|
||||
switch (contentType) {
|
||||
case 'domain': {
|
||||
delete templates['Email'];
|
||||
delete templates['Recipient'];
|
||||
delete templates['URLs & Tracking'];
|
||||
break;
|
||||
}
|
||||
case 'email': {
|
||||
templates['URLs & Tracking'] = [...templates['URLs & Tracking'], ...emailTemplates];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const insertTemplate = (text) => {
|
||||
if (editor) {
|
||||
const selection = editor.getSelection();
|
||||
editor.executeEdits('template-insert', [
|
||||
{
|
||||
range: selection,
|
||||
text: text
|
||||
}
|
||||
]);
|
||||
editor.focus();
|
||||
// updatePreview();
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
$: {
|
||||
|
||||
if (previewFrame && isPreviewVisible && !isRenderingPreview) {
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
onMount(() => {
|
||||
document.body.classList.add('overflow-hidden');
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: function (_, label) {
|
||||
if (label === 'html') {
|
||||
return new htmlWorker();
|
||||
}
|
||||
return new editorWorker();
|
||||
}
|
||||
};
|
||||
editor = monaco.editor.create(document.getElementById('monaco-editor'), {
|
||||
value: value,
|
||||
language: 'html',
|
||||
theme: 'vs-dark',
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
}
|
||||
});
|
||||
|
||||
editor.getModel().onDidChangeContent((e) => {
|
||||
if (previewRenderDelayID) {
|
||||
clearTimeout(previewRenderDelayID);
|
||||
previewRenderDelayID = null;
|
||||
}
|
||||
previewRenderDelayID = setTimeout(() => {
|
||||
updatePreview();
|
||||
}, previewRenderDelay);
|
||||
});
|
||||
updatePreview();
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
if (editor) {
|
||||
editor.dispose();
|
||||
monaco.editor.getModels().forEach((model) => model.dispose());
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const selectPreviewDomain = () => {
|
||||
baseURL = selectedDomain ? selectedDomain : baseURL;
|
||||
updatePreview();
|
||||
};
|
||||
|
||||
const updatePreview = async () => {
|
||||
if (isRenderingPreview) {
|
||||
return;
|
||||
}
|
||||
const v = editor.getValue() ?? value;
|
||||
value = v;
|
||||
const content = await replaceTemplateVariables(v);
|
||||
if (previewFrame) {
|
||||
const blob = new Blob([content], { type: 'text/html' });
|
||||
URL.revokeObjectURL(previewFrame.src);
|
||||
const url = URL.createObjectURL(blob);
|
||||
previewFrame.src = url;
|
||||
}
|
||||
if (externalFrameRef) {
|
||||
const blob = new Blob([createEmbed(content)], { type: 'text/html' });
|
||||
externalFrameRef.location.replace(URL.createObjectURL(blob));
|
||||
}
|
||||
isRenderingPreview = false;
|
||||
};
|
||||
|
||||
const replaceTemplateVariables = async (text) => {
|
||||
let param = '?id=905f286e-486b-434b-8ecc-d82456a07f7b';
|
||||
let _baseURL = `https://${baseURL}`;
|
||||
let _url = `https://${baseURL}${param}`;
|
||||
let _qrURL = _url;
|
||||
|
||||
if (text.includes('{{qr')) {
|
||||
const r = /{{qr\s+(.+?)\s+(\d+)}}/g;
|
||||
const rr = r.exec(text);
|
||||
let dotSize = 4;
|
||||
if (rr && rr[1] && rr[1] !== '.URL') {
|
||||
_qrURL = rr[1];
|
||||
}
|
||||
if (rr && rr[2]) {
|
||||
dotSize = Number(rr[2]);
|
||||
}
|
||||
const qrHash = `${_qrURL}${dotSize}`
|
||||
.split('')
|
||||
.reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0); // TODO add credits for hashing func
|
||||
if (previousQRHash === qrHash) {
|
||||
text = text.replace(r, (match, urlVar, size) => {
|
||||
return previousQRCode;
|
||||
});
|
||||
} else {
|
||||
const qr = await generateQR(_qrURL, dotSize);
|
||||
previousQRHash = qrHash;
|
||||
previousQRCode = qr;
|
||||
text = text.replace(r, (match, urlVar, size) => {
|
||||
return qr;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('{{urlEscape')) {
|
||||
const r = /{{urlEscape\s+(.+?)}}/g;
|
||||
text = text.replace(r, (match, v) => {
|
||||
return 'URL_ENCODED_TEXT';
|
||||
});
|
||||
}
|
||||
if (text.includes('{{randInt')) {
|
||||
const r = /{{randInt\s+(\d+)\s+(\d+)}}/g;
|
||||
text = text.replace(r, (match, min, max) => {
|
||||
min = parseInt(min, 10);
|
||||
max = parseInt(max, 10);
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
});
|
||||
}
|
||||
if (text.includes('{{randAlpha')) {
|
||||
const r = /{{randAlpha\s+(\d+)}}/g;
|
||||
text = text.replace(r, (match, length) => {
|
||||
const alphaChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
length = parseInt(length, 10);
|
||||
if (length > 32) {
|
||||
return 'ERROR: length must be less than 32';
|
||||
}
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += alphaChars.charAt(Math.floor(Math.random() * alphaChars.length));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
switch (contentType) {
|
||||
case 'domain':
|
||||
return text.replaceAll('{{.BaseURL}}', _baseURL);
|
||||
case 'page':
|
||||
return text
|
||||
.replaceAll('{{.FirstName}}', 'Alice')
|
||||
.replaceAll('{{.LastName}}', 'Andersen')
|
||||
.replaceAll('{{.Email}}', 'alice@worldcorp.test')
|
||||
.replaceAll('{{.To}}', 'Alice <alice@worldcorp.test>')
|
||||
.replaceAll('{{.Phone}}', '+45 13374242')
|
||||
.replaceAll('{{.ExtraIdentifier}}', 'Al1C5')
|
||||
.replaceAll('{{.Position}}', 'Head of operations')
|
||||
.replaceAll('{{.Department}}', 'Research and Development')
|
||||
.replaceAll('{{.City}}', 'Odense')
|
||||
.replaceAll('{{.Country}}', 'Denmark')
|
||||
.replaceAll('{{.Misc}}', 'Pasta')
|
||||
.replaceAll('{{.Tracker}}', '')
|
||||
.replaceAll('{{.TrackerURL}}', '')
|
||||
.replaceAll('{{.From}}', '')
|
||||
.replaceAll('{{.BaseURL}}', _baseURL)
|
||||
.replaceAll('{{.URL}}', _url);
|
||||
case 'email':
|
||||
return text
|
||||
.replaceAll('{{.FirstName}}', 'Alice')
|
||||
.replaceAll('{{.LastName}}', 'Andersen')
|
||||
.replaceAll('{{.Email}}', 'alice@worldcorp.test')
|
||||
.replaceAll('{{.To}}', 'Alice <alice@worldcorp.test>')
|
||||
.replaceAll('{{.Phone}}', '+45 13374242')
|
||||
.replaceAll('{{.ExtraIdentifier}}', 'Al1C5')
|
||||
.replaceAll('{{.Position}}', 'Head of operations')
|
||||
.replaceAll('{{.Department}}', 'Research and Development')
|
||||
.replaceAll('{{.City}}', 'Odense')
|
||||
.replaceAll('{{.Country}}', 'Denmark')
|
||||
.replaceAll('{{.Misc}}', 'Pasta')
|
||||
.replaceAll(
|
||||
'{{.Tracker}}',
|
||||
`<img src=\"${_baseURL}/wf/open?upn=905f286e-486b-434b-8ecc-d82456a07f7b\" alt=\"\" width=\"1\" height=\"1\" border=\"0\" style=\"height:1px !important;width:1px\" />`
|
||||
)
|
||||
.replaceAll(
|
||||
'{{.TrackerURL}}',
|
||||
`${_baseURL}/wf/open?upn=905f286e-486b-434b-8ecc-d82456a07f7b`
|
||||
)
|
||||
.replaceAll('{{.From}}', 'sender@new-order.test')
|
||||
.replaceAll('{{.BaseURL}}', _baseURL)
|
||||
.replaceAll('{{.URL}}', _url);
|
||||
}
|
||||
};
|
||||
|
||||
const onSetFile = (event) => {
|
||||
const file = event.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
value = e.target.result.toString();
|
||||
editor.getModel().setValue(value);
|
||||
updatePreview();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const createEmbed = (content) => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<style>
|
||||
*, body, iframe {margin: 0; padding: 0; border: 0; height: 100%; width: 100%;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
sandbox="allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock"
|
||||
src="data:text/html;base64,${btoa(content)}"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const openFullPagePreview = async (e) => {
|
||||
e.preventDefault();
|
||||
const v = editor.getValue();
|
||||
value = v;
|
||||
const content = await replaceTemplateVariables(v);
|
||||
const blob = new Blob([createEmbed(content)], { type: 'text/html' });
|
||||
let url = URL.createObjectURL(blob);
|
||||
externalFrameRef = window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (fileInputRef) {
|
||||
fileInputRef.click();
|
||||
}
|
||||
};
|
||||
|
||||
let isDetailsVisible = $$slots.default;
|
||||
</script>
|
||||
|
||||
<div class="w-80vw z-[9000] col-start-1 col-end-4 flex flex-col">
|
||||
<div class="">
|
||||
<div class="mt-4 flex items-center flex-wrap">
|
||||
<!-- mode tabs -->
|
||||
<div class="flex">
|
||||
{#if $$slots.default}
|
||||
<button
|
||||
on:click={() => {
|
||||
isDetailsVisible = true;
|
||||
}}
|
||||
type="button"
|
||||
class="h-8 border-2 rounded-md w-36 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 mb-2"
|
||||
class:font-bold={isDetailsVisible}
|
||||
class:bg-cta-blue={isDetailsVisible}
|
||||
class:text-white={isDetailsVisible}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path fill-rule="evenodd" d="M7 7h6v2H7V7zm0 4h6v2H7v-2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Details</span>
|
||||
</button>
|
||||
<button
|
||||
on:click={() => {
|
||||
isDetailsVisible = false;
|
||||
}}
|
||||
type="button"
|
||||
class="h-8 border-2 rounded-md w-36 text-center cursor-pointer hover:opacity-80 ml-1 flex items-center justify-center gap-2"
|
||||
class:font-bold={!isDetailsVisible}
|
||||
class:bg-cta-blue={!isDetailsVisible}
|
||||
class:text-white={!isDetailsVisible}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Editor</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- editor controls - custom markup that matches the design -->
|
||||
{#if !isDetailsVisible}
|
||||
<div
|
||||
class="flex items-center ml-0 xl:ml-4 flex-wrap gap-2 mb-2"
|
||||
class:ml-4={$$slots.default}
|
||||
class:mb-4={!$$slots.default}
|
||||
>
|
||||
<!-- custom file upload button -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={triggerFileInput}
|
||||
class="h-8 border-2 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Load File</span>
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
type="file"
|
||||
on:change={onSetFile}
|
||||
accept=".html,.htm,.txt"
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<!-- custom preview toggle button -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
isPreviewVisible = !isPreviewVisible;
|
||||
if (isPreviewVisible) {
|
||||
updatePreview();
|
||||
}
|
||||
}}
|
||||
class="h-8 border-2 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2"
|
||||
class:font-bold={isPreviewVisible}
|
||||
class:bg-cta-blue={isPreviewVisible}
|
||||
class:text-white={isPreviewVisible}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
|
||||
<!-- template selector -->
|
||||
<select
|
||||
class="h-8 border-2 rounded-md px-3 bg-white text-black cursor-pointer"
|
||||
on:change={(e) => {
|
||||
const t = /** @type {HTMLSelectElement} */ (e.target);
|
||||
if (t.value) {
|
||||
insertTemplate(t.value);
|
||||
t.value = ''; // reset selection
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option class="" value="">Templates...</option>
|
||||
{#each Object.entries(templates) as [group, items]}
|
||||
<optgroup label={group}>
|
||||
{#each items as item}
|
||||
<option value={item.text}>{item.label}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- domain selector if available -->
|
||||
{#if domainMap.values().length}
|
||||
<select
|
||||
id="domain-select"
|
||||
bind:value={selectedDomain}
|
||||
on:change={selectPreviewDomain}
|
||||
class="h-8 border-2 rounded-md px-3 bg-white text-black cursor-pointer"
|
||||
>
|
||||
<option value="">Select preview domain...</option>
|
||||
{#each domainMap.values() as domain}
|
||||
<option value={domain}>{domain}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<!-- open in new window button -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={openFullPagePreview}
|
||||
class="h-8 border-2 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V8.414l-4-4H4zm.5 2a.5.5 0 00-.5.5v7a.5.5 0 00.5.5h11a.5.5 0 00.5-.5v-7a.5.5 0 00-.5-.5h-11z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M8 6h2v2H8V6zM6 8h2v2H6V8zM8 10h2v2H8v-2zM6 12h2v2H6v-2zM10 8h2v2h-2V8zM12 6h2v2h-2V6zM10 12h2v2h-2v-2zM12 10h2v2h-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>New Window</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- details -->
|
||||
{#if $$slots.default}
|
||||
<div
|
||||
class="flex flex-col lg:flex-row lg:items-center h-auto w-full justify-between mb-4"
|
||||
class:lg:h-28={isDetailsVisible}
|
||||
>
|
||||
{#if isDetailsVisible}
|
||||
<slot />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex h-full">
|
||||
<div
|
||||
class="flex flex-col border-2 border-black {!isPreviewVisible ? 'w-80vw' : 'w-1/2'}"
|
||||
class:h-55vh={isDetailsVisible}
|
||||
class:h-67vh={!isDetailsVisible}
|
||||
>
|
||||
<div id="monaco-editor" class="h-full" />
|
||||
</div>
|
||||
<div class="bg-cta-blue cursor-move w-1" class:hidden={!isPreviewVisible}> </div>
|
||||
{#if isPreviewVisible}
|
||||
<div class="w-1/2 border-2 border-black">
|
||||
<iframe
|
||||
bind:this={previewFrame}
|
||||
sandbox="allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock"
|
||||
title="preview"
|
||||
class="h-full w-full"
|
||||
style="color-scheme: normal"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script>
|
||||
import { menu } from '$lib/consts/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { scrollBarClassesVertical } from '$lib/utils/scrollbar';
|
||||
import { shouldHideMenuItem } from '$lib/utils/common';
|
||||
|
||||
let isExpanded = false;
|
||||
|
||||
const icons = {
|
||||
dashboard: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>`,
|
||||
|
||||
campaigns_overview: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5h6c2 0 4 2 4 4v4c0 3-3 5-5 5 1.5-1.5 1.5-3 1.5-3" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.5 15l-2 2" />
|
||||
<circle cx="14" cy="5" r="1" fill="currentColor" />
|
||||
</svg>`,
|
||||
campaign_templates: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>`,
|
||||
|
||||
ip_filters: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
webhooks: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
|
||||
</svg>`,
|
||||
|
||||
recipients_overview: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>`,
|
||||
|
||||
recipient_groups: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
|
||||
</svg>`,
|
||||
|
||||
domains_overview: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>`,
|
||||
|
||||
pages: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>`,
|
||||
|
||||
assets: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>`,
|
||||
|
||||
emails_overview: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>`,
|
||||
|
||||
attachments: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>`,
|
||||
|
||||
smtp_configurations: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 1 0-2.636 6.364M16.5 12V8.25" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
api_senders: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
`
|
||||
};
|
||||
|
||||
const getIconForRoute = (route) => {
|
||||
const iconMap = {
|
||||
'/dashboard/': 'dashboard',
|
||||
'/campaign/': 'campaigns_overview',
|
||||
'/campaign-template/': 'campaign_templates',
|
||||
'/ip-filter/': 'ip_filters',
|
||||
'/webhook/': 'webhooks',
|
||||
'/recipient/': 'recipients_overview',
|
||||
'/recipient/group/': 'recipient_groups',
|
||||
'/domain/': 'domains_overview',
|
||||
'/page/': 'pages',
|
||||
'/asset/': 'assets',
|
||||
'/email/': 'emails_overview',
|
||||
'/attachment/': 'attachments',
|
||||
'/smtp-configuration/': 'smtp_configurations',
|
||||
'/api-sender/': 'api_senders'
|
||||
};
|
||||
|
||||
return icons[iconMap[route] || 'dashboard']; // fallback to dashboard if route not found
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex">
|
||||
<nav
|
||||
class="hidden lg:flex flex-col transition-all fixed top-16 z-10 bg-gradient-to-b from-pc-darkblue to-indigo-400 rounded-br-lg overflow-y-auto overflow-x-hidden min-h-0 max-h-[calc(100vh-4rem)] box-content border-r-[1px] border-pc-darkblue"
|
||||
class:w-40={isExpanded}
|
||||
class:w-12={!isExpanded}
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 bg-highlight-blue/20 border-b w-full border-blue-700/30 transform-none"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-center rounded-md hover:bg-blue-600/30 transition-colors group px-3 py-2"
|
||||
on:click={() => (isExpanded = !isExpanded)}
|
||||
>
|
||||
<svg
|
||||
class="text-blue-100 duration-200 w-6"
|
||||
class:rotate-180={!isExpanded}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Items -->
|
||||
<div
|
||||
class="flex flex-col py-4 flex-1 overflow-y-auto {scrollBarClassesVertical} [&::-webkit-scrollbar-track]:bg-cta-blue"
|
||||
>
|
||||
{#each menu as link}
|
||||
{#if link.type === 'submenu'}
|
||||
<div class="py-1 mt-4 first:mt-0">
|
||||
{#if isExpanded}
|
||||
<div class="px-3 py-2 text-xs font-semibold text-blue-100 uppercase tracking-wider">
|
||||
{link.label}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
{#each link.items as item, i (i)}
|
||||
<a
|
||||
class="flex items-center px-3 py-2 text-sm transition-all duration-150 relative group
|
||||
{$page.url.pathname === item.route
|
||||
? 'text-white font-medium bg-active-blue shadow-md'
|
||||
: 'text-blue-100 hover:shadow-md hover:bg-highlight-blue hover:text-white'}"
|
||||
class:hidden={shouldHideMenuItem(item.route)}
|
||||
draggable="false"
|
||||
href={item.route}
|
||||
title={item.label}
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
{@html getIconForRoute(item.route)}
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
<span class="ml-3 truncate">
|
||||
{#if i === 0}
|
||||
Overview
|
||||
{:else if item.singleLabel}
|
||||
{item.singleLabel}
|
||||
{:else}
|
||||
{item.label}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if $page.url.pathname === item.route}
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-white"></div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
class="flex items-center px-3 py-2 text-sm transition-all duration-150 relative group
|
||||
{$page.url.pathname === link.route
|
||||
? 'text-white font-medium bg-active-blue shadow-md'
|
||||
: 'text-blue-100 hover:text-white'}"
|
||||
draggable="false"
|
||||
href={link.route}
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
{@html icons[link.label]}
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
<span class="ml-3 truncate">{link.label}</span>
|
||||
{:else}
|
||||
<div
|
||||
class="absolute left-14 rounded bg-gray-900 text-white px-2 py-1 ml-6 text-sm
|
||||
invisible opacity-0 -translate-x-3 group-hover:visible group-hover:opacity-100 group-hover:translate-x-0
|
||||
transition-all duration-150 whitespace-nowrap z-50 shadow-lg"
|
||||
>
|
||||
{link.label}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $page.url.pathname === link.route}
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-white"></div>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script>
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import { onMount } from 'svelte';
|
||||
import Logo from './Logo.svelte';
|
||||
|
||||
const appState = AppStateService.instance;
|
||||
|
||||
export let isProfileMenuVisible = false;
|
||||
export let isMobileMenuVisible = false;
|
||||
export let toggleChangeCompanyModal;
|
||||
|
||||
let isUpdateAvailable = false;
|
||||
let isInstalled = false;
|
||||
let context = {
|
||||
current: '',
|
||||
companyName: ''
|
||||
};
|
||||
let username = '';
|
||||
|
||||
/*
|
||||
let updateURL = 'https://user.phishing.club/downloads';
|
||||
if (import.meta.env.DEV) {
|
||||
updateURL = 'https://localhost:8009/downloads';
|
||||
}
|
||||
*/
|
||||
|
||||
onMount(() => {
|
||||
const unsub = appState.subscribe((s) => {
|
||||
context = {
|
||||
current: s.context.current,
|
||||
companyName: s.context.companyName
|
||||
};
|
||||
isInstalled = s.installStatus === AppStateService.INSTALL.INSTALLED;
|
||||
const u = appState?.getUser();
|
||||
if (u.username) {
|
||||
username = u.username;
|
||||
}
|
||||
isUpdateAvailable = s.isUpdateAvailable;
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
});
|
||||
|
||||
// check if there is a context in local storage
|
||||
if (!context.companyName) {
|
||||
try {
|
||||
const ctxString = localStorage.getItem('context');
|
||||
const ctx = JSON.parse(ctxString);
|
||||
appState.setCompanyContext(ctx.id, ctx.name);
|
||||
} catch (e) {
|
||||
// do nothing failure to parse is expected if there is nothing
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(username) {
|
||||
return username
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
function profilePattern(username) {
|
||||
// Create consistent hash
|
||||
const hash = username.split('').reduce((acc, char) => {
|
||||
return char.charCodeAt(0) + ((acc << 5) - acc);
|
||||
}, 0);
|
||||
|
||||
// Generate base colors
|
||||
const hue = Math.abs(hash) % 360;
|
||||
const colors = {
|
||||
primary: `hsl(${hue}, 70%, 50%)`,
|
||||
secondary: `hsl(${(hue + 120) % 360}, 70%, 50%)`,
|
||||
accent: `hsl(${(hue + 240) % 360}, 70%, 50%)`
|
||||
};
|
||||
|
||||
// Generate pattern parameters
|
||||
const params = {
|
||||
rotation: hash % 360,
|
||||
segments: 6 + (hash % 6),
|
||||
waves: 3 + (hash % 4),
|
||||
amplitude: 5 + (hash % 10)
|
||||
};
|
||||
|
||||
return { colors, params };
|
||||
}
|
||||
|
||||
function generatePath(params, radius = 25, centerX = 25, centerY = 25) {
|
||||
let path = '';
|
||||
const points = [];
|
||||
const steps = 100;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const angle = (i / steps) * Math.PI * 2;
|
||||
const segment = (angle * params.segments) % (Math.PI * 2);
|
||||
const wave = Math.sin(angle * params.waves) * params.amplitude;
|
||||
const r = radius + wave;
|
||||
|
||||
const x = centerX + r * Math.cos(angle + params.rotation * (Math.PI / 180));
|
||||
const y = centerY + r * Math.sin(angle + params.rotation * (Math.PI / 180));
|
||||
|
||||
points.push({ x, y });
|
||||
path += i === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`;
|
||||
}
|
||||
|
||||
return { path, points };
|
||||
}
|
||||
|
||||
$: pattern = profilePattern(username);
|
||||
$: initials = getInitials(username || 'U');
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-20 col-span-12 h-16 bg-pc-darkblue flex justify-between items-center">
|
||||
<Logo />
|
||||
{#if isInstalled}
|
||||
<div class="hidden lg:flex flex-row items-center px-8 h-full justify-self-end">
|
||||
{#if context.current === AppStateService.CONTEXT.COMPANY}
|
||||
<p class="text-slate-300 uppercase font-bold text-lg mr-4">
|
||||
{context.companyName}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if isUpdateAvailable}
|
||||
<a
|
||||
class="flex items-center gap-2 mr-8 text-lg font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-md px-4 py-2 transition-all duration-300 transform hover:-translate-y-0.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2 active:scale-95 fixed bottom-4 right-2 shadow-md shadow-black"
|
||||
href={'/settings/update'}
|
||||
>
|
||||
<span class="">✨</span>
|
||||
<span>Update Available</span>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="relative ml-10 flex items-center">
|
||||
<button
|
||||
id="toggle-profile-menu"
|
||||
class="group flex items-center"
|
||||
on:click={() => (isProfileMenuVisible = !isProfileMenuVisible)}
|
||||
>
|
||||
<!-- Main Circle with Initials -->
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-cta-blue hover:bg-indigo-500 flex items-center justify-center text-white font-medium relative"
|
||||
>
|
||||
{initials}
|
||||
|
||||
<div class="absolute -bottom-1 -right-1 w-5 h-5"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown Indicator -->
|
||||
<svg
|
||||
class="w-4 h-4 ml-2 text-gray-300 transition-transform duration-200 group-hover:text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex w-14 mr-4 lg:hidden"
|
||||
on:click={() => (isMobileMenuVisible = !isMobileMenuVisible)}
|
||||
>
|
||||
<img class="" src="/mob-menu-button.svg" alt="toggle mobile menu" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button {
|
||||
filter: contrast(1.1) saturate(1.2);
|
||||
}
|
||||
button:hover {
|
||||
filter: contrast(1.2) saturate(1.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
</script>
|
||||
|
||||
<div
|
||||
on:click={() => goto('/dashboard/')}
|
||||
on:keydown={(e) => e.key === 'Enter' && goto('/dashboard/')}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="flex items-center w-40 sm:w-40 md:w-42 lg:w-56 justify-center py-4 my-6 rounded-md ml-4"
|
||||
>
|
||||
<img draggable="false" src="/logo-white.svg" alt="logo" />
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
export let href = '#';
|
||||
export let hidden = false;
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="py-2 px-4 text-white hover:bg-active-blue hover:text-white hover:rounded-md"
|
||||
class:bg-highlight-blue={$page.url.pathname === href}
|
||||
class:font-semibold={$page.url.pathname === href}
|
||||
class:rounded-md={$page.url.pathname === href}
|
||||
class:hidden
|
||||
{href}
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { menu, mobileTopMenu } from '$lib/consts/navigation';
|
||||
import MenuLink from './MenuLink.svelte';
|
||||
import { shouldHideMenuItem } from '$lib/utils/common';
|
||||
|
||||
export let visible = false;
|
||||
export let username = '';
|
||||
export let onClickLogout;
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed top-0 left-0 w-full h-full bg-pc-darkblue z-40 overflow-y-auto pb-4">
|
||||
<div class="flex justify-between h-16">
|
||||
<img class="w-40 sm:w-40 md:w-42 lg:w-56 ml-4" src="/logo-white.svg" alt="logo" />
|
||||
<button class="mr-4 w-14" on:click={() => (visible = !visible)}>
|
||||
<img class="w-3/4" src="/mob-menu-close.svg" alt="close mobile menu" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col px-4 py-4 rounded-b-xl">
|
||||
<div class="flex py-6 border-b-2 border-white mb-4">
|
||||
<!-- <div class="bg-slate-50 w-16 h-16 rounded-full" /> -->
|
||||
<div>
|
||||
<h1 class="font-bold text-3xl ml-6 text-white">{username ?? ''}</h1>
|
||||
<button
|
||||
on:click={onClickLogout}
|
||||
class="bg-cta-blue hover:bg-pc-lightblue uppercase font-bold ml-6 mt-2 py text-white rounded-md"
|
||||
>
|
||||
<p class="py px-8">Log Out</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col text-white">
|
||||
{#each mobileTopMenu as link}
|
||||
<a
|
||||
class="pl-5 py-2 hover:bg-cta-blue hover:text-white rounded-md"
|
||||
class:bg-gray-600={$page.url.pathname === link.route}
|
||||
class:hidden={shouldHideMenuItem(link.route)}
|
||||
on:click={() => (visible = !visible)}
|
||||
target={link.external ? '_blank' : '_self'}
|
||||
href={link.route}>{link.label}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col bg-cta-blue px-4 pt-4">
|
||||
{#each menu as link}
|
||||
{#if link.type === 'submenu'}
|
||||
<div class="text-white font-semibold text-xl">{link.label}</div>
|
||||
{#each link.items as item, i (i)}
|
||||
<MenuLink href={item.route} on:click={() => (visible = !visible)}>
|
||||
{#if i === 0}
|
||||
Overview
|
||||
{:else if item.singleLabel}
|
||||
{item.singleLabel}
|
||||
{:else}
|
||||
{item.label}
|
||||
{/if}
|
||||
</MenuLink>
|
||||
{/each}
|
||||
{:else}
|
||||
<MenuLink href={link.route}>{link.label}</MenuLink>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,64 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { topMenu } from '$lib/consts/navigation';
|
||||
import { shouldHideMenuItem } from '$lib/utils/common';
|
||||
export let logout;
|
||||
export let visible = false;
|
||||
|
||||
const handleClickOutsideNavigation = (event) => {
|
||||
const profileMenuElement = document.getElementById('profile-menu');
|
||||
const profileToggleElement = document.getElementById('toggle-profile-menu');
|
||||
const clickOutsideMenu = profileMenuElement && !profileMenuElement.contains(event.target);
|
||||
const clickOutsideToggleButton =
|
||||
profileToggleElement && !profileToggleElement.contains(event.target);
|
||||
if (clickOutsideMenu && clickOutsideToggleButton) {
|
||||
visible = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: {
|
||||
if (visible) {
|
||||
// add event listener to listen for a click outside the nav element
|
||||
document.addEventListener('click', handleClickOutsideNavigation);
|
||||
} else {
|
||||
// remove event listener
|
||||
document.removeEventListener('click', handleClickOutsideNavigation);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<nav
|
||||
id="profile-menu"
|
||||
class="lg:flex flex-col h-fit lg:col-start-10 lg:col-span-3 row-start-1 xl:col-start-11 xl:col-span-2 2xl:col-start-11 2xl:col-span-2 sticky top-20 z-30"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col bg-gradient-to-b from-cta-blue to-indigo-500 rounded-md"
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
{#each topMenu as item}
|
||||
<a
|
||||
class="pl-5 py-2 text-white last:rounded-md first:rounded-t-md"
|
||||
class:hover:shadow-md={$page.url.pathname !== item.route}
|
||||
class:hover:bg-highlight-blue={$page.url.pathname !== item.route}
|
||||
class:bg-active-blue={$page.url.pathname === item.route}
|
||||
class:shadow-md={$page.url.pathname === item.route}
|
||||
class:hidden={shouldHideMenuItem(item.route)}
|
||||
target={item.external ? '_blank' : '_self'}
|
||||
draggable="false"
|
||||
on:click={() => {
|
||||
visible = false;
|
||||
}}
|
||||
href={item.route}>{item.label}</a
|
||||
>
|
||||
{/each}
|
||||
<button
|
||||
on:click={logout}
|
||||
class="bg-white uppercase font-bold hover:bg-pc-lightblue py-2 mx-4 my-4 rounded-md"
|
||||
>
|
||||
<p class="text-cta-blue py px-8">Log Out</p>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
@@ -0,0 +1,157 @@
|
||||
<script>
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import Modal from '../Modal.svelte';
|
||||
import { fetchAllRows } from '$lib/utils/api-utils';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import { API } from '$lib/api/api';
|
||||
import { onMount } from 'svelte';
|
||||
import TextFieldSelect from '../TextFieldSelect.svelte';
|
||||
import { showIsLoading } from '$lib/store/loading';
|
||||
|
||||
// services
|
||||
const appState = AppStateService.instance;
|
||||
const api = API.instance;
|
||||
|
||||
// external
|
||||
export let visible = false;
|
||||
|
||||
// local
|
||||
let context = null;
|
||||
let selectedCompany = null;
|
||||
let inContext = false;
|
||||
let companies = [];
|
||||
let companyNameList = [];
|
||||
let isLoadingCompanies = false;
|
||||
|
||||
onMount(() => {
|
||||
const appStateUnsubscribe = appState.subscribe((s) => {
|
||||
// sync any changes to user and scope changes
|
||||
inContext = appState.isCompanyContext();
|
||||
context = {
|
||||
current: s.context.current,
|
||||
companyName: s.context.companyName
|
||||
};
|
||||
|
||||
if (!context.companyName) {
|
||||
try {
|
||||
const ctxString = localStorage.getItem('context');
|
||||
const ctx = JSON.parse(ctxString);
|
||||
appState.setCompanyContext(ctx.id, ctx.name);
|
||||
} catch (e) {
|
||||
// do nothing failure to parse is expected if there is nothing
|
||||
}
|
||||
}
|
||||
});
|
||||
// refreshContexts();
|
||||
window.addEventListener('storage', handleStorageChanges);
|
||||
|
||||
return () => {
|
||||
appStateUnsubscribe();
|
||||
window.removeEventListener('storage', handleStorageChanges);
|
||||
};
|
||||
});
|
||||
|
||||
// force refresh in tabs/windows not in focus when changing context
|
||||
const handleStorageChanges = (e) => {
|
||||
if (e.key === 'context' && document.hidden) {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const refreshContexts = async () => {
|
||||
isLoadingCompanies = true;
|
||||
try {
|
||||
companies = await getContexts();
|
||||
companyNameList = companies.map((c) => c.name);
|
||||
} catch (e) {
|
||||
addToast('Failed to load contexts', 'Error');
|
||||
console.error('Failed to load contexts', e);
|
||||
} finally {
|
||||
isLoadingCompanies = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getContexts = async () => {
|
||||
if (!appState.isLoggedIn()) {
|
||||
return [];
|
||||
}
|
||||
let data = [];
|
||||
try {
|
||||
return await fetchAllRows((options) => {
|
||||
return api.company.getAll(options);
|
||||
});
|
||||
} catch (e) {
|
||||
addToast('Failed to load contexts', 'Error');
|
||||
console.error('failed to get companies for context', e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const onClickSwitch = async () => {
|
||||
showIsLoading();
|
||||
visible = false;
|
||||
const c = companies.find((c) => c.name === selectedCompany);
|
||||
appState.setCompanyContext(c.id, c.name);
|
||||
localStorage.setItem('context', JSON.stringify({ id: c.id, name: c.name }));
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const onClickSwitchToAdministratorContext = async () => {
|
||||
showIsLoading();
|
||||
visible = false;
|
||||
appState.clearContext();
|
||||
localStorage.setItem('context', '');
|
||||
location.reload();
|
||||
};
|
||||
|
||||
$: {
|
||||
if (visible) {
|
||||
refreshContexts();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal headerText={'Change company'} bind:visible>
|
||||
<main class="flex flex-col h-full">
|
||||
<!-- Company Selection Section -->
|
||||
<div class="flex-grow p-6">
|
||||
<!--
|
||||
<h2 class="text-lg mb-4 text-pc-darkblue">
|
||||
Viewing as: {context?.companyName ?? 'Shared'}
|
||||
</h2>
|
||||
-->
|
||||
{#if isLoadingCompanies}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-gray-500">Loading companies...</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TextFieldSelect id={'context'} bind:value={selectedCompany} options={companyNameList}>
|
||||
Company
|
||||
</TextFieldSelect>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Button Section -->
|
||||
<div class="border-t p-6 mt-36 flex flex-wrap gap-4 justify-end">
|
||||
{#if inContext}
|
||||
<button
|
||||
type="button"
|
||||
class="bg-slate-400 hover:bg-slate-300 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isLoadingCompanies}
|
||||
on:click={onClickSwitchToAdministratorContext}
|
||||
>
|
||||
Shared view
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-cta-blue hover:bg-blue-700 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isLoadingCompanies || !selectedCompany}
|
||||
on:click={onClickSwitch}
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</Modal>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
import Alert from '../Alert.svelte';
|
||||
import TextField from '../TextField.svelte';
|
||||
|
||||
export let onClick;
|
||||
export let name;
|
||||
export let list = [];
|
||||
export let title = 'Delete';
|
||||
export let isVisible = false;
|
||||
export let confirm = false;
|
||||
export let confirmWord = 'confirm';
|
||||
export let permanent = true;
|
||||
|
||||
let confirmText = '';
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
if (confirm && confirmWord !== confirmText) {
|
||||
throw `Type '${confirmWord}' to delete`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await onClick();
|
||||
if (res?.success) {
|
||||
isVisible = false;
|
||||
return res;
|
||||
}
|
||||
throw res?.error || 'Failed to delete';
|
||||
} catch (e) {
|
||||
console.error('failed to delete record', e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
$: {
|
||||
if (!isVisible) {
|
||||
confirmText = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isVisible}
|
||||
<Alert headline={title} onConfirm={onConfirmDelete} bind:visible={isVisible}>
|
||||
<div class="space-y-6">
|
||||
<!-- Main Delete Warning -->
|
||||
<div>
|
||||
<!--
|
||||
<h3 class="text-lg font-medium text-gray-900">Delete {type}</h3>
|
||||
-->
|
||||
<p class="mt-2 text-gray-600">
|
||||
Are you sure you want to delete
|
||||
{#if name?.length > 30}
|
||||
<br />
|
||||
{/if}
|
||||
<span class="font-medium text-gray-900">"{name}"</span>?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Impact Section -->
|
||||
{#if list.length}
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="font-medium text-gray-900 mb-3">Side effects:</p>
|
||||
<ul class="space-y-2 ml-4 list-disc text-gray-600">
|
||||
{#each list as line}
|
||||
<li>{line}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirmation Input -->
|
||||
{#if confirm}
|
||||
<div class="mt-2">
|
||||
<TextField
|
||||
bind:value={confirmText}
|
||||
required
|
||||
id="confirmDelete"
|
||||
on:keydown={(e) => {
|
||||
const ele = /** @type {HTMLInputElement} */ (e.target);
|
||||
ele.setCustomValidity('');
|
||||
}}
|
||||
>
|
||||
Type '{confirmWord}' to confirm deletion
|
||||
</TextField>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if permanent}
|
||||
<p class="text-red-700 font-medium">This action cannot be undone.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { onClickCopy } from '$lib/utils/common';
|
||||
|
||||
export let text;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="hover:bg-gray-100 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden"
|
||||
title={text}
|
||||
on:click={() => onClickCopy(text)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
/** @type {*} */
|
||||
export let page = null;
|
||||
export let plural;
|
||||
export let colspan = 100;
|
||||
</script>
|
||||
|
||||
<tr class="text-center bg-pleasant-gray">
|
||||
<td class="p-24" {colspan}>
|
||||
{#if page === 1}
|
||||
<p class="text-lg text-gray-600">No {plural} found</p>
|
||||
{:else}
|
||||
<p class="text-lg text-gray-600">No more results found</p>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { toEvent } from '$lib/utils/events';
|
||||
import { onMount } from 'svelte';
|
||||
export let eventName;
|
||||
|
||||
let event = { name: '', priority: null, color: null };
|
||||
|
||||
onMount(() => {
|
||||
event = toEvent(eventName);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center text-ellipsis">
|
||||
<div class="w-4 h-4 {event.color} mr-2 rounded-sm"></div>
|
||||
{event.name}
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
import EmptyTableResult from './EmptyTableResult.svelte';
|
||||
import Pagination from '../Pagination.svelte';
|
||||
import TableHeader from './TableHeader.svelte';
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
import Select from '$lib/components/Select.svelte';
|
||||
import { afterUpdate, onMount } from 'svelte';
|
||||
import TableCell from './TableCell.svelte';
|
||||
import TableRow from './TableRow.svelte';
|
||||
import TableCellEmpty from './TableCellEmpty.svelte';
|
||||
import TableCellAction from './TableCellAction.svelte';
|
||||
import GhostText from '../GhostText.svelte';
|
||||
import { scrollBarClassesHorizontal } from '$lib/utils/scrollbar';
|
||||
|
||||
/** @type {Array<string>|*} */
|
||||
export let columns = [];
|
||||
/** @type {boolean} */
|
||||
export let hasData;
|
||||
/** @type {string} */
|
||||
export let plural;
|
||||
/** @type {*} */
|
||||
export let pagination = null;
|
||||
export let sortable = [];
|
||||
// key value map that should be switched on when selecting a sort by
|
||||
export let hasActions = true;
|
||||
export let isGhost = false;
|
||||
|
||||
let tableWrapper = null;
|
||||
let columnsLength = columns.length;
|
||||
|
||||
let rowsLength = 0;
|
||||
|
||||
afterUpdate(() => {
|
||||
const elements = tableWrapper?.querySelectorAll('table > tr.table-row');
|
||||
rowsLength = elements?.length ?? 0;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!pagination && sortable?.length) {
|
||||
console.warn('You need to pass a pagination object to make the column sortable');
|
||||
}
|
||||
columnsLength = columns.length + 2;
|
||||
});
|
||||
|
||||
let currentPage = pagination && pagination.currentPage;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="">
|
||||
<div class="flex justify-between items-center pb-4">
|
||||
{#if pagination}
|
||||
<Select {pagination}></Select>
|
||||
<Search {pagination}></Search>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
bind:this={tableWrapper}
|
||||
class="
|
||||
border-2 rounded-md px-4 py-4 overflow-x-auto
|
||||
{scrollBarClassesHorizontal}"
|
||||
>
|
||||
<table class="w-full table-fixed" class:animate-pulse={isGhost}>
|
||||
<TableHeader {isGhost} {columns} {sortable} {hasActions} {pagination} />
|
||||
{#if !hasData && !isGhost}
|
||||
<EmptyTableResult page={currentPage} {plural} colspan={columnsLength} />
|
||||
{/if}
|
||||
{#if !isGhost}
|
||||
<slot />
|
||||
{:else}
|
||||
{#each Array(rowsLength || pagination?.perPage) as _, row}
|
||||
<TableRow>
|
||||
{#each columns as column}
|
||||
<TableCell>
|
||||
<GhostText />
|
||||
</TableCell>
|
||||
{/each}
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<GhostText square center />
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</table>
|
||||
</div>
|
||||
{#if pagination}
|
||||
<Pagination paginator={pagination} />
|
||||
{:else}
|
||||
<div class="flex items-center mb-8 mt-4" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- TableCell.svelte -->
|
||||
<script>
|
||||
import Datetime from '../Datetime.svelte';
|
||||
import RelativeTime from '../RelativeTime.svelte';
|
||||
|
||||
/** @type {string} */
|
||||
export let value = '';
|
||||
export let isDate = false;
|
||||
export let hideHours = false;
|
||||
export let isRelative = false; // new prop to toggle relative time
|
||||
</script>
|
||||
|
||||
<td
|
||||
class={`pl-4 font-regular text-slate-600 text-ellipsis whitespace-nowrap overflow-hidden pr-4`}
|
||||
title={isDate ? '' : value}
|
||||
>
|
||||
{#if value}
|
||||
{#if isDate}
|
||||
{#if isRelative}
|
||||
<RelativeTime {value} />
|
||||
{:else}
|
||||
<Datetime {value} {hideHours} />
|
||||
{/if}
|
||||
{:else}
|
||||
{value ?? ''}
|
||||
{/if}
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
</td>
|
||||
@@ -0,0 +1,5 @@
|
||||
<td class="pl-4 w-48 text-center border: hidden;">
|
||||
<p class="font-regular text-slate-600">
|
||||
<slot />
|
||||
</p>
|
||||
</td>
|
||||
@@ -0,0 +1,5 @@
|
||||
<td class="pl-4 w-40 text-center border: hidden;">
|
||||
<p class="font-regular text-slate-600 flex justify-center">
|
||||
<slot />
|
||||
</p>
|
||||
</td>
|
||||
@@ -0,0 +1,3 @@
|
||||
<td class="pl-4 w-4 border: hidden;">
|
||||
|
||||
</td>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import TableDropDownButton from './TableDropDownButton.svelte';
|
||||
|
||||
export let disabled = false;
|
||||
export let title = 'Copy';
|
||||
|
||||
if (disabled && !title) {
|
||||
title = 'Disabled';
|
||||
}
|
||||
</script>
|
||||
|
||||
<TableDropDownButton name={'Copy'} {title} on:click {disabled} />
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
export let disabled = false;
|
||||
export let title = '';
|
||||
export let name = 'Delete';
|
||||
if (disabled && !title) {
|
||||
title = 'Delete is disabled';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if disabled}
|
||||
<button class="px py text-slate-300 cursor-not-allowed" {disabled} {title}>
|
||||
<p class="ml-2 text-left">{name}</p>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="px py-1 text-slate-600 hover:bg-red-400 hover:text-white cursor-pointer"
|
||||
on:click
|
||||
{title}
|
||||
>
|
||||
<p class="ml-2 text-left">{name}</p>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
export let disabled = false;
|
||||
export let title = '';
|
||||
export let name;
|
||||
if (disabled && !title) {
|
||||
title = 'Disabled';
|
||||
}
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.target.click();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if disabled}
|
||||
<button class="px py-1 text-slate-300 cursor-not-allowed" {disabled} {title}>
|
||||
<p class="ml-2 text-left">{name}</p>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="px py-1 text-slate-600 hover:bg-highlight-blue hover:text-white cursor-pointer"
|
||||
on:click
|
||||
on:keydown={handleKeydown}
|
||||
{title}
|
||||
>
|
||||
<p class="ml-2 text-left">{name}</p>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,133 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { activeFormElement } from '$lib/store/activeFormElement';
|
||||
import { scrollBarClassesVertical } from '$lib/utils/scrollbar';
|
||||
|
||||
let isMenuVisible = false;
|
||||
let menuX = 0;
|
||||
let menuY = 0;
|
||||
let menuRef = null;
|
||||
let buttonRef = null;
|
||||
|
||||
// generate unique ID for this dropdown instance
|
||||
const dropdownId = Symbol();
|
||||
|
||||
// subscribe to active dropdown store
|
||||
const unsubscribe = activeFormElement.subscribe((activeId) => {
|
||||
isMenuVisible = activeId === dropdownId;
|
||||
});
|
||||
|
||||
const toggle = (e) => {
|
||||
if (isMenuVisible) {
|
||||
activeFormElement.set(null);
|
||||
} else {
|
||||
document.addEventListener('click', handleClickWhenVisible);
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
activeFormElement.set(dropdownId); // set this as active, closing others
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const menuHeight = 128; // max-h-32 in pixels
|
||||
const buffer = 20; // extra space to ensure some padding from viewport edges
|
||||
|
||||
let clickViewportY, pageX, pageY;
|
||||
|
||||
// Handle both mouse and keyboard events
|
||||
if (e.clientY !== undefined && e.pageX !== undefined) {
|
||||
// Mouse event
|
||||
clickViewportY = e.clientY;
|
||||
pageX = e.pageX;
|
||||
pageY = e.pageY;
|
||||
} else {
|
||||
// Keyboard event - use button position
|
||||
const buttonRect = buttonRef.getBoundingClientRect();
|
||||
clickViewportY = buttonRect.top;
|
||||
pageX = buttonRect.left + window.scrollX;
|
||||
pageY = buttonRect.top + window.scrollY;
|
||||
}
|
||||
|
||||
// is the room enough to show the box
|
||||
const shouldShowAbove = viewportHeight - clickViewportY < menuHeight + buffer;
|
||||
|
||||
// find position
|
||||
menuX = pageX - 192;
|
||||
menuY = shouldShowAbove
|
||||
? pageY - menuHeight // Position above click
|
||||
: pageY; // Position below click
|
||||
|
||||
menuRef.style = `left: ${menuX}px; top: ${menuY}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickWhenVisible = (event) => {
|
||||
if (isMenuVisible && menuRef && buttonRef) {
|
||||
activeFormElement.set(null);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.removeEventListener('click', handleClickWhenVisible);
|
||||
};
|
||||
|
||||
const handleGlobalKeydown = (event) => {
|
||||
if (event.key === 'Escape' && isMenuVisible) {
|
||||
activeFormElement.set(null);
|
||||
document.removeEventListener('keydown', handleGlobalKeydown);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(e);
|
||||
} else if (e.key === 'Escape' && isMenuVisible) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activeFormElement.set(null);
|
||||
}
|
||||
};
|
||||
|
||||
const _onDestroy = () => {
|
||||
document.removeEventListener('click', handleClickWhenVisible);
|
||||
document.removeEventListener('keydown', handleGlobalKeydown);
|
||||
unsubscribe();
|
||||
// Clear active dropdown if this one was active
|
||||
activeFormElement.update((current) => (current === dropdownId ? null : current));
|
||||
};
|
||||
|
||||
onDestroy(_onDestroy);
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
_onDestroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="">
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
class="py-2 px-2"
|
||||
on:click|stopPropagation|preventDefault={toggle}
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
<svg width="3.335557" height="16.465519" viewBox="0 0 0.88253281 4.3565019">
|
||||
<g transform="translate(-892.25669,88.863024)">
|
||||
<g transform="matrix(0,1.0139418,-1.0139418,0,802.48114,-807.2715)">
|
||||
<circle class="fill-cta-blue" cx="708.99603" cy="-88.976357" r="0.40846577" />
|
||||
<circle class="fill-cta-blue" cx="710.67859" cy="-88.976357" r="0.40846577" />
|
||||
<circle class="fill-cta-blue" cx="712.36115" cy="-88.976357" r="0.40846577" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
bind:this={menuRef}
|
||||
class="absolute bg-white drop-shadow-md z-20 max-h-32 w-48 rounded-md overflow-y-scroll {scrollBarClassesVertical}"
|
||||
class:hidden={!isMenuVisible}
|
||||
>
|
||||
<ul class="flex flex-col text-left">
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<thead class="table-header-group">
|
||||
<slot />
|
||||
</thead>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import GhostText from '../GhostText.svelte';
|
||||
|
||||
export let column;
|
||||
/** @type {*|null} */
|
||||
export let pagination = null;
|
||||
export let alignText = 'left';
|
||||
export let sortable = false;
|
||||
export let size = '';
|
||||
// last tells if it the last field column before the actions column
|
||||
export let last = false;
|
||||
export let fillRest = false;
|
||||
export let isGhost = false;
|
||||
export let title = '';
|
||||
|
||||
let sortBy = pagination?.sortBy;
|
||||
let sortOrder = pagination?.sortOrder;
|
||||
const setSortAndSortBy = () => {
|
||||
if (!sortable || !pagination) {
|
||||
return;
|
||||
}
|
||||
pagination.sort(column.toLowerCase(), sortOrder.toLowerCase());
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (pagination) {
|
||||
pagination.onChange(() => {
|
||||
sortBy = pagination.sortBy;
|
||||
sortOrder = pagination.sortOrder;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<th
|
||||
class="pl-4 bg-grayblue-light py-4 border-hidden first:rounded-tl-lg first:rounded-bl-lg last:rounded-tr-lg last:border-4 min-w-48"
|
||||
class:rounded-br-lg={last}
|
||||
class:rounded-tr-lg={last}
|
||||
class:w-48={size === 'small'}
|
||||
class:w-56={size === 'medium'}
|
||||
class:w-80={size === 'large'}
|
||||
class:w-full={fillRest}
|
||||
>
|
||||
<button
|
||||
class="flex group cursor-pointer"
|
||||
class:pointer-events-none={!sortable}
|
||||
class:table-cell={alignText === 'center'}
|
||||
on:click|preventDefault={setSortAndSortBy}
|
||||
>
|
||||
<div class="w-full">
|
||||
<p class="font-bold text-slate-600 text-{alignText} flex">
|
||||
{#if !isGhost}
|
||||
{title.length ? title : column}
|
||||
{:else}
|
||||
<GhostText />
|
||||
{/if}
|
||||
{#if sortable && column.toLowerCase() === sortBy.toLowerCase() && !isGhost}
|
||||
<div
|
||||
class:bg-transparent={sortOrder === ''}
|
||||
class="flex justify-center items-center w-6 h-6 ml-2 rounded-md bg-cta-blue"
|
||||
>
|
||||
{#if sortOrder === 'asc'}
|
||||
<div>
|
||||
<img src="/arrow-up.svg" alt="arrow up" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if sortOrder === 'desc'}
|
||||
<div>
|
||||
<img src="/arrow-down.svg" alt="arrow down" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if sortOrder === ''}
|
||||
<p class="text-white"></p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
@@ -0,0 +1,5 @@
|
||||
<th class="bg-cta-light-blue w-20 py-4 border-hidden rounded-lg">
|
||||
<p class="font-bold text-slate-600 text-center">
|
||||
<slot />
|
||||
</p>
|
||||
</th>
|
||||
@@ -0,0 +1,5 @@
|
||||
<th class="pl-4 bg-grayblue-light w-48 py-4 border-hidden">
|
||||
<p class="font-bold text-slate-600 flex justify-center text-center">
|
||||
<slot />
|
||||
</p>
|
||||
</th>
|
||||
@@ -0,0 +1,3 @@
|
||||
<th class="pl-4 bg-white w-4 py-4 border-hidden">
|
||||
|
||||
</th>
|
||||
@@ -0,0 +1,5 @@
|
||||
<th class="pl-4 bg-grayblue-light w-48 py-4 border-hidden rounded-md">
|
||||
<p class="font-bold text-slate-600 text-left">
|
||||
<slot />
|
||||
</p>
|
||||
</th>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import TableHead from './TableHead.svelte';
|
||||
import TableHeadCell from './TableHeadCell.svelte';
|
||||
import TableHeadCellAction from './TableHeadCellAction.svelte';
|
||||
import TableHeadCellEmpty from './TableHeadCellEmpty.svelte';
|
||||
import TableRow from './TableRow.svelte';
|
||||
import TableRowEmpty from './TableRowEmpty.svelte';
|
||||
import GhostText from '../GhostText.svelte';
|
||||
|
||||
export let columns = [];
|
||||
export let sortable = [];
|
||||
export let isGhost = false;
|
||||
export let hasActions = true;
|
||||
/** @type {*|null} */
|
||||
export let pagination = null;
|
||||
|
||||
$: sortableMap = {};
|
||||
|
||||
onMount(() => {
|
||||
sortable.forEach((column) => {
|
||||
sortableMap[column.toLowerCase()] = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{#each columns as column, i (i)}
|
||||
{#if typeof column === 'object'}
|
||||
<TableHeadCell
|
||||
{...column}
|
||||
{...columns.length === 1 ? { size: '' } : {}}
|
||||
{pagination}
|
||||
{isGhost}
|
||||
sortable={sortableMap[column.column.toLowerCase()]}
|
||||
last={i === columns.length - 1}
|
||||
fillRest={i === columns.length - 1 && !hasActions}
|
||||
/>
|
||||
{:else}
|
||||
<TableHeadCell
|
||||
{column}
|
||||
{pagination}
|
||||
sortable={sortableMap[column.toLowerCase()]}
|
||||
last={i === columns.length - 1}
|
||||
fillRest={i === columns.length - 1 && !hasActions}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if hasActions}
|
||||
<TableHeadCellEmpty />
|
||||
<TableHeadCellAction>
|
||||
{#if !isGhost}
|
||||
Actions
|
||||
{:else}
|
||||
<GhostText center />
|
||||
{/if}
|
||||
</TableHeadCellAction>
|
||||
{/if}
|
||||
</TableRow>
|
||||
<TableRowEmpty />
|
||||
</TableHead>
|
||||
@@ -0,0 +1,3 @@
|
||||
<tr class="table-row odd:bg-white even:bg-pleasant-gray h-16 hover:bg-cta-light-blue">
|
||||
<slot />
|
||||
</tr>
|
||||
@@ -0,0 +1,3 @@
|
||||
<tr class="table-row bg-white h-4">
|
||||
<slot />
|
||||
</tr>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import TableDropDownButton from './TableDropDownButton.svelte';
|
||||
|
||||
export let disabled = false;
|
||||
export let title = '';
|
||||
export let name = 'Update';
|
||||
if (disabled && !title) {
|
||||
title = 'Disabled';
|
||||
}
|
||||
</script>
|
||||
|
||||
<TableDropDownButton {name} {title} on:click {disabled} />
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import TableDropDownButton from './TableDropDownButton.svelte';
|
||||
|
||||
export let disabled = false;
|
||||
export let title = '';
|
||||
export let name = 'View';
|
||||
if (disabled && !title) {
|
||||
title = 'Disabled';
|
||||
}
|
||||
</script>
|
||||
|
||||
<TableDropDownButton {name} {title} on:click {disabled} />
|
||||
@@ -0,0 +1,137 @@
|
||||
export const route = {
|
||||
profile: {
|
||||
label: 'Profile',
|
||||
route: '/profile/'
|
||||
},
|
||||
settings: {
|
||||
label: 'Settings',
|
||||
route: '/settings/'
|
||||
},
|
||||
sessions: {
|
||||
label: 'Sessions',
|
||||
route: '/sessions/'
|
||||
},
|
||||
logout: {
|
||||
label: 'Logout',
|
||||
route: '/logout/'
|
||||
},
|
||||
dashboard: {
|
||||
label: 'Dashboard',
|
||||
route: '/dashboard/'
|
||||
},
|
||||
companies: {
|
||||
label: 'Companies',
|
||||
route: '/company/'
|
||||
},
|
||||
smtpConfigurations: {
|
||||
label: 'SMTP Configurations',
|
||||
singleLabel: 'Configurations',
|
||||
route: '/smtp-configuration/'
|
||||
},
|
||||
domain: {
|
||||
label: 'Domains',
|
||||
route: '/domain/'
|
||||
},
|
||||
assets: {
|
||||
label: 'Assets',
|
||||
route: '/asset/'
|
||||
},
|
||||
attachments: {
|
||||
label: 'Attachments',
|
||||
route: '/attachment/'
|
||||
},
|
||||
recipients: {
|
||||
label: 'Recipients',
|
||||
route: '/recipient/'
|
||||
},
|
||||
recipientGroups: {
|
||||
label: 'Groups',
|
||||
route: '/recipient/group/'
|
||||
},
|
||||
emails: {
|
||||
label: 'Emails',
|
||||
route: '/email/'
|
||||
},
|
||||
pages: {
|
||||
label: 'Pages',
|
||||
route: '/page/'
|
||||
},
|
||||
campaignTemplates: {
|
||||
label: 'Campaign Templates',
|
||||
singleLabel: 'Templates',
|
||||
route: '/campaign-template/'
|
||||
},
|
||||
campaigns: {
|
||||
label: 'Campaigns',
|
||||
route: '/campaign/'
|
||||
},
|
||||
users: {
|
||||
label: 'Users',
|
||||
route: '/user/'
|
||||
},
|
||||
apiSenders: {
|
||||
label: 'API Senders',
|
||||
route: '/api-sender/'
|
||||
},
|
||||
allowDeny: {
|
||||
label: 'IP filters',
|
||||
route: '/ip-filter/'
|
||||
},
|
||||
webhook: {
|
||||
label: 'Webhooks',
|
||||
route: '/webhook/'
|
||||
},
|
||||
userGuide: {
|
||||
label: 'User Guide',
|
||||
route: 'https://phishing.club/guide/introduction/',
|
||||
external: true
|
||||
}
|
||||
};
|
||||
|
||||
export const menu = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
type: 'submenu',
|
||||
items: [route.dashboard]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Campaigns',
|
||||
type: 'submenu',
|
||||
items: [route.campaigns, route.campaignTemplates, route.allowDeny, route.webhook]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Recipients',
|
||||
type: 'submenu',
|
||||
items: [route.recipients, route.recipientGroups]
|
||||
},
|
||||
{
|
||||
label: 'Domains',
|
||||
type: 'submenu',
|
||||
items: [route.domain, route.pages, route.assets]
|
||||
},
|
||||
{
|
||||
label: 'Emails',
|
||||
type: 'submenu',
|
||||
items: [route.emails, route.attachments, route.smtpConfigurations, route.apiSenders]
|
||||
}
|
||||
];
|
||||
|
||||
export const topMenu = [
|
||||
route.profile,
|
||||
route.sessions,
|
||||
route.users,
|
||||
route.companies,
|
||||
route.settings,
|
||||
route.userGuide
|
||||
];
|
||||
|
||||
export const mobileTopMenu = [
|
||||
route.profile,
|
||||
route.sessions,
|
||||
route.users,
|
||||
route.companies,
|
||||
route.settings,
|
||||
route.userGuide
|
||||
];
|
||||
@@ -0,0 +1,329 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
/**
|
||||
* State is a singleton class that holds the global state of the application.
|
||||
* Get a instance via. State.instance
|
||||
*/
|
||||
export class AppStateService {
|
||||
/**
|
||||
* @returns {AppStateService|null}
|
||||
*/
|
||||
static #_instance = null;
|
||||
|
||||
/**
|
||||
* @returns {AppStateService}
|
||||
*/
|
||||
static get instance() {
|
||||
if (!AppStateService.#_instance) {
|
||||
AppStateService.#_instance = new AppStateService();
|
||||
}
|
||||
return AppStateService.#_instance;
|
||||
}
|
||||
|
||||
static INSTALL = {
|
||||
INSTALLED: 'INSTALLED',
|
||||
NOT_INSTALLED: 'NOT_INSTALLED',
|
||||
UNKNOWN: 'UNKNOWN'
|
||||
};
|
||||
|
||||
static LOGIN = {
|
||||
LOGGED_IN: 'LOGGED_IN',
|
||||
LOGGED_OUT: 'LOGGED_OUT',
|
||||
UNKNOWN: 'UNKNOWN'
|
||||
};
|
||||
|
||||
// License system removed - no longer needed
|
||||
|
||||
static CONTEXT = {
|
||||
SHARED: 'SHARED',
|
||||
COMPANY: 'COMPANY'
|
||||
};
|
||||
|
||||
static INITIAL_STATE = {
|
||||
loginStatus: AppStateService.LOGIN.UNKNOWN,
|
||||
installStatus: AppStateService.INSTALL.UNKNOWN,
|
||||
isReady: false,
|
||||
isUpdateAvailable: false,
|
||||
|
||||
user: {
|
||||
name: null,
|
||||
username: null,
|
||||
company: null,
|
||||
role: null
|
||||
},
|
||||
context: {
|
||||
current: AppStateService.CONTEXT.SHARED,
|
||||
companyName: null,
|
||||
companyID: null
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO update this time when it is more known - ref a dynamic value
|
||||
*
|
||||
* @type {import("svelte/store").Writable}
|
||||
*/
|
||||
#_store = null;
|
||||
|
||||
/**
|
||||
* Create a new state instance
|
||||
* if no sveltStore is provided, it will create a new one
|
||||
*
|
||||
* @param {import("svelte/store").Writable|void} sveltStore
|
||||
*/
|
||||
constructor(sveltStore) {
|
||||
if (sveltStore) {
|
||||
this.#_store = sveltStore;
|
||||
return;
|
||||
}
|
||||
this.#_store = writable(AppStateService.INITIAL_STATE);
|
||||
}
|
||||
|
||||
ready() {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
isReady: true
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} companyID
|
||||
*/
|
||||
setCompanyContext(companyID, companyName) {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
context: {
|
||||
current: AppStateService.CONTEXT.COMPANY,
|
||||
companyName,
|
||||
companyID
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
clearContext() {
|
||||
/*
|
||||
this.#_store.update(state => {
|
||||
return {
|
||||
...state,
|
||||
context: {
|
||||
current: AppStateService.CONTEXT.SHARED,
|
||||
companyName: null,
|
||||
companyID: null
|
||||
}
|
||||
}
|
||||
})
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Login.
|
||||
*
|
||||
* @param {typeof AppStateService.INITIAL_STATE.user} user
|
||||
*/
|
||||
setLoggedIn(user) {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
loginStatus: AppStateService.LOGIN.LOGGED_IN,
|
||||
user
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout >:( (╯°□°)╯︵ ┻━┻).
|
||||
*/
|
||||
setLoggedOut() {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
loginStatus: AppStateService.LOGIN.LOGGED_OUT,
|
||||
user: AppStateService.INITIAL_STATE.user
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* set the username
|
||||
*
|
||||
* @param {string} username
|
||||
*/
|
||||
setUsername(username) {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
username
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* setUserFullName
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
setUserFullName(name) {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
name
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* set company name
|
||||
*
|
||||
* @param {string} company
|
||||
*/
|
||||
setUserCompany(company) {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
company
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* set user role
|
||||
*
|
||||
* @param {string} role
|
||||
*/
|
||||
setUserRole(role) {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
role
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} status
|
||||
* @param {typeof AppStateService.INITIAL_STATE.user|void|null} user
|
||||
*/
|
||||
setLogin(status, user = null) {
|
||||
this.#_store.update((state) => {
|
||||
if (user) {
|
||||
return {
|
||||
...state,
|
||||
loginStatus: status,
|
||||
user
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
loginStatus: status
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setIsInstalled() {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
installStatus: AppStateService.INSTALL.INSTALLED
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setIsNotInstalled() {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
installStatus: AppStateService.INSTALL.NOT_INSTALLED
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// License methods removed - no longer needed
|
||||
|
||||
setIsUpdateAvailable(isUpdateAvailable) {
|
||||
this.#_store.update((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUpdateAvailable: isUpdateAvailable
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* expose the svelte store subscribe method
|
||||
*/
|
||||
get subscribe() {
|
||||
return this.#_store.subscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO all is* pulls a state snapshot, this is not ideal for performance - instead allow to pass in state as it
|
||||
* most often used inside a state subscribe method
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks the current snapshot of the store to see if the user is logged in
|
||||
* For continous checks use the subscribe method
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLoggedIn() {
|
||||
return get(this.#_store).loginStatus === AppStateService.LOGIN.LOGGED_IN;
|
||||
}
|
||||
|
||||
isSuperAdministrator() {
|
||||
return get(this.#_store).user.role === 'superadministrator';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the current snapshot of the store to see if the app is installed
|
||||
* For continous checks use the subscribe method
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isInstalled() {
|
||||
return get(this.#_store).installStatus === AppStateService.INSTALL.INSTALLED;
|
||||
}
|
||||
|
||||
isGlobalContext() {
|
||||
return get(this.#_store).context.current === AppStateService.CONTEXT.SHARED;
|
||||
}
|
||||
|
||||
isCompanyContext() {
|
||||
return get(this.#_store).context.current === AppStateService.CONTEXT.COMPANY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lastest snapshot of user state
|
||||
* For continous checks use the subscribe method
|
||||
*
|
||||
* @returns {typeof AppStateService.INITIAL_STATE.user}
|
||||
*/
|
||||
getUser() {
|
||||
return get(this.#_store).user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lastest snapshot of context state
|
||||
* For continous checks use the subscribe method
|
||||
*
|
||||
* @returns {typeof AppStateService.INITIAL_STATE.context}
|
||||
*/
|
||||
getContext() {
|
||||
return get(this.#_store).context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export const getPaginatedChunk = (arr, page = 1, perPage = 2, search = '', sortByField = '') => {
|
||||
let filtered = [...arr];
|
||||
if (search !== '') {
|
||||
filtered = arr.filter((r) => r.fullName.toLowerCase().includes(search.toLowerCase()));
|
||||
}
|
||||
filtered = arr.filter((r) => r.fullName.toLowerCase().includes(search.toLowerCase()));
|
||||
const start = (page - 1) * perPage;
|
||||
const end = start + perPage;
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
if (!a[sortByField] || !b[sortByField]) {
|
||||
return 0;
|
||||
}
|
||||
return a[sortByField].toLowerCase().localeCompare(b[sortByField].toLowerCase());
|
||||
});
|
||||
return sorted.slice(start, end);
|
||||
};
|
||||
|
||||
export const getPaginatedChunkWithParams = (
|
||||
arr,
|
||||
{ page = 1, perPage = 2, search = '', sortBy = '', sortOrder = 'asc' } = {}
|
||||
) => {
|
||||
let filtered = [...arr];
|
||||
if (search !== '') {
|
||||
filtered = arr.filter((r) =>
|
||||
Object.values(r).some((value) => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return value.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
);
|
||||
}
|
||||
const start = (page - 1) * perPage;
|
||||
const end = start + perPage;
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const aa = Object.fromEntries(
|
||||
Object.entries(a).map(([key, value]) => [key.toLowerCase().replace(/\s+/g, ''), value])
|
||||
);
|
||||
const bb = Object.fromEntries(
|
||||
Object.entries(b).map(([key, value]) => [key.toLowerCase().replace(/\s+/g, ''), value])
|
||||
);
|
||||
const sortByNormalized = sortBy.toLowerCase().replace(/\s+/g, '');
|
||||
if (!aa[sortByNormalized] || !bb[sortByNormalized]) {
|
||||
return 0;
|
||||
}
|
||||
const comparison = aa[sortByNormalized]
|
||||
.toLowerCase()
|
||||
.localeCompare(bb[sortByNormalized].toLowerCase());
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
return sorted.slice(start, end);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { API } from '$lib/api/api.js';
|
||||
import { AppStateService } from './appState';
|
||||
|
||||
/**
|
||||
* Session class
|
||||
*
|
||||
* Use Session.instance to get the default global singleton instance
|
||||
* The first time you call Session.instance or the constructor, it will be initialized with the global api client
|
||||
*/
|
||||
export class Session {
|
||||
/**
|
||||
* Global singleton session instance
|
||||
* @type {Session|null}
|
||||
*/
|
||||
static #_instance = null;
|
||||
|
||||
static get instance() {
|
||||
if (!Session.#_instance) {
|
||||
Session.#_instance = new Session();
|
||||
}
|
||||
return Session.#_instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interval in milliseconds between each session ping
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
#intervalMS = 1000 * 60;
|
||||
|
||||
/**
|
||||
* @type {API|null}
|
||||
*/
|
||||
#apiClient = null;
|
||||
|
||||
/**
|
||||
* @type {AppStateService|null}
|
||||
*/
|
||||
#appStateService = null;
|
||||
|
||||
/**
|
||||
* @type {number|null}
|
||||
*/
|
||||
#intervalID = null;
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isRunning = false;
|
||||
|
||||
get isRunning() {
|
||||
return this.#isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
#debug = false;
|
||||
|
||||
/**
|
||||
* If no client is provided, use it automatically uses the global api client
|
||||
* @param {API} apiClient
|
||||
* @param {AppStateService} appStateService
|
||||
*/
|
||||
constructor(apiClient = API.instance, appStateService = AppStateService.instance) {
|
||||
this.#apiClient = apiClient;
|
||||
this.#appStateService = appStateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* log to console if debug is enabled
|
||||
* @param {...*} x
|
||||
*/
|
||||
#log(...x) {
|
||||
if (this.#debug) {
|
||||
console.log('session:', ...x);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ping session
|
||||
*
|
||||
* @throws {Error} if session ping fails
|
||||
*/
|
||||
async ping() {
|
||||
this.#log('pinging...');
|
||||
const sessionPingResult = await this.#apiClient.session.ping();
|
||||
// user is not logged in
|
||||
if (!sessionPingResult.success) {
|
||||
this.#appStateService.setLogin(AppStateService.LOGIN.LOGGED_OUT);
|
||||
return;
|
||||
}
|
||||
// user is logged in
|
||||
this.#appStateService.setLogin(AppStateService.LOGIN.LOGGED_IN, {
|
||||
name: sessionPingResult.data.name,
|
||||
username: sessionPingResult.data.username,
|
||||
company: sessionPingResult.data.company,
|
||||
role: sessionPingResult.data.role
|
||||
});
|
||||
// check if app is installed
|
||||
this.#log('user is logged in - retrieving install status');
|
||||
const res = await this.#apiClient.option.get('is_installed');
|
||||
if (res.data.value === 'true') {
|
||||
this.#appStateService.setIsInstalled();
|
||||
} else {
|
||||
this.#appStateService.setIsNotInstalled();
|
||||
}
|
||||
this.#log('ping success');
|
||||
}
|
||||
|
||||
debugOn() {
|
||||
this.#debug = true;
|
||||
}
|
||||
|
||||
debugOff() {
|
||||
this.#debug = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* start session ping
|
||||
*
|
||||
* @throws {Error} if session is already started
|
||||
* @throws {Error} if session initialization failed
|
||||
*/
|
||||
async start() {
|
||||
if (this.#isRunning) {
|
||||
this.#log('already started');
|
||||
throw new Error('session is already started');
|
||||
}
|
||||
this.#isRunning = true;
|
||||
this.#log('initial ping');
|
||||
try {
|
||||
// initial ping
|
||||
// setup continous ping
|
||||
this.#intervalID = window.setInterval(async () => {
|
||||
try {
|
||||
await this.ping();
|
||||
} catch (e) {
|
||||
this.#log('ping failed', e);
|
||||
}
|
||||
}, this.#intervalMS);
|
||||
await this.ping();
|
||||
this.#log('ping success');
|
||||
this.#log('continous ping is running');
|
||||
} catch (e) {
|
||||
this.#isRunning = false;
|
||||
this.#log('initial ping failed', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stop session ping
|
||||
*
|
||||
* @throws {Error} if session is not started
|
||||
*/
|
||||
stop = () => {
|
||||
if (!this.#isRunning) {
|
||||
this.#log('not started');
|
||||
throw new Error('session is not started');
|
||||
}
|
||||
clearInterval(this.#intervalID);
|
||||
this.#isRunning = false;
|
||||
this.#log('stopped');
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const defaultOptions = {
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
search: ''
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} settings
|
||||
* @param {number} [settings.page]
|
||||
* @param {number} [settings.perPage]
|
||||
* @param {string} [settings.sortBy]
|
||||
* @param {string} [settings.sortOrder]
|
||||
* @param {string} [settings.search]
|
||||
* @returns {Object} { currentPage: number, perPage: number, next: function, previous: function
|
||||
*/
|
||||
|
||||
export const newTableParams = (options = {}) => {
|
||||
options = { ...defaultOptions, ...options };
|
||||
let state = { ...defaultOptions, ...options };
|
||||
let firstState = { ...defaultOptions, ...options };
|
||||
return {
|
||||
_listeners: [],
|
||||
onChange: function (callback) {
|
||||
this._listeners.push(callback);
|
||||
},
|
||||
unsubscribe: function () {
|
||||
this._listeners = [];
|
||||
},
|
||||
_notifyListeners: function (property, value) {
|
||||
this._listeners.forEach((callback) => callback(property, value));
|
||||
},
|
||||
get currentPage() {
|
||||
return state.page;
|
||||
},
|
||||
get perPage() {
|
||||
return state.perPage;
|
||||
},
|
||||
get sortBy() {
|
||||
return state.sortBy;
|
||||
},
|
||||
get sortOrder() {
|
||||
return state.sortOrder;
|
||||
},
|
||||
get search() {
|
||||
return state.search;
|
||||
},
|
||||
set search(search) {
|
||||
state.search = search;
|
||||
state.page = 1;
|
||||
this._notifyListeners('search', search);
|
||||
this._notifyListeners('page', 1);
|
||||
},
|
||||
set perPage(perPage) {
|
||||
state.perPage = perPage;
|
||||
state.page = 1;
|
||||
this._notifyListeners('perPage', perPage);
|
||||
this._notifyListeners('page', 1);
|
||||
},
|
||||
reset: function () {
|
||||
state = { ...firstState };
|
||||
},
|
||||
sort: function (sortBy, sortOrder) {
|
||||
// handle sorting order and logic
|
||||
if (sortBy !== state.sortBy) {
|
||||
sortOrder = 'asc';
|
||||
} else {
|
||||
switch (sortOrder) {
|
||||
case '':
|
||||
sortOrder = 'asc';
|
||||
break;
|
||||
case 'asc':
|
||||
sortOrder = 'desc';
|
||||
break;
|
||||
case 'desc':
|
||||
sortOrder = options.sortOrder;
|
||||
sortBy = options.sortBy;
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.sortBy = sortBy;
|
||||
state.sortOrder = sortOrder;
|
||||
this._notifyListeners('sortBy', sortBy);
|
||||
this._notifyListeners('sortOrder', sortOrder);
|
||||
},
|
||||
next: function () {
|
||||
state.page = state.page + 1;
|
||||
this._notifyListeners('page', state.page);
|
||||
return state.page;
|
||||
},
|
||||
previous: function () {
|
||||
if (state.page !== 1) {
|
||||
state.page = state.page - 1;
|
||||
}
|
||||
this._notifyListeners('page', state.page);
|
||||
return state.page;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const minPage = 1;
|
||||
const minPerPage = 10;
|
||||
const maxPerPage = 100;
|
||||
export const defaultStartPage = 1;
|
||||
export const defaultPerPage = 10;
|
||||
const acceptedPerPageValues = [10, 25, 50];
|
||||
const defaultSortBy = 'updated_at';
|
||||
const defaultSortOrder = 'desc';
|
||||
|
||||
const defaultOptions = {
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
search: '',
|
||||
onPopState: () => {},
|
||||
prefix: '',
|
||||
noScroll: false
|
||||
};
|
||||
|
||||
const param = (key, prefix) => {
|
||||
return prefix ? `${prefix}_${key}` : key;
|
||||
};
|
||||
|
||||
class URLUpdateQueue {
|
||||
static instance;
|
||||
queue = [];
|
||||
isProcessing = false;
|
||||
|
||||
static getInstance() {
|
||||
if (!URLUpdateQueue.instance) {
|
||||
URLUpdateQueue.instance = new URLUpdateQueue();
|
||||
}
|
||||
return URLUpdateQueue.instance;
|
||||
}
|
||||
|
||||
async add(updateFn) {
|
||||
this.queue.push(updateFn);
|
||||
if (!this.isProcessing) {
|
||||
await this.process();
|
||||
}
|
||||
}
|
||||
|
||||
async process() {
|
||||
this.isProcessing = true;
|
||||
while (this.queue.length > 0) {
|
||||
const update = this.queue.shift();
|
||||
await update();
|
||||
}
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {Object} TableURLParamsOptions
|
||||
* @property {number} [page]
|
||||
* @property {number} [perPage]
|
||||
* @property {string} [sortBy]
|
||||
* @property {string} [sortOrder]
|
||||
* @property {string} [search]
|
||||
* @property {() => void} [onPopState]
|
||||
* @property {string} [prefix]
|
||||
* @property {boolean} [noScroll]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {TableURLParamsOptions} [options]
|
||||
*/
|
||||
export const newTableURLParams = (
|
||||
options = {
|
||||
page: defaultStartPage,
|
||||
perPage: defaultPerPage,
|
||||
sortBy: defaultSortBy,
|
||||
sortOrder: defaultSortOrder,
|
||||
search: '',
|
||||
onPopState: () => {},
|
||||
prefix: '',
|
||||
noScroll: false
|
||||
}
|
||||
) => {
|
||||
let urlParams = new URLSearchParams(window.location.search);
|
||||
const initialPath = window.location.pathname;
|
||||
|
||||
// first add the default values
|
||||
const state = {
|
||||
...defaultOptions
|
||||
};
|
||||
if (options.page) {
|
||||
state.page = options.page;
|
||||
}
|
||||
if (options.perPage) {
|
||||
state.perPage = options.perPage;
|
||||
}
|
||||
if (options.sortBy) {
|
||||
state.sortBy = options.sortBy;
|
||||
}
|
||||
if (options.sortOrder) {
|
||||
state.sortOrder = options.sortOrder;
|
||||
}
|
||||
if (options.search) {
|
||||
state.search = options.search;
|
||||
}
|
||||
if (options.onPopState) {
|
||||
state.onPopState = options.onPopState;
|
||||
}
|
||||
if (options.prefix) {
|
||||
state.prefix = options.prefix;
|
||||
}
|
||||
if (options.noScroll) {
|
||||
state.noScroll = options.noScroll;
|
||||
}
|
||||
|
||||
// if there is a state set in the url params, use it
|
||||
const urlParamsPage = parseInt(urlParams.get(param('page', state.prefix)));
|
||||
if (urlParamsPage) {
|
||||
state.page = urlParamsPage;
|
||||
}
|
||||
const urlParamsperPage = parseInt(urlParams.get(param('perPage', state.prefix)));
|
||||
if (urlParamsperPage) {
|
||||
state.perPage = urlParamsperPage;
|
||||
}
|
||||
const urlParamsSortBy = urlParams.get(param('sortBy', state.prefix));
|
||||
if (urlParamsSortBy) {
|
||||
state.sortBy = urlParamsSortBy;
|
||||
}
|
||||
const urlParamsSortOrder = urlParams.get(param('sortOrder', state.prefix));
|
||||
if (urlParamsSortOrder) {
|
||||
state.sortOrder = urlParamsSortOrder;
|
||||
}
|
||||
const urlParamsSearch = urlParams.get(param('search', state.prefix));
|
||||
if (urlParamsSearch) {
|
||||
state.search = urlParamsSearch;
|
||||
}
|
||||
|
||||
// validate values or set back to defaults
|
||||
if (state.page < minPage) {
|
||||
console.warn('adjusting pagination: currentPage < minPage');
|
||||
state.page = defaultOptions.page;
|
||||
}
|
||||
if (state.perPage < minPerPage || state.perPage > maxPerPage) {
|
||||
console.warn('adjusting pagination: perPage < minPerPage || perPage > maxPerPage');
|
||||
state.perPage = defaultOptions.perPage;
|
||||
}
|
||||
if (acceptedPerPageValues.includes(state.perPage) === false) {
|
||||
console.warn('adjusting pagination: acceptedPerPageValues.includes(perPage) === false');
|
||||
const closestPerPage = acceptedPerPageValues.reduce((prev, curr) => {
|
||||
if (Math.abs(curr - state.perPage) < Math.abs(prev - state.perPage)) {
|
||||
return curr;
|
||||
} else {
|
||||
return prev;
|
||||
}
|
||||
}, acceptedPerPageValues[0]);
|
||||
state.perPage = closestPerPage;
|
||||
}
|
||||
if (['asc', 'desc'].includes(state.sortOrder) === false) {
|
||||
state.sortOrder = defaultOptions.sortOrder;
|
||||
}
|
||||
|
||||
const popstateHandler = () => {
|
||||
// Don't process if we're navigating to a different route
|
||||
if (window.location.pathname !== initialPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.toString());
|
||||
const newPage = parseInt(url.searchParams.get(param('page', state.prefix)));
|
||||
const newPerPage = parseInt(url.searchParams.get(param('perPage', state.prefix)));
|
||||
const newSortBy = url.searchParams.get(param('sortBy', state.prefix));
|
||||
const newSortOrder = url.searchParams.get(param('sortOrder', state.prefix));
|
||||
const newSearch = url.searchParams.get(param('search', state.prefix));
|
||||
|
||||
if (newPage) state.page = newPage;
|
||||
if (newPerPage) state.perPage = newPerPage;
|
||||
if (newSortBy) state.sortBy = newSortBy;
|
||||
if (newSortOrder) state.sortOrder = newSortOrder;
|
||||
if (newSearch) state.search = newSearch;
|
||||
|
||||
state.onPopState();
|
||||
pagination._notifyListeners('page', state.page);
|
||||
pagination._notifyListeners('perPage', state.perPage);
|
||||
pagination._notifyListeners('sortBy', state.sortBy);
|
||||
pagination._notifyListeners('sortOrder', state.sortOrder);
|
||||
pagination._notifyListeners('search', state.search);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
|
||||
const updateURL = async () => {
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set(param('page', state.prefix), state.page.toString());
|
||||
url.searchParams.set(param('perPage', state.prefix), state.perPage.toString());
|
||||
url.searchParams.set(param('sortBy', state.prefix), state.sortBy);
|
||||
url.searchParams.set(param('sortOrder', state.prefix), state.sortOrder);
|
||||
url.searchParams.set(param('search', state.prefix), state.search);
|
||||
await goto(`?${url.searchParams.toString()}`, { replaceState: true, invalidateAll: false });
|
||||
};
|
||||
|
||||
URLUpdateQueue.getInstance().add(updateURL);
|
||||
|
||||
const pagination = {
|
||||
_listeners: [],
|
||||
onChange: function (callback) {
|
||||
this._listeners.push(callback);
|
||||
},
|
||||
unsubscribe: function () {
|
||||
this._listeners = [];
|
||||
window.removeEventListener('popstate', popstateHandler);
|
||||
},
|
||||
_notifyListeners: function (property, value) {
|
||||
this._listeners.forEach((callback) => {
|
||||
callback(property, value);
|
||||
});
|
||||
},
|
||||
_goto(url, opts) {
|
||||
const o = { ...opts };
|
||||
if (options.noScroll) {
|
||||
o.noScroll = true;
|
||||
}
|
||||
const updateURL = async () => {
|
||||
await goto(`?${url.searchParams.toString()}`, o);
|
||||
};
|
||||
URLUpdateQueue.getInstance().add(updateURL);
|
||||
},
|
||||
get currentPage() {
|
||||
return state.page;
|
||||
},
|
||||
get perPage() {
|
||||
return state.perPage;
|
||||
},
|
||||
get sortBy() {
|
||||
return state.sortBy;
|
||||
},
|
||||
get sortOrder() {
|
||||
return state.sortOrder;
|
||||
},
|
||||
get search() {
|
||||
return state.search;
|
||||
},
|
||||
set search(search) {
|
||||
state.search = search;
|
||||
state.page = 1;
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set(param('search', state.prefix), search);
|
||||
url.searchParams.set(param('page', state.prefix), '1');
|
||||
this._goto(url, {
|
||||
keepFocus: true,
|
||||
replaceState: true
|
||||
});
|
||||
this._notifyListeners('search', search);
|
||||
this._notifyListeners('page', 1);
|
||||
},
|
||||
set perPage(perPage) {
|
||||
state.perPage = perPage;
|
||||
state.page = 1;
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set(param('perPage', state.prefix), perPage.toString());
|
||||
url.searchParams.set(param('page', state.prefix), '1');
|
||||
this._goto(url);
|
||||
this._notifyListeners('perPage', perPage);
|
||||
this._notifyListeners('page', 1);
|
||||
},
|
||||
sort: function (sortBy, sortOrder) {
|
||||
if (sortBy !== state.sortBy) {
|
||||
sortOrder = 'asc';
|
||||
} else {
|
||||
switch (sortOrder) {
|
||||
case '':
|
||||
sortOrder = 'asc';
|
||||
break;
|
||||
case 'asc':
|
||||
sortOrder = 'desc';
|
||||
break;
|
||||
case 'desc':
|
||||
sortOrder = defaultSortOrder;
|
||||
sortBy = defaultSortBy;
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.sortBy = sortBy;
|
||||
state.sortOrder = sortOrder;
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set(param('sortBy', state.prefix), sortBy);
|
||||
url.searchParams.set(param('sortOrder', state.prefix), sortOrder);
|
||||
this._goto(url);
|
||||
this._notifyListeners('sortBy', sortBy);
|
||||
this._notifyListeners('sortOrder', sortOrder);
|
||||
},
|
||||
next: function () {
|
||||
state.page = state.page + 1;
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set(param('page', state.prefix), state.page.toString());
|
||||
this._goto(url);
|
||||
this._notifyListeners('page', state.page);
|
||||
return state.page;
|
||||
},
|
||||
previous: function () {
|
||||
if (state.page !== 1) {
|
||||
state.page = state.page - 1;
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set(param('page', state.prefix), state.page.toString());
|
||||
this._goto(url);
|
||||
this._notifyListeners('page', state.page);
|
||||
}
|
||||
return state.page;
|
||||
}
|
||||
};
|
||||
|
||||
return pagination;
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
//import { actions } from '$lib/state.js'
|
||||
import { API } from '$lib/api/api.js';
|
||||
import { AppStateService } from './appState';
|
||||
|
||||
/**
|
||||
* UserService is a singleton class that provides methods to interact with the user
|
||||
* Get a instance via. UserService.instance
|
||||
*/
|
||||
export class UserService {
|
||||
/**
|
||||
* @returns {UserService|null}
|
||||
*/
|
||||
static #_instance = null;
|
||||
|
||||
/**
|
||||
* @returns {UserService}
|
||||
*/
|
||||
static get instance() {
|
||||
if (!UserService.#_instance) {
|
||||
UserService.#_instance = new UserService();
|
||||
}
|
||||
return UserService.#_instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {API}
|
||||
*/
|
||||
#apiClient = null;
|
||||
|
||||
/**
|
||||
* @type {AppStateService|null}
|
||||
*/
|
||||
#appStateService = null;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {API} apiClient
|
||||
*/
|
||||
constructor(apiClient = API.instance, appStateService = AppStateService.instance) {
|
||||
this.#apiClient = apiClient;
|
||||
this.#appStateService = appStateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* login
|
||||
*
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @returns {Promise<import('$lib/api/api').ApiResponse>}
|
||||
*/
|
||||
async login(username, password, mfaTOTP = '', recoveryCode = '') {
|
||||
let res;
|
||||
const hasMFATOTP = mfaTOTP.length > 0;
|
||||
const hasRecoveryCode = recoveryCode.length > 0;
|
||||
const isPasswordLogin = !hasMFATOTP && !hasRecoveryCode;
|
||||
const isTOTPLogin = hasMFATOTP && !hasRecoveryCode;
|
||||
const isRecoveryCodeLogin = !hasMFATOTP && hasRecoveryCode;
|
||||
|
||||
switch (true) {
|
||||
case isPasswordLogin: {
|
||||
res = await this.#apiClient.user.login(username, password);
|
||||
break;
|
||||
}
|
||||
case isTOTPLogin: {
|
||||
res = await this.#apiClient.user.loginTOTP(username, password, mfaTOTP);
|
||||
break;
|
||||
}
|
||||
case isRecoveryCodeLogin: {
|
||||
res = await this.#apiClient.user.loginMFARecoveryCode(username, password, recoveryCode);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error('Invalid login method.');
|
||||
}
|
||||
}
|
||||
switch (res.statusCode) {
|
||||
case 200: {
|
||||
if (res.data.mfa) {
|
||||
return res;
|
||||
}
|
||||
// user is logged in - also check if app is installed
|
||||
const isInstalledRes = await this.#apiClient.option.get('is_installed');
|
||||
if (isInstalledRes.success && isInstalledRes.data.value === 'true') {
|
||||
this.#appStateService.setIsInstalled();
|
||||
} else {
|
||||
this.#appStateService.setIsNotInstalled();
|
||||
}
|
||||
this.#appStateService.setLoggedIn({
|
||||
name: res.data.user.name,
|
||||
username: res.data.user.username,
|
||||
company: res.data.user.company && res.data.user.company.name,
|
||||
role: res.data.user.role.name
|
||||
});
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* logout
|
||||
*
|
||||
* @returns {Promise<import('$lib/api/api').ApiResponse>}
|
||||
*/
|
||||
async logout() {
|
||||
const res = await this.#apiClient.user.logout();
|
||||
if (res.success) {
|
||||
this.clear();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* clear user state
|
||||
*/
|
||||
async clear() {
|
||||
this.#appStateService.setLoggedOut();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* change user name.
|
||||
*
|
||||
* @param {string} newUsername
|
||||
* @returns {Promise<import('$lib/api/api').ApiResponse>}
|
||||
*/
|
||||
async changeUsername(newUsername) {
|
||||
const res = await this.#apiClient.user.changeUsername(newUsername);
|
||||
if (res.success) {
|
||||
this.#appStateService.setUsername(newUsername);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* change user's full name.
|
||||
*
|
||||
* @param {string} newFullname
|
||||
* @returns {Promise<import('$lib/api/api').ApiResponse>}
|
||||
*/
|
||||
async changeFullname(newFullname) {
|
||||
const res = await this.#apiClient.user.changeFullname(newFullname);
|
||||
if (res.success) {
|
||||
this.#appStateService.setUserFullName(newFullname);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* change user's company name.
|
||||
*
|
||||
* @param {string} newCompany
|
||||
* @returns {Promise<import('$lib/api/api').ApiResponse>}
|
||||
*/
|
||||
async changeCompany(newCompany) {
|
||||
const res = await this.#apiClient.user.changeCompany(newCompany);
|
||||
if (res.success) {
|
||||
this.#appStateService.setUserCompany(newCompany);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const activeFormElement = writable(null);
|
||||
|
||||
export const activeFormElementSubscribe = (id, callback) => {
|
||||
return activeFormElement.subscribe((activeId) => {
|
||||
if (activeId === id && activeId !== null) {
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const createAutoRefreshStore = () => {
|
||||
const { subscribe, set, update } = writable({
|
||||
enabled: true,
|
||||
interval: 60000
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setEnabled: (enabled) => update((state) => ({ ...state, enabled })),
|
||||
setInterval: (interval) => update((state) => ({ ...state, interval })),
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
export const autoRefreshStore = createAutoRefreshStore();
|
||||
|
||||
// Helper to manage page-specific storage
|
||||
export const getPageAutoRefresh = (pageId) => {
|
||||
const stored = localStorage.getItem(`autoRefresh_${pageId}`);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
return { enabled: true, interval: 60000 };
|
||||
};
|
||||
|
||||
export const setPageAutoRefresh = (pageId, settings) => {
|
||||
localStorage.setItem(`autoRefresh_${pageId}`, JSON.stringify(settings));
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isLoading = writable(false);
|
||||
|
||||
export const showIsLoading = () => {
|
||||
isLoading.set(true);
|
||||
};
|
||||
|
||||
export const hideIsLoading = () => {
|
||||
isLoading.set(false);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/**
|
||||
* @type {import("svelte/store").Writable<{id: number, text: string, type:string}[]>}
|
||||
*/
|
||||
export const toasts = writable([]);
|
||||
|
||||
let nextID = 0;
|
||||
|
||||
/**
|
||||
* add a toast
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {"Success"|"Info"|"Warning"|"Error"} type
|
||||
*/
|
||||
export const addToast = (text, type, visibilityMS = 5000) => {
|
||||
const t = { id: nextID++, text, type }
|
||||
toasts.update((toasts) => [...toasts, t]);
|
||||
// remove the toast after the specified time
|
||||
setTimeout(() => {
|
||||
toasts.update((toasts) => toasts.filter((toast) => toast.id !== t.id));
|
||||
}, visibilityMS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a toast
|
||||
* @param {number} id
|
||||
*/
|
||||
export const removeToast = (id) => {
|
||||
toasts.update((toasts) => toasts.filter((toast) => toast.id !== id));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user