diff --git a/.gitignore b/.gitignore index 541fbbf..af65c73 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,49 @@ .well-known/ +/node_modules +/public/hot +/public/storage +/storage/*.key +/vendor +.env +.env.backup +.phpunit.result.cache +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +public/robots.txt + + +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Icon? +ehthumbs.db +Thumbs.db +.directory + +# Tool specific files # +####################### +# vim +*~ +*.swp +*.swo +# sublime text & textmate +*.sublime-* +*.stTheme.cache +*.tmlanguage.cache +*.tmPreferences.cache +# Eclipse +.settings/* +# JetBrains, aka PHPStorm, IntelliJ IDEA +.idea/* +# NetBeans +nbproject/* +# Visual Studio Code +.vscode +# Sass preprocessor +.sass-cache/ diff --git a/README.md b/README.md index 1ddf5ef..e9fcd58 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,5 @@ -![screenshot](https://storage.googleapis.com/public.victorwesterlund.com/github/VictorWesterlund/victorwesterlund.com/screenshot.png) - -## Source for [*victorwesterlund.com*](https://victorwesterlund.com) -![Website](https://img.shields.io/website?url=https%3A%2F%2Fvictorwesterlund.com) - -This repo contains the source code for the root-domain `victorwesterlund.com`. - -While some sub-domains host code which isn't open source, here's at least an explanation on what they're used for: - -Domain name|Description ---|-- -`public.victorwesterlund.com`|GCS bucket with public content *(the beautiful screenshot above)* -`*.github.victorwesterlun.com`|CNAME for my public repositories using [GitHub Pages](https://pages.github.com/) -`api.victorwesterlund.com`|Hosted endpoints (most of which contain the back-end component of my public repos) -`friday.victorwesterlund.com`|Self-hosted content -`info.victorwesterlund.com`|(old and useless) Google Pages site +
+ +

www.victorwesterlund.com

+

The source code for victorwesterlund.com

+
diff --git a/public/CNAME b/public/CNAME deleted file mode 100644 index 56bdaab..0000000 --- a/public/CNAME +++ /dev/null @@ -1 +0,0 @@ -www.victorwesterlund.com \ No newline at end of file diff --git a/public/assets/css/fonts.css b/public/assets/css/fonts.css deleted file mode 100644 index afd0844..0000000 --- a/public/assets/css/fonts.css +++ /dev/null @@ -1,17 +0,0 @@ -@font-face { - font-family: "Roboto Mono"; - font-weight: 400; - src: local("Roboto Mono Regular"), - local("RobotoMono-Regular"), - url("../fonts/RobotoMono-Regular.woff2"), - url("../fonts/RobotoMono-Regular.ttf"); -} - -@font-face { - font-family: "Roboto Mono"; - font-weight: 700; - src: local("Roboto Mono Bold"), - local("RobotoMono-Bold"), - url("../fonts/RobotoMono-Bold.woff2"), - url("../fonts/RobotoMono-Bold.ttf"); -} \ No newline at end of file diff --git a/public/assets/css/modal.css b/public/assets/css/modal.css new file mode 100644 index 0000000..1abd25b --- /dev/null +++ b/public/assets/css/modal.css @@ -0,0 +1,179 @@ +/* Victor Westerlund */ + +/* -- Transition overrides -- */ + +body main .screen { + transition: var(--transition) transform, var(--transition) filter; + transition-delay: calc(var(--transition) / 2); +} + +.modal.active + .modal:nth-child(n+2), +body .modal.active ~ main .screen { + transition: var(--transition); + transition-delay: 1ms; + transform: scale(.95); + pointer-events: none; + filter: blur(2px); +} + +.modal.active + .modal { + z-index: 10; +} + +.modal:first-child { + z-index: 15; +} + +.modal.active + .modal:nth-child(n+2) { + filter: blur(2px) brightness(.5); + z-index: 5; +} + +/* -- Boilerplate -- */ + +.modal { + transition: var(--transition) transform, var(--transition) filter; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100%; + z-index: 10; + pointer-events: none; + box-sizing: border-box; + padding: var(--padding); +} + +.modal.active { + pointer-events: all; +} + +.modal .button { + align-self: stretch; +} + +.modal .inner { + transition: var(--transition) transform, var(--transition) opacity; + position: relative; + background-color: var(--swatch-background); + width: calc(100vw - var(--padding)); + max-width: 500px; + max-height: 100%; + overflow-y: auto; + word-break: break-word; + box-sizing: border-box; + padding: var(--padding); + border-radius: var(--border-radius); + box-shadow: 0 3px 30px 0 rgba(33,33,33,.2); + opacity: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--padding); +} + +.modal.active .inner { + opacity: 1; +} + +.modal .inner > h1, +.modal .inner > h2, +.modal .inner > p { + text-align: center; +} + +/* ---- */ + +.spinner.logo { + --size: clamp(30px,5vw,60px); + --anim-speed: 1s; + align-self: center; + margin-top: var(--padding); + margin-left: calc((var(--size) / 2) * -1); + animation: logoSpinner var(--anim-speed) infinite alternate linear; +} + +.error { + text-align: center; + font-size: 20px; +} + +.error:first-line { + font-size: 50px; +} + +@keyframes logoSpinner { + to { + opacity: .1; + } +} + +/* ---- */ + +.modal h1 { + font-size: clamp(20px,2vw,20px); +} + +.modal pre { + align-self: stretch; + overflow: scroll; + background-color: black; + color: white; + padding: 10px 15px; + border-radius: 6px; +} + +/* -- Cards -- */ + +.modal.card .inner { + align-self: flex-end; + transform: scale(.99) translateY(1vh); +} + +.modal.card.active .inner { + transform: scale(1) translateY(0); +} + +.modal.card .button[data-action="close"] { + margin-top: auto; +} + +/* -- Dialogs -- */ + +.modal.dialog .inner { + transform: scale(.95); +} + +.modal.dialog.active .inner { + transform: scale(1); +} + +@media (min-aspect-ratio: 14/9) { + /* -- Transition overrides -- */ + + body .modal { + transition-delay: calc(var(--transition) / 2); + } + + body .modal.active { + transition-delay: 1ms; + } + + /* -- Boilerplate -- */ + + .modal.card .inner { + align-self: unset; + transform: scale(.99) translateY(10px); + } + + .modal.dialog .inner { + width: unset; + min-width: 100px; + max-width: 50vw; + } + + .modal.dialog .button { + align-self: unset; + width: clamp(100px,100%,500px); + } +} \ No newline at end of file diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 1da6436..ac30e44 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1,15 +1,57 @@ -@import url("fonts.css"); +/* Victor Westerlund */ :root { - --comp-background: 255,255,255; - --comp-highlight: 244,242,255; - --comp-accent: 33,33,33; + /* Component colors */ + --palette-background: 255,255,255; + --palette-inverted: 0,0,0; + --palette-contrast: 33,33,33; + --palette-accent: 22,183,255; - --color-background: rgb(var(--comp-background)); - --color-highlight: rgb(var(--comp-highlight)); - --color-accent: rgb(var(--comp-accent)); + /* Compiled colors */ + --swatch-background: rgb(var(--palette-background)); + --swatch-inverted: rgb(var(--palette-inverted)); + --swatch-contrast: rgb(var(--palette-contrast)); + --swatch-accent: rgb(var(--palette-accent)); - --page-padding: 5vw; + /* Default styles */ + --padding: 20px; + --border-radius: 10px; + --header-height: 100px; + --transition: 300ms; +} + +.dark { + --palette-background: 33,33,33; + --palette-inverted: 255,255,255; + --palette-contrast: 255,255,255; + --palette-accent: 255,255,255; + + --swatch-background: rgb(var(--palette-background)); + --swatch-inverted: rgb(var(--palette-inverted)); + --swatch-contrast: rgb(var(--palette-contrast)); + --swatch-accent: rgb(var(--palette-accent)); +} + +.wide { + display: none; /* Hide wide-screen elements */ +} + +@font-face { + font-family: "Roboto Mono"; + font-weight: 400; + src: local("Roboto Mono Regular"), + local("RobotoMono-Regular"), + url("../fonts/RobotoMono-Regular.woff2"), + url("../fonts/RobotoMono-Regular.ttf"); +} + +@font-face { + font-family: "Roboto Mono"; + font-weight: 700; + src: local("Roboto Mono Bold"), + local("RobotoMono-Bold"), + url("../fonts/RobotoMono-Bold.woff2"), + url("../fonts/RobotoMono-Bold.ttf"); } /* -- Cornerstones -- */ @@ -17,226 +59,438 @@ * { margin: 0; font-family: "Roboto Mono","Arial",sans-serif; - color: var(--color-accent); + color: var(--swatch-contrast); } *::selection { - background-color: var(--color-highlight); - color: var(--color-accent); + background-color: var(--swatch-contrast); + color: var(--swatch-accent); +} + +a, +picture { + text-decoration: none; + display: contents; } html, -body, -main, -main > div { +body { width: 100%; height: 100%; + overflow: hidden; + background-color: var(--swatch-background); +} + +main { + width: 200vw; + height: 100%; + overflow: hidden; + display: flex; +} + +body.menuActive { + background-color: var(--swatch-contrast); +} + +body.dark.menuActive { + background-color: black; +} + +body.menuActive main { + transform: translateX(-100vw); +} + +/* ---- */ + +.screen { + --background-pattern: + linear-gradient(90deg, var(--swatch-background) calc(var(--padding) + 1px), transparent 1%) center, + linear-gradient(var(--swatch-background) calc(var(--padding) + 1px), transparent 1%) center, var(--swatch-contrast); + --background-pattern-size: calc(var(--padding) + 2px) calc(var(--padding) + 2px); + + width: 100vw; + background-color: var(--swatch-background); + display: flex; + flex-direction: column; +} + +body.dark .screen.dark { + background-color: black; +} + +.screen .inner { + display: contents; +} + +.screen .content { + position: relative; + box-sizing: border-box; + padding: calc(var(--padding) * 1.5); + padding-top: 0; + flex-grow: 1; +} + +/* -- Positioning -- */ + +.center { + display: flex; + justify-content: center; + align-items: center; } /* ---- */ .logo { - --size: 5em; + --size: 1em; --skew: calc(var(--size) / 1.7); width: 0; height: 0; border: var(--skew) solid transparent; - border-top: var(--size) solid var(--color-accent); + border-top: var(--size) solid var(--swatch-accent); } .logo::after { content: ""; border: var(--skew) solid transparent; - border-top: var(--size) solid rgba(var(--comp-accent),.3); -} - -h1 { - font-size: 2em; -} - -h2 { - font-weight: normal; - font-size: 1.42em; -} - -h2 span { - color: var(--color-background); - background-color: var(--color-accent); -} - -/* -- Layout -- */ - -main { - --grid-spacing: 3vh; - - margin: auto; - max-width: 200vh; - background: radial-gradient(circle, rgba(var(--comp-accent),.2) .1vh, var(--color-background) .1vh),url("../img/pattern.gif"); - background-size: var(--grid-spacing) var(--grid-spacing),auto 90%; - background-position: 100% 100%; - background-repeat: repeat,no-repeat; - background-blend-mode: multiply; - image-rendering: pixelated; - image-rendering: -moz-crisp-edges; -} - -main * { - image-rendering: initial; -} - -main > div { - position: absolute; - width: 50%; - height: 100%; + border-top: var(--size) solid rgba(var(--palette-accent),.3); } /* ---- */ -#intro { - left: 0; - width: 100vh; +.button { + text-align: center; + padding: 25px; + border-radius: var(--border-radius); + font-size: clamp(16px,5vw,22px); + display: flex; + justify-content: center; + align-items: center; + gap: var(--padding); +} + +a.button, +p.button { + padding: unset; + display: inline; + font-size: inherit; + color: var(--swatch-accent); + text-decoration: underline; +} + +.button.solid { + background-color: var(--swatch-contrast); + color: var(--swatch-background); + fill: var(--swatch-background); +} + +.button.phantom { + background-color: rgba(var(--palette-inverted),.05); + color: var(--swatch-contrast); + fill: var(--swatch-contrast); +} + +.button svg { + pointer-events: none; + fill: inherit; + transform: scale(1.2); +} + +.button p { + pointer-events: none; + font-size: inherit; + color: inherit; +} + +.button.loading p { + opacity: 0; +} + +.button.loading::after { + position: absolute; + content: "loading..."; + opacity: 1; +} + +/* -- Screens -- */ + +header { + --size: var(--header-height,100px); box-sizing: border-box; - padding: var(--page-padding); + padding: var(--padding); + height: var(--size); display: flex; align-items: center; + font-weight: bold; +} + +header > *:nth-child(2) { + margin-left: 10px; +} + +header > *:nth-child(n+3) { + margin-left: var(--padding); +} + +header .hamburger { + width: calc(var(--size) - (var(--padding) * 2)); + height: calc(var(--size) - (var(--padding) * 2)); + box-sizing: border-box; + flex-shrink: 0; + padding: 15px; +} + +header .hamburger div { + width: 100%; + height: 2px; + background: var(--swatch-contrast); + box-shadow: 0 -10px 0 0 var(--swatch-contrast), 0 10px 0 0 var(--swatch-contrast); +} + +header .hamburger svg { + fill: none; + stroke: var(--swatch-contrast); + stroke-linecap: round; + stroke-width: 2; +} + +header .spacer { + width: 1px; + height: 80%; + background-color: rgba(var(--palette-contrast),.2); +} + +.dark header .spacer { + background-color: black; +} + +body.dark .dark header .spacer { + background-color: rgba(var(--palette-contrast),.2); +} + +header .logo { + --size: 25px; + margin-top: calc(var(--size) / 2); + margin-right: calc(var(--size) / 2); +} + +/* -- Screen > Landingpage -- */ + +.screen.landingpage { + background: var(--background-pattern); + background-size: var(--background-pattern-size); +} + +.screen.landingpage .content { + padding-bottom: 0; +} + +.screen.landingpage img { + position: relative; + width: clamp(100px,80vw,40vh); + align-self: flex-end; z-index: 1; } -#intro .inner { - display: flex; - height: 100%; - flex-direction: column; - justify-content: space-between; -} - -#intro .logo { - --size: 7vh; - margin-bottom: calc(var(--page-padding) / 2); -} - -#intro .block { - width: 100%; - font-size: 3vh; -} - -#intro .block:last-child { - display: flex; - align-items: flex-end; -} - -#intro .block p { - font-size: .7em; - margin-bottom: 1.5em; -} - -#intro .block p span { - margin-right: .9em; - background-color: var(--color-background); -} - -/* ---- */ - -nav a { - text-decoration: none; - margin-right: 2em; - font-size: .9em; -} - -nav a::after { - content: "→"; - padding-left: .5em; -} - -/* ---- */ - -#myface { - right: 0; - width: 50%; - display: flex; - justify-content: center; -} - -#myface picture, -#myface img { +.screen.landingpage .pattern { position: absolute; - height: 80%; - bottom: 0; - pointer-events: none; + top: var(--header-height); + width: 100vw; + height: calc(100% - var(--header-height)); + overflow: hidden; +} + +.screen.landingpage .pattern div { + --size: clamp(100px,100vw,35vh); + position: relative; + top: calc((var(--size) - var(--header-height)) * -1); + width: 0; + height: 0; + border: solid var(--size) transparent; + border-bottom: solid calc(var(--size) * 2) rgba(var(--palette-accent),.1); + transform-origin: 50% 75%; + transform: rotate(20deg); +} + +/* -- Screen > Menu -- */ + +.screen.menu .content { display: flex; - justify-content: center; + flex-direction: column; + align-items: center; + gap: 20px; } -#myface img { - height: 100%; +.screen.menu .button { + width: calc(100% - (var(--padding) * 2)); + max-width: 400px; + flex: 0; } -/* -- Size Queries -- */ +.screen.menu .button[data-value="contact"] { + margin-top: auto; +} -@media (max-width: 1100px) { - :root { - --page-padding: 50px; +/* WIP */ +.screen.menu .content > .narrow { + align-items: flex-start; + max-width: 900px; + gap: var(--padding); +} + +/* -- Media queries -- */ + +@media (max-width: 570px) { + .screen.menu .content > .narrow { + flex-direction: column; } +} - h1 { - font-size: 7vw; - } +/* Wide-screen */ +@media (min-aspect-ratio: 14/9) and (min-height: 300px) { + /* -- Cornerstones -- */ - h2 { - font-size: 5vw; - } - - /* ---- */ - - main { - max-width: unset; - background-position: 100% 0; - background-size: var(--grid-spacing) var(--grid-spacing), auto 100vw; - } - - /* ---- */ - - #intro { - width: initial; - } - - #intro .logo { - --size: 10vw; - } - - #intro p { - text-align: left; - } - - #intro .block p:last-of-type { - text-align: initial; - } - - #myface { + .narrow, + header { display: none; } - /* ---- */ + main { + width: 100vw; + flex-direction: row-reverse; + } - nav a { - text-decoration: none; - margin-right: 5vw; - font-size: 5vw; + /* -- Cornerstones > State overrides -- */ + + body.menuActive { + background-color: inherit; + } + + body.menuActive main { + transform: unset; + } + + /* -- Screens -- */ + + .screen.menu, + .screen.landingpage { + width: 50vw; + flex-direction: row; + background: var(--background-pattern); + background-size: var(--background-pattern-size); + } + + body.dark .screen { + --swatch-background: black; + } + + /* -- Screens > Menu -- */ + + body:not(.dark) .screen.menu { + /* Component colors */ + --palette-background: 255,255,255; + --palette-inverted: 0,0,0; + --palette-contrast: 33,33,33; + --palette-accent: 33,33,33; + + /* Compiled colors */ + --swatch-background: rgb(var(--palette-background)); + --swatch-inverted: rgb(var(--palette-inverted)); + --swatch-contrast: rgb(var(--palette-contrast)); + --swatch-accent: rgb(var(--palette-accent)); + } + + .screen.menu .content { + padding-top: calc(var(--padding) * 1.5); + } + + .screen.menu .wide { + display: flex; + flex-direction: column; + justify-content: space-between; + box-sizing: border-box; + padding: clamp(var(--padding),5vw,5vh); + width: 100%; + height: 100%; + overflow-y: auto; + } + + .screen.menu .wide .group { + display: flex; + flex-direction: column; + gap: var(--padding); + } + + .screen.menu .wide .logo { + --size: clamp(20px,3.5vw,5vh); + } + + .screen.menu .wide h1 { + margin: 0; + font-size: clamp(20px,3vw,5vh); + } + + .screen.menu .wide h1 span { + background: var(--swatch-contrast); + color: var(--swatch-background); + padding: 0 var(--padding); + } + + .screen.menu .wide p { + margin: 0; + font-size: clamp(16px,5vw,2vh); + } + + .screen.menu .wide nav { + display: flex; + margin-top: calc(var(--padding) * 2); + gap: clamp(var(--padding),3vw,500px); + } + + .screen.menu .wide nav p { + display: inline-block; + padding: var(--padding); + } + + .screen.menu .button[data-value="contact"] { + display: none; } } -/* -- Accessibility -- */ - -@media (hover: hover) { - nav a:hover { - background: var(--color-highlight); - font-weight: bold; +/* Narrow display */ +@media (max-width: 300px) { + .button svg:not(.hidden) ~ p, + header .logo { + display: none; } } -@media (prefers-color-scheme: dark) { - :root { - --comp-background: 0,0,0; - --comp-accent: 255,255,255; +/* Super-narrow display */ +@media (max-width: 230px) { + header { + justify-content: center; + } + + header .spacer, + header p { + display: none; } } + +/* -- Media queries > Media features -- */ + +@media (pointer: fine) { + .button { + cursor: pointer; + } +} + +@media (any-hover: hover) { + .button { + transition: var(--transition) background-color; + } + + .button.phantom:hover { + background-color: rgba(var(--palette-inverted),.2); + } +} \ No newline at end of file diff --git a/public/assets/img/icons/email.svg b/public/assets/img/icons/email.svg new file mode 100644 index 0000000..3e44b87 --- /dev/null +++ b/public/assets/img/icons/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/icons/signal.svg b/public/assets/img/icons/signal.svg new file mode 100644 index 0000000..fa9c720 --- /dev/null +++ b/public/assets/img/icons/signal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/js/modules/Components.mjs b/public/assets/js/modules/Components.mjs new file mode 100644 index 0000000..6e8667e --- /dev/null +++ b/public/assets/js/modules/Components.mjs @@ -0,0 +1,51 @@ +// Victor Westerlund - www.victorwesterlund.com + +// UI component constructor +class Component { + constructor(tag) { + this.element = document.createElement(tag); // Root element + } +} + +// ⬇ UI Components ⬇ + +export class Button extends Component { + constructor(properties) { + super("div"); + this.properties = properties; + this.element.classList.add("button"); + + this.setText(); + this.setAction(); + this.setType(); + } + + setText() { + if(!this.properties.text) { + return false; + } + const textElement = document.createElement("p"); + textElement.innerText = this.properties.text; + this.element.appendChild(textElement); + } + + setAction() { + if(!this.properties.action) { + return false; + } + this.element.setAttribute("data-action",this.properties.action); + } + + setType() { + const types = [ + "solid", + "phantom" + ]; + const type = types.includes(this.properties.type) ? this.properties.type : false; + + if(!this.properties.type || !type) { + return false; + } + this.element.classList.add(type); + } +} \ No newline at end of file diff --git a/public/assets/js/modules/Debugging.mjs b/public/assets/js/modules/Debugging.mjs new file mode 100644 index 0000000..1fb4294 --- /dev/null +++ b/public/assets/js/modules/Debugging.mjs @@ -0,0 +1,71 @@ +// Copyright © Victor Westerlund - No libraries! 😲 + +class Debug { + constructor() { + console.log("Debug mode is enabled.\nList debug functions by running window._debug.list()"); + } + + list() { + const functions = [ + "list", + "toggleMenu", + "openContactsModal", + "invalidCard", + "infiniteLoadingCard" + ]; + console.log("Available functions:",functions.map(f => `window._debug.${f}();`)); + } + + toggleMenu() { + document.getElementsByClassName("hamburger")[0].click(); + } + + openContactsModal() { + document.getElementsByClassName("hamburger")[0].click(); + document.querySelector("div[data-action='openContactCard']").click(); + } + + demoCard() { + const module = import("./Modals.mjs"); + const interactions = { + hello: () => { + console.log("Hello world"); + } + }; + + module.then(modals => { + const card = new modals.Card(interactions); + card.inner.style.height = "80vh"; + card.inner.insertAdjacentHTML("afterbegin","

Hello world

"); + card.open(); + }); + } + + invalidCard() { + const module = import("./Modals.mjs"); + const interactions = { + hello: () => { + console.log("Hello world"); + } + }; + + module.then(modals => { + const card = new modals.Card(interactions); + card.openPage("invalid_card"); + }); + } + + infiniteLoadingCard() { + const module = import("./Modals.mjs"); + const spinner = document.createElement("div"); + spinner.classList = "logo spinner"; + + module.then(modals => { + const card = new modals.Card(new Object()); + card.insertElement(spinner); + card.open(); + }); + } +} + +export default window._debug = new Debug(); \ No newline at end of file diff --git a/public/assets/js/modules/Logging.mjs b/public/assets/js/modules/Logging.mjs new file mode 100644 index 0000000..e59e49e --- /dev/null +++ b/public/assets/js/modules/Logging.mjs @@ -0,0 +1,34 @@ +// Victor Westerlund - www.victorwesterlund.com + +class Logging { + constructor() { + this.endpoint = "/log/"; + this.data = new URLSearchParams(); + + document.addEventListener("visibilitychange",() => { + if(document.visibilityState === "hidden") { + this.send(); + } + }); + + this.log("foo","bar"); + } + + log(key,value) { + this.data.append(key,value); + } + + send() { + const send = navigator.sendBeacon(this.endpoint,this.data); + if(send !== true) { + const url = this.endpoint + this.data.toString(); + fetch(url).catch(response => console.log(response)); + } + } +} + +export default class Log { + constructor(value,key = "u") { + // WIP + } +} \ No newline at end of file diff --git a/public/assets/js/modules/Modals.mjs b/public/assets/js/modules/Modals.mjs new file mode 100644 index 0000000..fdb0c83 --- /dev/null +++ b/public/assets/js/modules/Modals.mjs @@ -0,0 +1,191 @@ +// Victor Westerlund - www.victorwesterlund.com + +import { default as Interaction, destroy } from "./UI.mjs"; +import { Button } from "./Components.mjs"; + +// Boilerplate for creating element overlays +class Modal extends Interaction { + constructor(extendedInteractions = {}) { + const element = document.createElement("div"); + let interactions = { + close: () => { + this.close(); + }, + openPage: (event) => { + let modal = undefined; + switch(event.target.dataset.type) { + case "card": + modal = new Card({}); + break; + case "dialog": + default: + modal = new Dialog({}); + break; + } + modal.openPage(event.target.dataset.value); + } + }; + // Combine template and incoming interactions into one object + interactions = Object.assign(interactions,extendedInteractions); + super(interactions,element); + + this.transition = 300; + + this.element = this.applyTemplate(element); + this.element.close = () => this.close(); // Bind modal close to element prototype + document.body.insertAdjacentElement("afterbegin",this.element); + } + + // Fetch page html from "assets/pages" + async getPage(page) { + const url = `assets/pages/${page}`; + const response = await fetch(url); + if(!response.ok) { + const report = { + "self": "Modal.getPage()", + "self_page": page, + "resp_status": response.status, + "resp_statusText": response.statusText, + "resp_url": response.url, + "rqst_ua": navigator.userAgent + }; + throw new Error(JSON.stringify(report,null,2)); + } + return response.text(); + } + + insertHTML(element) { + this.inner.insertAdjacentHTML("afterbegin",element); + } + + insertElement(element) { + this.inner.insertAdjacentElement("afterbegin",element); + } + + // Apply a modal template to the provided element + applyTemplate(element) { + // The inner div will contain modal content + this.inner = document.createElement("div"); + this.inner.classList.add("inner"); + element.appendChild(this.inner); + element.classList.add("modal"); + + // PointerEvents on the outer div will close the modal + element.addEventListener("click",event => { + if(event.target == element) { + this.close(); + } + }); + + return element; + } + + error(message) { + const oops = document.createElement("p"); + const infoButton = document.createElement("p"); + + oops.classList.add("error"); + oops.innerText = "🤯\nSomething went wrong"; + + infoButton.innerText = "more info.."; + infoButton.addEventListener("click",() => { + const details = new Dialog(); + + details.insertHTML(`

📄 Error report

${message}
`); + 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 @@ + +

🔑 PGP Public Key

+

+

5466 B1EB 2F44 6D3D DC34 E9F7 5BE0 CB0B E3BB 69DA

+

show key

+
+ +

download .gpg

+
\ 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 @@ + +
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBF/K6MkBCACkRMhMfYdeNP+M3XQoZHQVJgippQvYZ4QqH6F6brWD5989Xy5W
+kDCvLbmPJ66boqB0dHExswOvMlhfFha65pRmfP6lIoIxZlZKwll1XASP2osS8f6r
+63T7hAbL3V2Dkm49tiH1tk578xGomDrxOrd4izpH4mn9AyBIL4M+5j34bKFVZKQ+
+QfMu7tduF/1oQHfDaXJeLXSfn5cNTy8DlLcLJKUSk4cjabf1D88gMVszqAAC5o1a
+fI0YxoyZ+Fv+CmyrQm2iIZ3+MyDU9JAvoImtlp1h5aNgbFRDi2vKcSlv158Hq97Z
+XlH1ttRZuFZiJzb8iukgUUFi4RORoXWt2rtNABEBAAG0LlZpY3RvciBXZXN0ZXJs
+dW5kIDxoZWxsb0B2aWN0b3J3ZXN0ZXJsdW5kLmNvbT6JATUEEAEIACkFAl/K6MkG
+CwkIBwMCCRBb4MsL47tp2gQVCAIKAxYCAQIZAQIbAwIeAQAAJ5MIAKDl9yHjwTO7
+20sDrPa6ECsSBU/FwkvkWecuauvY19/OqtacNk8dEeiITLeUeBXkvNzN+P0y8hoF
+ABZeir59dsY00iIp8gm03eLalhcblR5jYe3c08HssJH8PksczP3kitRNLvPAf2nU
+BYg3zca5Ka21/4BPRLFb9SAQGxfHyZdy3Poug+o+pokbeK2wLqqfSMtH+waBB6Lg
+2dRXuEnaZorUpNBpsahxastvNehv31Ke41Brvft15VKpO25GKZDPhm0odXMth1/J
+pzWRQtndazY2guB0Ft+5wujv28HFCgVgZn2fKiQVytAetO+/wzPijBkGRvdIE+Zb
+VRd3Nc0mHI65AQ0EX8royQEIALcoWEurmyXD2LoGvR+sYW+YPAPM6KG8KF4cWUn8
+8+kZ6F4FH9OW64di2npYe3x+zR7DgQ1yHXcmalAsP0nN4JWTavLwsSO+JAv8NpL5
+bgDs6fGaEQFl+X4fYOpkBkBmb1JrbnBk1a2u3qsEw8t7+wW1LG9z/Si5+G1KQko8
+x/PEaZ2ZVv7L51ZfIQRnMtl4vL5X23BPVsDywotvuFqlTiSjGP4CR0lVa5CRv3DJ
+FSmHxAxeI0vMMlwbIIUTrtwJR320sZvh2cRiwAXHQXm6l0ojzRnl46mmXnB3N6q9
+PyWOaUgPrMFjT24wtgopIOwbFAT3xTr1Un0FbdeaG9JhdJ8AEQEAAYkBHwQYAQgA
+EwUCX8royQkQW+DLC+O7adoCGwwAAIV/B/9OLYeQOxbXh1/hvW7/oTvN1py8wfFq
+buvQSrb/MZKm6lZgG+kQy3DWjGTi/xvNqDHfBiObFSGso8RHSbHFldzEuMgrgoWW
+/4JH1GDiKOp+rmBxfG30/DzOoFSfVcUfP5r8xNQby4Bh6zJhKPKVB3sZjO8cHNZD
+HcNAqT3Gh5yFzsUna+ZjvPF7iU5RF1YP46dsIdvuo4xFbHpEPoZs7wgZijf+vmKO
+lP61UFvKuXzwcLiI6s919EBJ9+7je8ZAxe6BCaazk+AhxXeokVvDgwQ150DNk4up
+1ftWZI0LHqEpVGNejQ09uu+TdC/ISy/Ti0XKlJDER1eUL577YRUl876Y
+=2qWm
+-----END PGP PUBLIC KEY BLOCK-----
\ 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

+
+
+
+
+
+ + + + + + + +
+
+ -
- - - - - - - -
- - + + diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index c2b2541..0000000 --- a/public/sw.js +++ /dev/null @@ -1,102 +0,0 @@ -const version = "1628250171"; -const root = "/"; - -let activeCaches = [ - `content-${version}` -]; - -let cacheManifest = [ - "index.html", - "assets/css/style.css", - "assets/css/fonts.css", - "assets/img/favicon.png", - "assets/img/pattern.gif", - "assets/fonts/RobotoMono-Bold.woff2", - "assets/fonts/RobotoMono-Regular.woff2", - "assets/js/script.js" -]; - -// Download assets and install ServiceWorker -self.addEventListener("install", event => { - event.waitUntil( - caches.open(`content-${version}`).then(cache => cache.addAll(cacheManifest.map(asset => { - // Append the root path to all assets - return root + asset; - }))) - ) -}); - -// Wipe old assets from Cache Storage -self.addEventListener("activate", event => { - event.waitUntil( - // Delete inactive caches - caches.keys().then(cacheNames => { - return Promise.all( - cacheNames.map(cacheName => { - if(!activeCaches.includes(cacheName)) { - return caches.delete(cacheName); - } - }) - ) - }) - ) -}); - -// Fetch and cache an asset not defined in the manifest -async function fetchToCache(event) { - const networkFetch = fetch(event.request); - - event.waitUntil( - networkFetch.then(response => { - const responseClone = response.clone(); - caches.open("bucket").then(cache => cache.put(event.request, responseClone)); - }) - ); - - const response = await caches.match(event.request); - return response || networkFetch; -} - -// Fetch and follow redirects without caching -async function fetchContent(url,i = 0) { - if(i >= 5) { - throw new Error("ERR_TOO_MANY_REDIRECTS"); - } - - return await fetch(url).then(response => { - if(response.redirected) { - i++; - return fetchContent(response.url,i); - } - return response; - }); -} - -self.addEventListener("fetch", event => { - const url = new URL(event.request.url); - const origin = (url.origin == location.origin) ? true : false; // Is same-origin - - // Speed up TTFB by serving index file first - if(origin && url.pathname == "/") { - event.respondWith(caches.match(root + "index.html")); - return; - } - - // Fetch cross-origin content using the network - if(!origin || (url.pathname.substring(1,7) != "assets")) { - event.respondWith(fetchContent(url.href)); - return; - } - - // Get pattern.gif from generator. Fallback to cache on failure - if(origin && url.pathname.endsWith("pattern.gif")) { - const pattern = new Request(`${location.origin}${root}assets/img/pattern.php`); - event.respondWith(fetch(pattern).catch(() => caches.match(root + "assets/img/pattern.gif"))); - return; - } - - // Respond with content from cache or fetch and save - event.respondWith( - caches.match(event.request).then(response => response || fetchToCache(event)) - ); -});