`);
+ details.open();
+
+ this.close();
+ });
+
+ this.insertElement(infoButton);
+ this.insertElement(oops);
+ }
+
+ // Open page from "assets/pages"
+ openPage(page) {
+ // Show a spinner while fetching
+ const spinner = document.createElement("div");
+ spinner.classList = "logo spinner";
+ this.element.setAttribute("data-page",page);
+ this.insertElement(spinner);
+ this.open();
+
+ // Fetch the requested page
+ this.getPage(page).then(html => {
+ this.insertHTML(html);
+ this.bindAll(this.inner);
+ })
+ .catch(error => {
+ const tryAgain = new Button({
+ text: "try again",
+ type: "solid"
+ });
+ tryAgain.element.addEventListener("click",() => {
+ // Clear and recreate modal structure
+ destroy(this.inner);
+ this.applyTemplate(this.element);
+ this.init();
+ this.insertElement(spinner);
+ // Attempt to fetch the requested url again (with soft rate-limiting)
+ setTimeout(() => {
+ this.openPage(page);
+ destroy(spinner);
+ },500);
+ });
+ this.insertElement(tryAgain.element);
+ this.error(error);
+ })
+ .finally(() => destroy(spinner));
+ }
+
+ open() {
+ setTimeout(() => this.element.classList.add("active"),this.transition / 2);
+ }
+
+ // Close the modal and remove it from the DOM
+ close() {
+ this.element.classList.remove("active");
+ setTimeout(() => destroy(this.element),this.transition + 1); // Wait for transition
+ }
+}
+
+export class Dialog extends Modal {
+ constructor(interactions = {}) {
+ super(interactions);
+ this.init();
+ }
+
+ init() {
+ this.element.classList.add("dialog");
+ this.element.classList.add("center");
+ const closeButton = new Button({
+ text: "close",
+ action: "close",
+ type: "phantom"
+ });
+
+ this.bind(closeButton.element);
+ this.inner.appendChild(closeButton.element);
+ }
+}
+
+// Overlay with a slide-in animation from the bottom of the viewport
+export class Card extends Modal {
+ constructor(interactions = {}) {
+ super(interactions);
+ this.init();
+ }
+
+ init() {
+ this.element.classList.add("card");
+ this.element.classList.add("center");
+ const closeButton = new Button({
+ text: "close",
+ action: "close",
+ type: "phantom"
+ });
+
+ this.bind(closeButton.element);
+ this.inner.appendChild(closeButton.element);
+ }
+}
\ No newline at end of file
diff --git a/public/assets/js/modules/Preload.mjs b/public/assets/js/modules/Preload.mjs
new file mode 100644
index 0000000..48e32bd
--- /dev/null
+++ b/public/assets/js/modules/Preload.mjs
@@ -0,0 +1,42 @@
+// Victor Westerlund - www.victorwesterlund.com
+
+// Load assets for later use on this page.
+// This implements a hybrid of the link types "preload" and "prefetch"
+export default class Preload {
+ constructor(assets) {
+ this.scripts = [];
+ this.stylesheets = [];
+
+ // Get the type of asset from the file extension
+ assets.forEach(asset => {
+ const components = asset.split(".");
+ const extension = components[components.length - 1];
+ switch(extension) {
+ case "mjs":
+ this.scripts.push(asset);
+ break;
+ case "css":
+ this.stylesheets.push(asset);
+ break;
+ }
+ });
+
+ // Append tags when DOM is ready
+ window.addEventListener("DOMContentLoaded",() => this.import());
+ }
+
+ import() {
+ this.scripts.forEach(script => {
+ const element = document.createElement("script");
+ element.setAttribute("type","module");
+ element.src = "assets/js/" + script;
+ document.body.appendChild(element);
+ });
+ this.stylesheets.forEach(sheet => {
+ const element = document.createElement("link");
+ element.setAttribute("rel","stylesheet");
+ element.href = "assets/css/" + sheet;
+ document.head.appendChild(element);
+ });
+ }
+}
\ No newline at end of file
diff --git a/public/assets/js/modules/UI.mjs b/public/assets/js/modules/UI.mjs
new file mode 100644
index 0000000..09fffe8
--- /dev/null
+++ b/public/assets/js/modules/UI.mjs
@@ -0,0 +1,57 @@
+// Victor Westerlund - www.victorwesterlund.com
+
+import { default as Logging } from "./Logging.mjs";
+
+// Remove an element and its subtree
+export function destroy(family) {
+ while(family.firstChild) {
+ family.removeChild(family.lastChild);
+ }
+ family.parentNode.removeChild(family);
+}
+
+// General-purpose scoped event handler
+export default class Interaction extends Logging {
+ constructor(interactions,scope) {
+ super();
+ this.interactions = interactions;
+ this.attribute = "data-action"; // Target elements with this attribute
+
+ this.bindAll(scope);
+ }
+
+ // Bind event listeners to this element
+ bind(element) {
+ if(element.hasAttribute("data-bound") || !element.hasAttribute(this.attribute)) {
+ return false;
+ }
+ element.addEventListener("click",event => this.pointerEvent(event));
+ element.setAttribute("data-bound","");
+ }
+
+ // Get all elements with the target attribute in scope
+ getAll(scope) {
+ return scope.querySelectorAll(`[${this.attribute}]`);
+ }
+
+ // Bind listeners to all attributed elements within scope
+ bindAll(scope) {
+ const elements = this.getAll(scope);
+ for(const element of elements) {
+ this.bind(element);
+ }
+ }
+
+ // Handle click/touch interactions
+ pointerEvent(event) {
+ const target = event.target.closest(`[${this.attribute}]`);
+ const action = target?.getAttribute(this.attribute) ?? null;
+
+ if(!target || !action || !Object.keys(this.interactions).includes(action)) {
+ // Exit if the interaction is invalid or action doesn't exist
+ return false;
+ }
+ // Execute the function from the data-action attribute
+ this.interactions[action](event);
+ }
+}
\ No newline at end of file
diff --git a/public/assets/js/script.js b/public/assets/js/script.js
index 656c4d3..83cc78d 100644
--- a/public/assets/js/script.js
+++ b/public/assets/js/script.js
@@ -1,19 +1,79 @@
-// Register SW if supported by browser
-if(navigator.serviceWorker) {
- navigator.serviceWorker.register("sw.js",{
- scope: "/"
- });
-}
+// Victor Westerlund - www.victorwesterlund.com
+import { default as Preload } from "./modules/Preload.mjs";
+import { default as Interaction, destroy } from "./modules/UI.mjs";
-const theme = window.matchMedia("(prefers-color-scheme: dark)");
+// Load these assets when the DOM is ready (not needed right away)
+new Preload([
+ "modules/Modals.mjs",
+ "modules/Components.mjs",
+ "modal.css"
+]);
-// Set theme color
function updateTheme() {
- // Get theme color from stylesheet
- const color = window.getComputedStyle(document.body).getPropertyValue("--color-background");
- document.querySelector("meta[name='theme-color']").setAttribute("content",color);
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ document.body.classList.remove("dark");
+
+ // Force dark theme on all pages
+ if(media.matches) {
+ document.body.classList.add("dark");
+ return;
+ }
}
-// Set theme color and listen for changes
+// All default interactions
+const interactions = {
+ toggleMenu: () => {
+ const transition = 200;
+ const menu = document.getElementsByTagName("main")[0];
+
+ // Animate menu state change
+ menu.style.setProperty("transition",`${transition}ms`);
+ document.body.classList.toggle("menuActive");
+ // Remove transition CSS when finished. Wonky resize effects otherwise
+ setTimeout(() => menu.style.removeProperty("transition"),transition + 1);
+ },
+ // Open page defined with data-value as a card
+ newCard: (event) => {
+ const module = import("./modules/Modals.mjs");
+ const interactions = {
+ // Like newCard() except it closes the previous card
+ getContact: (event) => {
+ const service = event.target.dataset.value;
+ module.then(modals => {
+ event.target.closest(".modal").close();
+ const card = new modals.Card(interactions);
+ card.openPage(service);
+ });
+ },
+ // Copy text defined in data-value to clipboard and play animation
+ copyText: (event) => {
+ const copy = navigator.clipboard.writeText(event.target.dataset.value);
+ copy.then(() => {
+ event.target.classList.add("copied");
+ const copied = document.createElement("p");
+ copied.innerText = "copied!";
+ event.target.appendChild(copied);
+
+ // Reset button state
+ setTimeout(() => {
+ event.target.classList.remove("copied");
+ destroy(copied);
+ },1000);
+ });
+ }
+ };
+
+ // Create card and open the specified page asynchronously
+ module.then(modals => {
+ const card = new modals.Card(interactions);
+ card.openPage(event.target.dataset.value);
+ });
+ }
+}
+
+// Set the current page theme, and listen for changes
+const theme = window.matchMedia("(prefers-color-scheme: dark)");
theme.addEventListener("change",updateTheme);
-updateTheme(theme);
+
+new Interaction(interactions,document.body); // Initialize default interactions
+updateTheme();
\ No newline at end of file
diff --git a/public/assets/pages/contact.html b/public/assets/pages/contact.html
new file mode 100644
index 0000000..104487e
--- /dev/null
+++ b/public/assets/pages/contact.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
Signal
+
+
+
+
E-Mail
+
+
\ No newline at end of file
diff --git a/public/assets/pages/contact_email.html b/public/assets/pages/contact_email.html
new file mode 100644
index 0000000..a0be5ff
--- /dev/null
+++ b/public/assets/pages/contact_email.html
@@ -0,0 +1,65 @@
+
+
+
+
+
PGP key
+
+
+
hello@victorwesterlund.com
+
You can also here to send a mail directly from your mail app
+
+
+
+
copy email
+
\ No newline at end of file
diff --git a/public/assets/pages/contact_email_pgp.html b/public/assets/pages/contact_email_pgp.html
new file mode 100644
index 0000000..e9b2895
--- /dev/null
+++ b/public/assets/pages/contact_email_pgp.html
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/public/assets/pages/contact_email_pgp_view.html b/public/assets/pages/contact_email_pgp_view.html
new file mode 100644
index 0000000..36144b4
--- /dev/null
+++ b/public/assets/pages/contact_email_pgp_view.html
@@ -0,0 +1,31 @@
+
+
\ No newline at end of file
diff --git a/public/assets/pages/contact_signal.html b/public/assets/pages/contact_signal.html
new file mode 100644
index 0000000..9e9191f
--- /dev/null
+++ b/public/assets/pages/contact_signal.html
@@ -0,0 +1,76 @@
+
+
+
+
+4670-245-2459
+
Signal is a free and encrypted message platform with apps for all major platforms.
+
+
+
copy number
+
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 5259755..a754867 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,47 +1,74 @@
+
+ Victor Westerlund
+
-
-
-
- Victor Westerlund
+
+
+
-
-
-
+
+
+
+
+
+
+
+
victor westerlund
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
victor westerlund
+
+
+
-
victor westerlund
-
full-stack web developer
-
-
-
I create things with code. The things I've created for the public reside as open-source repositories on GitHub, the rest you'll be lucky to hear about some day.
-
Other topics (seemingly irrelevant to programming) I find facinating include but is in no way limited to astronomy, psychology, sociology, economics, ...
I create things with code. The things I've created for the public reside as open-source repositories on GitHub, the rest you might hear about from me some day.
+
Other topics (seemingly irrelevant to programming) I find facinating include but is in no way limited to astronomy, psychology, sociology, economics, ...
+
+
+
I create things with code. The things I've created for the public reside as open-source repositories on GitHub, the rest you might hear about from me some day.
+
Other topics (seemingly irrelevant to programming) I find facinating include but is in no way limited to astronomy, psychology, sociology, economics, ...