Initial open source release

This commit is contained in:
Ronni Skansing
2025-08-21 16:14:09 +02:00
commit 11cf01f08e
488 changed files with 97180 additions and 0 deletions
+53
View File
@@ -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;
}
+13
View File
@@ -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
+21
View File
@@ -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;
}
});
+185
View File
@@ -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
};
}
+21
View File
@@ -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;
};
+330
View File
@@ -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>
+18
View File
@@ -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>
+15
View File
@@ -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} ">&nbsp;</div>
</div>
{:else}
<div class="{width} {height} {gradient}">&nbsp;</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>
+8
View File
@@ -0,0 +1,8 @@
<script>
export let name = 'World';
console.log(name)
</script>
<main>
Hello {name}!
</main>
+37
View File
@@ -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>
+27
View File
@@ -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}
+281
View File
@@ -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}>&lt;&lt;</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}>&gt;&gt;</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>
+45
View File
@@ -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>
+45
View File
@@ -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>
+12
View File
@@ -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

+181
View File
@@ -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}>&nbsp;</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} />
View File
+137
View File
@@ -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
];
+329
View File
@@ -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;
}
}
View File
@@ -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);
};
+166
View File
@@ -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');
};
}
+100
View File
@@ -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;
}
};
};
+310
View File
@@ -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;
};
+161
View File
@@ -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();
});
};
+30
View File
@@ -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));
};
+11
View File
@@ -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);
};
+31
View File
@@ -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