Version 9.0 (#17)

Release of version 9.0.0
This commit is contained in:
Victor Westerlund 2021-09-20 11:37:36 +02:00 committed by GitHub
parent 6645f2b672
commit 5c07d93535
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1465 additions and 354 deletions

48
.gitignore vendored
View file

@ -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/

View file

@ -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
<div align="center">
<img width="100px" src="https://storage.googleapis.com/public.victorwesterlund.com/github/VictorWesterlund/victorwesterlund.com/vw.svg"/>
<h3><strong><code>www.victorwesterlund.com</code></strong></h3>
<p>The source code for <a href="https://victorwesterlund.com">victorwesterlund.com</a></p>
</div>

View file

@ -1 +0,0 @@
www.victorwesterlund.com

View file

@ -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");
}

179
public/assets/css/modal.css Normal file
View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z"/></svg>

After

Width:  |  Height:  |  Size: 274 B

View file

@ -0,0 +1 @@
<svg version="1.1" id="katman_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 1024 1024" xml:space="preserve"><style>.st0{fill:#3a76f0}</style><path class="st0" d="M28.4 241.6v540.7c0 117.7 95.5 213.2 213.2 213.2h540.7c117.7 0 213.2-95.5 213.2-213.2V241.6c0-117.7-95.5-213.2-213.2-213.2H241.6c-117.7 0-213.2 95.5-213.2 213.2z"/><path d="M430.1 180.9l7.7 31c-30.2 7.4-59.1 19.4-85.7 35.4L335.7 220c29.3-17.7 61.2-30.9 94.4-39.1zm163.8 0l-7.7 31c30.2 7.4 59.1 19.4 85.7 35.4l16.5-27.4c-29.4-17.6-61.2-30.8-94.5-39zM220 335.7c-17.7 29.3-30.9 61.2-39.1 94.4l31 7.7c7.4-30.2 19.4-59.1 35.4-85.7L220 335.7zM202.9 512c0-15.5 1.1-31 3.5-46.3l-31.6-4.8c-5.1 33.9-5.1 68.3 0 102.2l31.6-4.8c-2.3-15.3-3.5-30.8-3.5-46.3zm485.4 292l-16.4-27.4c-26.6 16.1-55.4 28-85.6 35.4l7.7 31c33.2-8.2 65-21.3 94.3-39zm132.8-292c0 15.5-1.1 31-3.5 46.3l31.6 4.8c5.1-33.9 5.1-68.3 0-102.2l-31.6 4.8c2.3 15.3 3.5 30.8 3.5 46.3zm22 81.8l-31-7.7c-7.4 30.2-19.4 59.1-35.4 85.7l27.4 16.5c17.6-29.3 30.8-61.2 39-94.5zM558.3 817.6c-30.7 4.6-61.9 4.6-92.6 0l-4.8 31.6c33.9 5.1 68.3 5.1 102.2 0l-4.8-31.6zm202.5-122.3c-18.4 25-40.5 47-65.5 65.4l19 25.7c27.6-20.3 51.9-44.5 72.3-72l-25.8-19.1zm-65.5-432.1c25 18.4 47.1 40.5 65.5 65.5l25.7-19.2c-20.3-27.5-44.6-51.8-72-72l-19.2 25.7zm-432.1 65.5c18.4-25 40.5-47.1 65.5-65.5l-19.2-25.7c-27.5 20.3-51.8 44.6-72 72l25.7 19.2zm540.8 7l-27.4 16.4c16.1 26.6 28 55.4 35.4 85.6l31-7.7c-8.1-33.2-21.3-65-39-94.3zM465.7 206.4c30.7-4.6 61.9-4.6 92.6 0l4.8-31.6c-33.9-5.1-68.3-5.1-102.2 0l4.8 31.6zM279.6 795l-66 15.4 15.4-66-31.1-7.3-15.4 66c-2.5 10.8.7 22.1 8.5 29.9s19.1 11 29.9 8.5l66-15.1-7.3-31.4zm-75.1-86.5l31.1 7.2 10.7-45.8c-15.5-26.1-27.1-54.4-34.4-83.9l-31 7.7c7 28.3 17.5 55.5 31.4 81l-7.8 33.8zm149.2 69.3L308 788.5l7.2 31.1 33.7-7.8c25.6 13.9 52.8 24.5 81.1 31.4l7.7-31c-29.4-7.3-57.6-19-83.7-34.6l-.3.2zM512 234.9c-100.8.1-193.7 54.9-242.4 143.1s-45.7 196 8 281.4L251 773.1l113.7-26.6c99.7 62.8 228.2 55.7 320.4-17.7S812.9 531.7 774 420.5c-39.2-111.3-144.2-185.7-262-185.6z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -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);
}
}

View file

@ -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","<p>Hello world</p>");
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();

View file

@ -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
}
}

View file

@ -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(`<h1>📄 Error report</h1><pre>${message}</pre>`);
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);
}
}

View file

@ -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);
});
}
}

View file

@ -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);
}
}

View file

@ -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();

View file

@ -0,0 +1,45 @@
<!-- Victor Westerlund - www.victorwesterlund.com -->
<style>
.contact {
align-self: stretch;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--padding);
}
.contact .item {
--size: 1fr;
width: var(--size);
height: var(--size);
border-radius: var(--border-radius);
background-color: rgba(var(--palette-inverted),.05);
flex-direction: column;
justify-content: space-between;
box-sizing: border-box;
padding: var(--padding);
}
.contact .item * {
pointer-events: none;
}
.contact .item img {
height: 8vh;
}
@media (max-width: 300px) {
.contact {
grid-template-columns: 1fr;
}
}
</style>
<div class="contact">
<div class="item button phantom" data-action="getContact" data-value="contact_signal">
<img src="assets/img/icons/signal.svg"/>
<p>Signal</p>
</div>
<div class="item button phantom" data-action="getContact" data-value="contact_email">
<img src="assets/img/icons/email.svg"/>
<p>E-Mail</p>
</div>
</div>

View file

@ -0,0 +1,65 @@
<!-- Victor Westerlund - www.victorwesterlund.com -->
<style>
.button.copied {
pointer-events: none;
animation: beat 500ms forwards;
animation-delay: 200ms;
}
.button.copied svg,
.button.copied p:first-of-type {
opacity: 0;
}
.button.copied p:last-of-type {
position: absolute;
animation: slide 1000ms forwards;
}
@keyframes beat {
0% { transform: scale(1); }
25% { transform: scale(.95); }
55% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@keyframes slide {
0% { transform: translateY(calc(var(--padding))); opacity: 0; }
30% { transform: translateY(0); opacity: 1; }
70% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(calc(var(--padding) * -1)); opacity: 0; }
}
a.button {
color: var(--swatch-accent);
cursor: pointer;
}
a.button::before {
content: "tap ";
}
@media (max-width: 300px) {
.button.copied p:last-of-type {
display: initial;
}
}
@media (pointer: fine) {
a.button::before {
content: "click ";
}
}
</style>
<div class="button phantom" data-action="openPage" data-value="contact_email_pgp" data-type="card">
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>
<p>PGP key</p>
</div>
<p></p>
<h1>hello@victorwesterlund.com</h1>
<p>You can also <a class="button" href="mailto:hello@victorwesterlund.com?subject=Hello Victor!">here</a> to send a mail directly from your mail app</p>
<p></p>
<div class="button solid" data-action="copyText" data-value="hello@victorwesterlund.com">
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M18 2H9c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h9c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H9V4h9v12zM3 15v-2h2v2H3zm0-5.5h2v2H3v-2zM10 20h2v2h-2v-2zm-7-1.5v-2h2v2H3zM5 22c-1.1 0-2-.9-2-2h2v2zm3.5 0h-2v-2h2v2zm5 0v-2h2c0 1.1-.9 2-2 2zM5 6v2H3c0-1.1.9-2 2-2z"/></svg>
<p>copy email</p>
</div>

View file

@ -0,0 +1,9 @@
<!-- Victor Westerlund - www.victorwesterlund.com -->
<h1>🔑 PGP Public Key</h1>
<p></p>
<p>5466 B1EB 2F44 6D3D DC34 E9F7 5BE0 CB0B E3BB 69DA</p>
<p class="button" data-action="openPage" data-value="contact_email_pgp_view" data-type="dialog">show key</p>
<a href="https://storage.googleapis.com/public.victorwesterlund.com/publickey.gpg"><div class="button solid">
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z"/></svg>
<p>download .gpg</p>
</div></a>

View file

@ -0,0 +1,31 @@
<!-- Victor Westerlund - www.victorwesterlund.com -->
<pre>
-----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-----</pre>

View file

@ -0,0 +1,76 @@
<!-- Victor Westerlund - www.victorwesterlund.com -->
<style>
body:not(.dark) .modal[data-page="contact_signal"] .inner {
--palette-inverted: 255,255,255;
--palette-background: 58,118,240;
--swatch-background: rgb(var(--palette-background));
--swatch-inverted: rgb(var(--palette-inverted));
}
body:not(.dark) .modal[data-page="contact_signal"] .button.solid {
background-color: var(--swatch-inverted);
color: var(--swatch-background);
}
.modal .inner > h1,
.modal .inner > p,
.modal .button.phantom {
color: var(--swatch-inverted);
}
/* ---- */
#logo_signal {
width: clamp(100px,50%,200px);
}
#number {
background: black;
padding: 10px 15px;
border-radius: 6px;
}
.button.copied {
pointer-events: none;
animation: beat 500ms forwards;
animation-delay: 200ms;
}
.button.copied svg,
.button.copied p:first-of-type {
opacity: 0;
}
.button.copied p:last-of-type {
position: absolute;
animation: slide 1000ms forwards;
}
@keyframes beat {
0% { transform: scale(1); }
25% { transform: scale(.95); }
55% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@keyframes slide {
0% { transform: translateY(calc(var(--padding))); opacity: 0; }
30% { transform: translateY(0); opacity: 1; }
70% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(calc(var(--padding) * -1)); opacity: 0; }
}
@media (max-width: 300px) {
.button.copied p:last-of-type {
display: initial;
}
}
</style>
<img id="logo_signal" src="assets/img/icons/signal.svg"/>
<h1 id="number">+4670-245-2459</h1>
<p>Signal is a free and encrypted message platform with apps for all major platforms.</p>
<div class="button solid" data-action="copyText" data-value="+46702452459">
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M18 2H9c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h9c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H9V4h9v12zM3 15v-2h2v2H3zm0-5.5h2v2H3v-2zM10 20h2v2h-2v-2zm-7-1.5v-2h2v2H3zM5 22c-1.1 0-2-.9-2-2h2v2zm3.5 0h-2v-2h2v2zm5 0v-2h2c0 1.1-.9 2-2 2zM5 6v2H3c0-1.1.9-2 2-2z"/></svg>
<p>copy number</p>
</div>

View file

@ -1,47 +1,74 @@
<!DOCTYPE html>
<!-- Victor Westerlund - www.victorwesterlund.com -->
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Victor Westerlund</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Full-stack web developer from Stockholm, Sweden.">
<meta name="theme-color" content="#ffffff">
<link href="assets/img/favicon.png" rel="icon">
<link href="assets/css/style.css" rel="stylesheet">
<title>Victor Westerlund</title>
<link rel="icon" href="assets/img/favicon.png">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<main>
<div id="intro">
<div class="inner">
<div class="block">
<div class="screen landingpage">
<header>
<div class="hamburger center" data-action="toggleMenu" data-theme-color="contrast">
<div></div>
</div>
<div class="spacer"></div>
<div class="logo"></div>
<p>victor westerlund</p>
</header>
<div class="content center">
<div class="pattern center">
<div></div>
</div>
<picture>
<source srcset="assets/img/myface/highres.avif" type="image/avif" media="(min-width: 1920px)">
<source srcset="assets/img/myface/highres.webp" type="image/webp" media="(min-width: 1920px)">
<source srcset="assets/img/myface/mediumres.avif" type="image/avif">
<source srcset="assets/img/myface/mediumres.webp" type="image/webp">
<img src="assets/img/myface/mediumres.png" type="image/png">
</picture>
</div>
</div>
<div class="screen menu dark">
<header>
<div class="hamburger center" data-action="toggleMenu" data-theme-color="background">
<svg xmlns="http://www.w3.org/2000/svg" width="28.548" height="22.828"><path d="M2.28 11.414h25.269M1.414 11.414l10-10M1.414 11.414l10 10"/></svg>
</div>
<div class="spacer"></div>
<div class="logo"></div>
<p>victor westerlund</p>
</header>
<div class="content">
<div class="wide">
<div class="logo"></div>
<h1><span>victor westerlund</span></h1>
<h2><span>full-stack web developer</span></h2>
</div>
<div class="block">
<p><span>I create things with code. The things I've created for the public reside as open-source repositories on <a href="https://github.com/VictorWesterlund" ping="https://api.victorwesterlund/log/ping/" target="_blank">GitHub</a>, the rest you'll be lucky to hear about some day.</span></p>
<p><span>Other topics (seemingly irrelevant to programming) I find facinating include but is in no way limited to astronomy, psychology, sociology, economics, ...</span></p>
<p><span><strong>hello@victorwesterlund.com</strong></span>&#8203;(<a href="https://storage.googleapis.com/public.victorwesterlund.com/publickey.gpg" ping="https://api.victorwesterlund/log/ping/" target="_blank">PGP&nbsp;Key</a>)</p>
</div>
<div class="block">
<div class="group">
<h1><span>victor westerlund</span></h1>
<p>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.</p>
<p>Other topics (seemingly irrelevant to programming) I find facinating include but is in no way limited to astronomy, psychology, sociology, economics, ...</p>
</div>
<nav>
<!--<a href="#">contact</a>-->
<a href="https://github.com/VictorWesterlund" ping="https://api.victorwesterlund/log/ping/" target="_blank">github</a>
<p class="button" data-action="newCard" data-value="contact_signal">signal</p>
<p class="button" data-action="newCard" data-value="contact_email">email</p>
<a href="https://github.com/VictorWesterlund" target="_blank"><p class="button">github&nbsp;</p></a>
</nav>
</div>
<div class="narrow center">
<p>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.</p>
<p>Other topics (seemingly irrelevant to programming) I find facinating include but is in no way limited to astronomy, psychology, sociology, economics, ...</p>
</div>
<div class="button phantom" data-action="newCard" data-value="contact">
<p>contact me</p>
</div>
</div>
</div>
<div id="myface">
<picture>
<source srcset="assets/img/myface/highres.avif" type="image/avif" media="(min-width: 1920px)">
<source srcset="assets/img/myface/highres.webp" type="image/webp" media="(min-width: 1920px)">
<source srcset="assets/img/myface/mediumres.avif" type="image/avif">
<source srcset="assets/img/myface/mediumres.webp" type="image/webp">
<img src="assets/img/myface/mediumres.png" type="image/png">
</picture>
</div>
</main>
<script src="assets/js/script.js" type="module"></script>
<script src="assets/js/nomodule.js" nomodule></script>
<script type="module" src="assets/js/script.js"></script>
<script nomodule src="assets/js/nomodule.js"></script>
</body>
</html>

View file

@ -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))
);
});