Initial open source release

This commit is contained in:
Ronni Skansing committed 2025-08-21 16:14:09 +02:00
commit 11cf01f08e
488 files changed
+97180

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
**/node_modules/
./src
+13
View File
@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
+17
View File
@@ -0,0 +1,17 @@
module.exports = {
root: true,
extends: ['eslint:recommended', 'plugin:svelte/recommended', 'prettier'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
"rules": {
"@typescript-eslint/no-unused-vars": 0
}
};
+12
View File
@@ -0,0 +1,12 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
vite.dev.config.ts.timestamp-*
vite.dev.config.js.timestamp-*
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
resolution-mode=highest
+13
View File
@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
+16
View File
@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+14
View File
@@ -0,0 +1,14 @@
FROM node:latest
WORKDIR /app
#COPY package.json package.json
#COPY package-lock.json package-lock.json
RUN chown -R node:node /app
USER node
#RUN npm install # handled in the dev.sh
## Add user
# Add group with ID 1000 and user with ID 1000
#COPY ./ /app/
CMD [ "bash", "/app/dev.sh" ]
+42
View File
@@ -0,0 +1,42 @@
# Description
Template for a static generated page
# Install
- Clone the repo
- Remove the git folder (`rm -rf ./.git`)
- Start a new git (`git init`)
- `npm install`
- `npm run dev`
# Development
- `npm run dev`
# Install / Update dependencies
- Attach to the frontend `make front-attach`
- Check for new updates, including breaking `npm run update-check`
- Update the dependencies `npm run update-break`
- Update the dependencies `npm run install`
- If it fails try deleting the `node_modules` folder and run `npm install` again
- If it works, delete the `node_modules` folder and exit the container.
- Rebuild the frontend `make frontend-build`
- The updated node_modules folder should be in the `./frontend` folder
## Bugs
There has been multiple bugs with watch rebuilding and hot reloading were files were not added to the build.
Multiple solutions have been tried. The current solution is to use vite dev and proxy requests to the backend.
This means that frontend should be reached via. HTTP on localhost:8003 and localhost:8002
A backup solution exists using nodemon via. `npm run dev-nodemon` and then using HTTPS on `localhost:8002`
There had been issues with vite watch not adding changed/new files to the build when via. containers.
To solve this, a switch to nodemon was made. This is not ideal, but it works.
We can test if the bug gets fixed by switching to `npm run build-dev` and add the `--watch` flag to it.
# Local build
- `npm run build` build is in `./build`
@@ -0,0 +1,76 @@
import fs from 'fs';
import path from 'path';
import { parse } from 'node-html-parser';
const __dirname = path.resolve();
const buildDir = path.join(__dirname, 'build');
function removeCspMeta(inputFile) {
const fileContents = fs.readFileSync(inputFile, { encoding: 'utf-8' });
const root = parse(fileContents);
const element = root.querySelector('head meta[http-equiv="content-security-policy"]');
const content = element.getAttribute('content');
element.remove()
return content;
}
const cspMap = new Map();
function findCspMeta(startPath, filter = /\.html$/) {
if (!fs.existsSync(startPath)) {
console.error(`Unable to find CSP start path: ${startPath}`);
return;
}
const files = fs.readdirSync(startPath);
files.forEach((item) => {
const filename = path.join(startPath, item);
const stat = fs.lstatSync(filename);
if (stat.isDirectory()) {
findCspMeta(filename, filter);
} else if (filter.test(filename)) {
cspMap.set(
filename
.replace(buildDir, '')
.replace(/\.html$/, '')
.replace(/^\/index$/, '/'),
removeCspMeta(filename),
);
}
});
}
function createHeaders() {
const headers = ` Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Permitted-Cross-Domain-Policies: 'none'
`;
const cspHashes = {};
cspMap.forEach((csp) => {
const cspParts = csp.split(" ")
if (cspParts.length != 3) {
console.error(`CSP had unexpected number of parts, expected 3: ${csp}`)
throw new Error("failed to collect hashes for inline CSP")
}
// push the key, so we only have the unique hashes
cspHashes[cspParts[2]] = true
})
const cspHashesInline = Object.keys(cspHashes).join(" ")
const csp = (` Content-Security-Policy: script-src 'self' ${cspHashesInline}`)
const headersFile = path.join(buildDir, '_headers');
const allHeaders = `/*\n${csp}\n${headers}\n`
fs.writeFileSync(headersFile, allHeaders);
return allHeaders
}
async function main() {
findCspMeta(buildDir);
return createHeaders();
}
const headers = await main();
console.log("generate-headers: Success: Removed CSP from html and added _headers file")
@@ -0,0 +1,5 @@
{
"name": "",
"copyright": "",
"licenseText": ""
}
+10
View File
@@ -0,0 +1,10 @@
#/bin/bash
# if node_modules folder does not exist, install dependencies
if [ ! -d "node_modules" ]; then
npm install
fi
while true; do \
npm run dev; \
echo "Process crashed! Restarting in 10 seconds..."; \
sleep 10; \
done
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": false,
"moduleResolution": "bundler"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"ignore": [
".svelte-kit/",
"build/",
"node_modules/"
],
"watch": [
"src/",
"static/"
],
"exec": "npm run build-dev",
"events": {
"restart": "rm ./vite.dev.config.js.timestamp* -f"
},
"ext": "js,ts,svelte,html,css,svg,png,jpg,jpeg,webp,json"
}
+3900
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "platform",
"no-version-attr-as-it-break-build-license-script": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev --config vite.dev.config.js --mode development",
"dev-nodemon": "nodemon",
"license-report": "license-checker --production --customPath ./build-scripts/license-format.json --json --excludePackages 'platform'",
"build": "vite build",
"build-production": "vite build --config vite.config.js --mode production ",
"build-dev": "vite build --config vite.dev.config.js --mode development",
"preview": "vite preview",
"lint": "prettier --plugin-search-dir . --check .",
"format": "prettier --plugin-search-dir . --write .",
"update-check": "ncu",
"update-break": "ncu -u"
},
"devDependencies": {
"@sveltejs/adapter-auto": "3.2.5",
"@sveltejs/adapter-static": "3.0.5",
"@sveltejs/kit": "2.6.1",
"@vitejs/plugin-basic-ssl": "1.1.0",
"autoprefixer": "10.4.20",
"nodemon": "3.1.7",
"prettier": "3.3.3",
"prettier-plugin-svelte": "3.2.7",
"vite": "5.4.8"
},
"type": "module",
"dependencies": {
"d3": "^7.9.0",
"date-fns": "4.1.0",
"license-checker": "^25.0.1",
"monaco-editor": "^0.52.2",
"npm-check-updates": "^17.1.3",
"papaparse": "5.4.1",
"svelte": "^4.2.19",
"tailwindcss": "3.4.13"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+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>
Loaded 100 of 488 files, more files were not shown because too many files have changed in this diff. Show more