Version 11 (#23)

* wip(22w6a): add glitch bg

* wip(22w6b): add content and more glitching

* wip(22w7a): add card backdrop-filter

* wip(22w7b): add visibilitychange event

* wip(22w7c): add link click handler

* wip(22w7d): add webkit backdrop filter support

* wip(22w7e): fix font size

* wip(22w7f): refactor glitch

* wip(22w7g): add forceBg to glitch

* wip(22w7h): add error page

* wip(22w7i): hide contact button

* feat: add OGP tags

Co-authored-by: Cloud Shell <cloud-shell@victor-westerlund.iam.gserviceaccount.com>
This commit is contained in:
Victor Westerlund 2022-02-22 04:46:05 -08:00 committed by GitHub
parent 1dd61b7596
commit 36345199e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 386 additions and 665 deletions

0
.github/ISSUE_TEMPLATE/lighthouse.md vendored Normal file → Executable file
View file

0
.gitignore vendored Normal file → Executable file
View file

0
LICENSE Normal file → Executable file
View file

0
README.md Normal file → Executable file
View file

View file

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Victor Westerlund - About</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Full-stack web developer from Stockholm, Sweden.">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:no-preference)">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:light)">
<link rel="icon" href="assets/media/favicon-light.png" media="(prefers-color-scheme:dark)">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<p><a href="/">victor westerlund</a></p>
</header>
<main>
<p>...</p>
</main>
<footer>
<p><a href="about">about</a></p>
<p><a href="search">search</a></p>
<p><a class="arrow" href="more">more</a></p>
</footer>
</body>
</html>

View file

@ -1,20 +0,0 @@
.button {
background-color: rgb(var(--color-contrast));
color: rgb(var(--color-background));
padding: var(--padding) calc(var(--padding) * 2);
}
/* -- Media Queries -- */
@media (pointer: fine) {
.button {
cursor: pointer;
}
}
@media (hover: hover) {
.button:hover {
background-color: rgba(var(--color-contrast),.11);
color: rgb(var(--color-contrast));
}
}

View file

@ -1,59 +0,0 @@
main {
align-self: start;
width: 100%;
max-width: 900px;
}
#search {
width: 100%;
height: 80px;
color: rgb(var(--color-background));
background-color: rgb(var(--color-contrast));
}
input[type="search"] {
width: 100%;
height: 100%;
font-size: 18px;
color: inherit;
padding: 0 calc(var(--padding) * 1.5);
background-color: transparent;
border: none;
outline: none;
}
input[type="search"]::selection {
color: rgb(var(--color-contrast));
background-color: rgb(var(--color-background));
}
input[type="search"]::placeholder {
color: rgba(var(--color-background),.4);
}
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
height: 32px;
width: 32px;
background: center no-repeat rgba(var(--color-background),.1) url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAQAAABKfvVzAAAAAXNSR0IArs4c6QAAAFRJREFUeNrlkrkNADEIBK8JunD/GVW4oLnMAViagNBkLDsS3/d4sEiiqUGy7kACmyj2DeQRavEgTRGkZ4oUuyPF7ojYBRi35ENP1+qHG7+GP9/b8QPsDLZNvX0megAAAABJRU5ErkJggg==');
cursor: pointer;
}
#results {
text-align: center;
font-size: 18px;
padding: 0 var(--padding);
}
/* -- Media Queries -- */
@media (prefers-color-scheme: dark) {
#search {
color: rgb(var(--color-contrast));
background-color: rgba(var(--color-contrast),.15);
}
input[type="search"]::placeholder {
color: rgba(var(--color-contrast),.4);
}
}

235
public/assets/css/style.css Normal file → Executable file
View file

@ -1,10 +1,9 @@
:root {
--color-background: 255,255,255;
--color-contrast: 33,33,33;
--color-base: 0, 0, 0;
--color-contrast: 256, 256, 256;
--padding: 20px;
--running-height: 100px;
--footer-denom: 1;
--padding: clamp(40px, 2vw, 2vw);
--border-size: clamp(4px, .25vw, .25vw);
}
/* -- Cornerstones -- */
@ -17,7 +16,7 @@
*::selection {
background-color: rgb(var(--color-contrast));
color: rgb(var(--color-background));
color: rgb(var(--color-base));
}
html,
@ -27,116 +26,180 @@ body {
overflow-x: hidden;
}
body {
display: grid;
grid-template-rows: var(--running-height) 1fr calc(var(--running-height) / var(--footer-denom));
background-color: rgb(var(--color-background));
place-items: center;
justify-items: center;
font-size: 21px;
html {
background-color: rgba(var(--color-base), .7);
background-size: cover;
background-blend-mode: overlay;
background-position: center;
background-attachment: fixed;
}
body > * {
box-sizing: border-box;
}
p,
a {
color: inherit;
text-decoration: none;
}
a {
picture {
display: contents;
}
h1 {
font-size: clamp(45px, 7vw, 6vh);
}
p, a {
font-size: clamp(20px, 3vw, 2vh);
text-align: justify;
}
/* -- Components -- */
main {
body {
display: flex;
flex-direction: column;
align-items: center;
justify-items: center;
gap: var(--padding, 30px);
}
.arrow {
display: inline;
font-weight: bold;
body > div {
padding: calc(var(--padding) / 2);
}
.arrow::after {
content: " →";
:is(#intro, #card) a {
--padding-vert: clamp(17px, 1.1vw, 1.1vw);
display: inline-block;
text-decoration: none;
text-align: center;
user-select: none;
background-color: rgba(var(--color-contrast), .13);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
box-shadow:
inset 0 .3vh 1.6vh rgba(0, 0, 0, 0),
0 .1vh .3vh rgba(0, 0, 0, .12),
0 .1vh .2vh rgba(0, 0, 0, .24);
}
footer {
justify-self: end;
padding: 0 calc(var(--running-height) / (var(--footer-denom) * 2));
/* --- */
#intro {
padding: calc(var(--padding) / 2);
}
footer p:not(:last-child) {
display: none;
#intro a {
padding: var(--padding-vert) 2vw;
border-radius: 100px;
border: solid var(--border-size) rgba(var(--color-contrast), 0);
margin: var(--padding) 0;
width: calc(100% - ((var(--padding) / 2) + var(--border-size)));
}
#intro p {
margin: 1vh 0;
font-size: clamp(20px, 3vw, 3vh);
}
/* --- */
#card,
#card > div {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(var(--padding) / 2);
}
#card {
--portrait-size: clamp(128px, 12vw, 12vh);
position: relative;
max-width: 600px;
padding: var(--padding);
border-radius: clamp(18px, 1vw, 1vw);
backdrop-filter: saturate(100) brightness(.4);
-webkit-backdrop-filter: saturate(100) brightness(.4);
border: solid var(--border-size) rgba(var(--color-contrast), .1);
box-shadow: 0 1vh 2vh rgba(0, 0, 0, .19), 0 6px 6px rgba(0, 0, 0, .23);
}
#card img {
width: var(--portrait-size);
height: var(--portrait-size);
position: absolute;
border-radius: 100%;
top: calc(((var(--portrait-size) / 2) + var(--border-size)) * -1);
background-color: rgb(var(--color-base));
box-shadow: 0 1vh 2vh rgba(0, 0, 0 , .19), 0 6px 6px rgba(0, 0, 0 , .23);
}
#card a {
width: 100%;
padding: var(--padding-vert) 0;
margin-top: calc(var(--padding) / 2);
border-radius: clamp(9px, .5vw, .5vw);
}
/* -- Media Queries -- */
@media (prefers-color-scheme: dark) {
:root {
--color-background: 0,0,0;
--color-contrast: 255,255,255;
@supports ((backdrop-filter: saturate) and (backdrop-filter: brightness)) or ((-webkit-backdrop-filter: saturate) and (-webkit-backdrop-filter: brightness)) {
#card {
background-color: rgba(var(--color-base), .7);
}
}
@media (pointer: fine) {
a:hover {
background: rgba(var(--color-contrast),.1);
}
:is(#intro, #card) a {
--transition-speed: 200ms;
transition:
var(--transition-speed) background-color,
var(--transition-speed) box-shadow,
var(--transition-speed) border-color;
}
@media print {
.arrow::after {
content: ": " attr(href);
}
:is(#intro, #card) a:hover {
background-color: rgba(var(--color-contrast), .2);
border-color: rgba(var(--color-contrast), .2);
box-shadow:
inset 0 .3vh 1.6vh rgba(0, 0, 0, .16),
0 .3vh .6vh rgba(0, 0, 0, .16),
0 .3vh .6vh rgba(0, 0, 0, .23);
}
@media (max-width: 300px) {
body > * {
justify-self: center;
text-align: center;
}
}
@media (min-aspect-ratio: 14/9) and (min-height: 500px) {
:root {
--footer-denom: 2;
}
footer {
width: 100%;
height: 100%;
gap: calc(var(--padding) * 1.5);
font-size: 19px;
color: rgb(var(--color-background));
background-color: rgb(var(--color-contrast));
display: flex;
align-items: center;
}
footer p:not(:last-child) {
display: initial;
}
footer p:last-child {
margin-left: auto;
}
footer p:first-child:last-child {
margin-left: auto;
margin-right: auto;
}
}
@media (min-aspect-ratio: 14/9) and (min-height: 500px) and (prefers-color-scheme: dark) {
footer {
color: rgb(var(--color-contrast));
:is(#intro, #card) a:active {
background-color: rgba(var(--color-contrast), .15);
}
}
@media (max-width: 330px) {
p, a {
text-align: left;
font-size: 18px;
}
#card {
padding: calc(var(--padding) / 2);
}
}
@media (min-aspect-ratio: 14/9) and (min-height: 600px) {
body {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: unset;
}
body > div {
display: grid;
align-items: center;
}
body > div:last-of-type {
padding: calc(var(--padding) * 2);
}
#intro a {
width: unset;
}
#card {
min-width: 300px;
max-width: 30vw;
}
}

View file

@ -0,0 +1,79 @@
// Fetch and create glitchy background effects
class Generator {
constructor() {
this.bg = {
_this: this,
_image: null,
_dir: location,
_dir_rel: "assets/media/b64/",
count: 4,
// Get or set current background
get current () { return this._image; },
set current (image) {
this._image = image;
this._this.setBg(image);
},
// Get or set the path to where base64 images are stored
get dir () { return this._dir; },
set dir (newPath) {
const url = new URL(newPath);
// Replace pathname of this file with relative path to assets
const path = url.pathname.split("/");
path[path.length - 1] = this._dir_rel;
url.pathname = path.join("/");
this._dir = url.toString();
}
}
}
// Genrate random int in range
static randInt(min, max) {
if(min === max) return min;
return Math.round(Math.random() * (max - min) + min);
}
// Generate random string of length from charset
static randStr(length = 2) {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let output = "";
for(let i = 0; i < length; i++) {
output += charset.charAt(Math.floor(Math.random() * charset.length));
}
return output;
}
// Give generated background image to parent thread
setBg(image) {
if(typeof image !== "string") throw new TypeError("Image must be of type 'string'");
postMessage(["BG_UPDATE", image]);
}
// Generate and set a glitchy image
glitch() {
if(!this.bg.current) return;
const image = this.bg.current.replaceAll(Generator.randStr(), Generator.randStr());
this.setBg(image);
}
// Fetch a base64 encoded background image
async fetchBg(id) {
const url = new URL(this.bg.dir);
url.pathname += id + ".txt";
const image = await fetch(url);
if(!image.ok) throw new Error("Failed to fetch background image");
return image.text();
}
// Load a random background from the image set
async randBg() {
const id = Generator.randInt(1, this.bg.count);
const image = await this.fetchBg(id);
this.bg.current = image;
}
}

View file

@ -0,0 +1,41 @@
export default class Glitch {
constructor(target) {
this.worker = new Worker(this.getWorkerScriptURL());
this.worker.addEventListener("message", event => this.message(event));
this.target = target ? target : document.body;
}
// Update the target CSS background with an image URL
setVisibleBg(image) {
this.target.style.setProperty("background-image", `url(${image})`);
}
// Get URL for the dedicated worker
getWorkerScriptURL() {
const name = "GlitchWorker.js";
const url = new URL(import.meta.url);
// Replace pathname of this file with worker
const path = url.pathname.split("/");
path[path.length - 1] = name;
url.pathname = path.join("/");
return url.toString();
}
// Event handler for messages from worker thread
message(event) {
const data = typeof event.data === "object" ? event.data : [event.data];
switch(data[0]) {
case "READY":
this.worker.postMessage(["START", new URL(location).toString()]);
break;
case "BG_UPDATE":
this.setVisibleBg(data[1]);
break;
}
}
}

View file

@ -0,0 +1,54 @@
importScripts("./Generator.mjs");
class GlitchWorker extends Generator {
constructor() {
super();
// Delay between these values
this.config = {
glitch: { min: 500, max: 2500 },
randBg: { min: 5000, max: 5000 }
}
this._timers = {};
self.addEventListener("message", event => this.message(event));
self.postMessage("READY");
}
// Run a scoped function on a random interval between
queue(func) {
clearTimeout(this._timers[func]);
const next = Generator.randInt(this.config[func].min, this.config[func].max);
this._timers[func] = setTimeout(() => this.queue(func), next);
this[func]?.();
}
// Set background by id and stop randBg animation
async forceBg(id) {
clearTimeout(this._timers.randBg);
const image = await this.fetchBg(id);
this.bg.current = image;
this.setBg(image);
}
// Event handler for messages from parent thread
message(event) {
const data = typeof event.data === "object" ? event.data : [event.data];
switch(data[0]) {
case "START":
this.bg.dir = data[1];
this.randBg();
for(const func of Object.keys(this.config)) {
this.queue(func);
}
break;
}
}
}
self.glitch = new GlitchWorker();

View file

@ -1,96 +0,0 @@
export default class Search {
constructor(input,results) {
const self = this;
this.endpoint = new URL("api/search",window.location.href);
this.lastQuery = "";
this.throttle = null;
this.controller = null; // AbortController will be assigned here
this.results = results;
this.input = input;
this.input?.addEventListener("keyup",event => this.keyEvent(event)) ?? false;
}
// Destroy the result DOM tree
clearResults() {
while(this.results.firstChild) {
this.results.removeChild(this.results.lastChild);
}
}
// Display output as HTML
output(html) {
this.clearResults();
if(typeof html === "string") {
this.results.insertAdjacentHTML("beforeEnd",html);
return;
}
this.results.appendChild(html);
}
// Display a status message in a paragraph
status(text,classList = false) {
const element = document.createElement("p");
if(classList !== false) {
element.classList = classList;
}
element.innerText = text;
this.output(element);
}
// Fetch search results from endpoint
async search(query) {
const url = new URL(this.endpoint);
url.searchParams.set("q",query);
const timeout = new Promise(reject => setTimeout(() => reject("Request timed out"),3000));
// Fetch response from server
const api = fetch(url,{
signal: this.controller.signal,
headers: {
"Content-Type": "text/html"
}
});
const result = Promise.race([api,timeout]);
result.then(response => {
if(!response.ok) {
this.status("oh no, something went wrong","error");
throw new Error("Invalid response from server");
}
return response.text();
})
.then(html => this.output(html))
.catch(error => {});
}
// Wait until the user stops typing for a few miliseconds
queue(query) {
clearTimeout(this.throttle);
this.controller = new AbortController(); // Spawn a new AbortController for each fetch
this.throttle = setTimeout(() => this.search(query),500);
}
keyEvent(event) {
const query = event.target.value;
// Don't do the search thing if query is too weak
if(query.length < 1) {
this.controller.abort(); // Abort queued search
this.lastQuery = "";
this.status("search results will appear here as you type");
return;
}
// Pressing a modifier key (Ctrl, Shift etc.) doesn't change the query
if(query === this.lastQuery) {
return false;
}
this.lastQuery = query;
this.status("searching..");
this.queue(query);
}
}

0
public/assets/js/noscript.js Normal file → Executable file
View file

View file

@ -0,0 +1,14 @@
import { default as Glitch } from "./glitch/Glitch.mjs";
const logging = "https://victorwesterlund-logging-dnzfgzf6za-lz.a.run.app";
// Log link clicks
for(let link of document.getElementsByTagName("a")) {
link.addEventListener("click", event => {
event.preventDefault();
navigator?.sendBeacon(logging, event);
window.location.href = event.target.href;
});
}
window.glitch = new Glitch(document.body.parentElement);

View file

@ -1,9 +0,0 @@
import { default as Search } from "./modules/Search.mjs";
const searchBox = document.getElementById("search")?.getElementsByTagName("input")[0] ?? false;
const resultsContainer = document.getElementById("results");
new Search(searchBox,resultsContainer);
// Set focus on searchbox when typing from anywhere
window.addEventListener("keydown",() => searchBox.focus());

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

0
public/assets/media/favicon-dark.png Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 628 B

After

Width:  |  Height:  |  Size: 628 B

0
public/assets/media/favicon-light.png Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="81.968" height="45.389"><path d="M1.172 9.641c5.062.216 7.335 9.392 8.326 13.13 1.116 4.363 1.748 8.825 2.814 13.2.833 3.43 2.456 12.139 7.651 8.584 6-4.1 8.326-13.779 10.182-20.265a119.035 119.035 0 0 0 3.014-13.471c.425-2.573 1.815-6.794.466-9.158-2.614-4.646-6.86 1.665-7.285 4.438-1.232 8.218 2.1 18.317 5.22 25.727.733 1.74 1.773 5.828 4.1 6.369s3.9-2.173 4.712-3.822a29.121 29.121 0 0 0 1.782-5.3c.325-1.174.741-5 1.865-5.6 2.623-1.349 3.115 6.469 3.264 7.443.275 1.807.491 3.971 1.6 5.5 4.687 6.494 10.108-8.717 11.306-11.331C64.5 15.661 71.425 8.343 81.134 4.438c1.474-.591.833-3.014-.667-2.406-11.606 4.679-18.575 13.229-23.595 24.394a56.539 56.539 0 0 1-2.5 5.1 6.551 6.551 0 0 1-1.1 1.8 2.66 2.66 0 0 1-3.081-.241c-.916-1.041-.691-4.279-.916-5.712a16.236 16.236 0 0 0-1.141-4.488c-1.54-3.2-4.754-3.4-6.511-.266-1.315 2.34-1.665 5.2-2.431 7.718-.375 1.191-1.4 5.362-3.455 4.654-1.207-.416-2.539-5.67-2.9-6.661a69.05 69.05 0 0 1-2.914-10.182c-.7-3.38-2.09-8.409-1.066-11.881.316-1.082 1.99-4.163 2.681-2.29a5.7 5.7 0 0 1-.2 1.973c-.25 2.173-.608 4.338-1.007 6.494Q29.3 18.1 27.723 23.646a95.743 95.743 0 0 1-3.28 9.791c-.949 2.331-6.128 12.822-8.25 7.044-2.5-6.752-2.906-14.062-5-20.914-1.482-4.812-4.08-12.147-9.991-12.4C-.4 7.1-.4 9.6 1.206 9.666Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Victor Westerlund - Contact</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Full-stack web developer from Stockholm, Sweden.">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:no-preference)">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:light)">
<link rel="icon" href="assets/media/favicon-light.png" media="(prefers-color-scheme:dark)">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<p><a href="/">victor westerlund</a></p>
</header>
<main>
<p>hello@&ZeroWidthSpace;victorwesterlund.com</p>
</main>
<footer>
<p><a href="about">about</a></p>
<p><a href="search">search</a></p>
<p><a class="arrow" href="more">more</a></p>
</footer>
</body>
</html>

39
public/error.html Normal file → Executable file
View file

@ -5,33 +5,24 @@
<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.">
<link rel="icon" href="/assets/media/favicon-dark.png" media="(prefers-color-scheme:no-preference)">
<link rel="icon" href="/assets/media/favicon-dark.png" media="(prefers-color-scheme:light)">
<link rel="icon" href="/assets/media/favicon-light.png" media="(prefers-color-scheme:dark)">
<link rel="stylesheet" href="/assets/css/style.css">
<link rel="stylesheet" href="/assets/css/components.css">
<style>
main { align-items: center; gap: unset; }
h1 { font-size: clamp(25px,40px,3vw); }
.button { margin-top: calc(var(--padding) * 2); }
footer { background-color: transparent; }
</style>
<meta name="theme-color" content="#000000">
<meta property='og:title' content='Victor Westerlund'>
<meta property='og:image' content='//victorwesterlund.com/assets/media/banner.jpg'>
<meta property='og:description' content='Full-stack web developer from Stockholm, Sweden.'>
<meta property='og:url' content='//www.example.com/URL of the article'>
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:no-preference)">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:light)">
<link rel="icon" href="assets/media/favicon-light.png" media="(prefers-color-scheme:dark)">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<p><a href="/">victor westerlund</a></p>
</header>
<main>
<div>
<div id="intro">
<h1>there is nothing here</h1>
<p>and that is all I know</p>
<a href="/">
<div class="button">
<p>take me home</p>
<p>and that's all I know</p>
<a href="/">take me home -></a>
</div>
</a>
</main>
<footer>
<p>404 not found</p>
</footer>
</div>
<script type="module" src="assets/js/script.mjs"></script>
</body>
</html>

46
public/index.html Normal file → Executable file
View file

@ -5,22 +5,46 @@
<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="#000000">
<meta property='og:title' content='Victor Westerlund'>
<meta property='og:image' content='//victorwesterlund.com/assets/media/banner.jpg'>
<meta property='og:description' content='Full-stack web developer from Stockholm, Sweden.'>
<meta property='og:url' content='//www.example.com/URL of the article'>
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:no-preference)">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:light)">
<link rel="icon" href="assets/media/favicon-light.png" media="(prefers-color-scheme:dark)">
<link rel="stylesheet" href="assets/css/style.css">
<style>body { grid-template-rows: 1fr calc(var(--running-height) / var(--footer-denom)); }</style>
</head>
<body>
<main>
<p>victor westerlund</p>
<p><a class="arrow" href="https://github.com/VictorWesterlund">github</a></p>
<p><a class="arrow" href="contact">contact</a></p>
</main>
<footer>
<p><a href="about">about</a></p>
<p><a href="search">search</a></p>
<p><a class="arrow" href="more">more</a></p>
</footer>
<div>
<div id="intro">
<p>hello, my name is</p>
<h1>victor</h1>
<p>I'm a</p>
<h1>full-stack</h1>
<h1>developer</h1>
<p>from Sweden</p>
<a href="https://github.com/VictorWesterlund">my github -></a>
</div>
</div>
<div>
<div id="card">
<picture>
<source srcset="assets/media/pfp/256.webp" type="image/webp" media="(min-width: 600px)">
<source srcset="assets/media/pfp/128.webp" type="image/webp">
<source srcset="assets/media/pfp/256.jpg" type="image/jpg" media="(min-width: 600px)">
<img src="assets/media/pfp/128.jpg" alt="portrait of victor"/>
</picture>
<div>
<p>I create things with code. When I'm not creating things with code, I enjoy skiing, watching movies and some occasional gaming</p>
<p>And beyond computer science, I'm also an armchair rabbit-holer for engineering, physics and astronomy</p>
</div>
<div>
<p>...and ☕, full-time</p>
</div>
<!--<a href="contact">contact me</a>-->
</div>
</div>
<script type="module" src="assets/js/script.mjs"></script>
</body>
</html>

View file

@ -1,36 +0,0 @@
<!DOCTYPE html>
<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.">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:no-preference)">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:light)">
<link rel="icon" href="assets/media/favicon-light.png" media="(prefers-color-scheme:dark)">
<link rel="stylesheet" href="assets/css/style.css">
<style>
.arrow[href="/"]::after { content: ""; }
.arrow[href="/"]::before { content: "← "; }
</style>
</head>
<body>
<header>
<p><a href="/">victor westerlund</a></p>
</header>
<main>
<p><a class="arrow" href="https://github.com/VictorWesterlund">github</a></p>
<p><a class="arrow" href="contact">contact</a></p>
<p><a class="arrow" href="about">about</a></p>
<p></p>
<p>random stuff <span style="white-space:nowrap;">--</span></p>
<p><a class="arrow" href="search">search</a></p>
<p><a class="arrow" rel="noopener" target="_blank" href="https://open.spotify.com/playlist/1NiR19Fg3AHg0XlqJhEKTF?si=c53d53f4c11f4971">megalodon</a></p>
</main>
<footer>
<p><a href="about">about</a></p>
<p><a href="search">search</a></p>
<p><a class="arrow" href="/">less</a></p>
</footer>
</body>
</html>

0
public/robots.txt Normal file → Executable file
View file

View file

@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Victor Westerlund - Search</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="google" content="nositelinkssearchbox">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:no-preference)">
<link rel="icon" href="assets/media/favicon-dark.png" media="(prefers-color-scheme:light)">
<link rel="icon" href="assets/media/favicon-light.png" media="(prefers-color-scheme:dark)">
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="assets/css/search.css">
</head>
<body>
<header>
<p><a href="/">victor westerlund</a></p>
</header>
<main>
<div id="search">
<input type="search" spellcheck="false" autocomplete="false" placeholder="start typing to search..">
</div>
<div id="results">
<p>search results will appear here as you type</p>
</div>
</main>
<footer>
<p><a href="about">about</a></p>
<p><a href="search">search</a></p>
<p><a class="arrow" href="more">more</a></p>
</footer>
<script type="module" src="assets/js/search.mjs"></script>
<script nomodule defer src="assets/js/noscript.js"></script>
</body>
</html>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://victorwesterlund.com/</loc>
<lastmod>2021-02-09T06:54:02+00:00</lastmod>
</url>
</urlset>

View file

@ -1,50 +0,0 @@
<?php
class APIRouter {
public function __construct($path) {
// List of implemented API services
$this->services = [
"search" => function() {
require_once "/search/Search.php";
new Search();
}
];
$this->url = parse_url($path);
$this->run();
}
// Find the requested service by looking at the next URI breadcrumb after "api"
private function get_service() {
$path = explode("/",$this->url["path"]);
$service = array_search("api",$path) + 1; // Next array value
$service = $path[$service];
return $service;
}
private function error($message,$code = 500) {
$output = [
"ok" => false,
"code" => strval($code),
"message" => $message
];
header("Content-Type: application/json");
http_response_code($code);
echo json_encode($output);
}
// Run the requested service if it exists in services list
private function run() {
$service = $this->get_service();
if(!array_key_exists($service,$this->services)) {
$this->error("Inavlid API");
return false;
}
// Import and run requested service
$this->services[$service]();
}
}
new APIRouter($_SERVER["REQUEST_URI"]);

View file

@ -1,16 +0,0 @@
<?php
class Import {
// Import assets from disk
public static function file($file) {
$content = file_get_contents($file);
return $content;
}
// Import JSON to PHP list
public static function json($file) {
$contents = Import::file($file);
$json = json_decode($contents);
return $json;
}
}

View file

@ -1,47 +0,0 @@
<?php
include_once dirname(__DIR__,1)."/core/Import.php";
class Database extends mysqli {
public function __construct($table) {
// Load config file from this directory
$config_path = dirname(__FILE__,1)."/config.json";
$config = Import::json($config_path);
parent::__construct();
//$this->ssl_set();
// Attempt to connect to MySQL servers in order (moving to the next on failure)
foreach($config->servers as $server) {
$db = $this->real_connect($server->host,$server->user,$server->pass,$server->db);
if($db) {
return true;
}
}
}
// Exit with error code
private function error($message) {
http_response_code(500);
header("Content-Type: application/json");
$output = json_encode([
"error" => $message
]);
die($output);
}
// Return affected rows as an array of arrays
protected function get_rows($sql) {
if(!$this->ping()) {
$this->error("No database connected");
}
$query = $this->query($sql);
$rows = [];
while($row = $query->fetch_row()) {
$rows[] = $row;
}
return $rows;
}
}

View file

@ -1,10 +0,0 @@
{
"servers": [
{
"host": "",
"user": "",
"pass": "",
"db": ""
}
]
}

View file

@ -1,87 +0,0 @@
<?php
require_once dirname(__DIR__,1)."/core/Import.php";
require_once dirname(__DIR__,1)."/database/Database.php";
class Search extends Database {
public function __construct() {
parent::__construct("search");
$this->query = $this->real_escape_string($_GET["q"]); // Escape the user-provided query
// Determine response type from request header or search param
$mime_type = $_SERVER["HTTP_CONTENT_TYPE"] ? $_SERVER["HTTP_CONTENT_TYPE"] : $_GET["f"];
switch($mime_type) {
case "html":
case "text/html":
$this->get_html();
break;
default:
case "json":
case "application/json":
$this->get_json();
break;
}
}
// Perform a seach on the given query and return the results as an array
private function get_results() {
$sql = "SELECT template,title,content,href FROM `search` WHERE `title` LIKE '%{$this->query}%' OR `content` LIKE '%{$this->query}%'";
$rows = $this->get_rows($sql);
return $rows;
}
// Load HTML template from disk
private function get_html_template($name) {
$path = dirname(__FILE__,1)."/templates/${name}.html";
if(!is_file($path)) {
return $this->get_html_template("card_error_display");
}
$html = Import::file($path);
return $html;
}
// Return query as HTML from templates
private function get_html() {
$results = $this->get_results();
if(count($results) < 1) {
$results[] = ["message","info","no results 😞"];
}
// Load HTML and format each response from template
$results = array_map(function($result) {
// Use first row as template name
$template = $this->get_html_template($result[0]);
// Use remaining rows as format arguments
$format = array_shift($result);
return sprintf($template,...$result);
},$results);
header("Content-Type: text/html");
echo implode("",$results);
}
// Return query as JSON
private function get_json() {
$results = $this->get_results();
$data = [
"results" => []
];
// Assign custom keys to each value (not db columns)
foreach($results as $result) {
$data["results"][] = [
"html_template" => $result[0],
"title" => $result[1],
"content" => $result[2],
"href" => $result[3]
];
}
$json = json_encode($data);
header("Content-Type: application/json");
echo $json;
}
}

View file

@ -1,8 +0,0 @@
<div class="card">
<div>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M3 2.75A2.75 2.75 0 0 1 5.75 0h14.5a.75.75 0 0 1 .75.75v20.5a.75.75 0 0 1-.75.75h-6a.75.75 0 0 1 0-1.5h5.25v-4H6A1.5 1.5 0 0 0 4.5 18v.75c0 .716.43 1.334 1.05 1.605a.75.75 0 0 1-.6 1.374A3.25 3.25 0 0 1 3 18.75v-16zM19.5 1.5V15H6c-.546 0-1.059.146-1.5.401V2.75c0-.69.56-1.25 1.25-1.25H19.5z" fill="currentColor"/><path d="M7 18.25a.25.25 0 0 1 .25-.25h5a.25.25 0 0 1 .25.25v5.01a.25.25 0 0 1-.397.201l-2.206-1.604a.25.25 0 0 0-.294 0L7.397 23.46a.25.25 0 0 1-.397-.2v-5.01z" fill="currentColor"/></svg>
<p>%s</p>
</div>
<p>%s</p>
<p href="%s" class="button">read more</p>
</div>

View file

@ -1,4 +0,0 @@
<div class="card error">
<p><strong>There was a problem displaying this result</strong></p>
<p>This is a problem on my side, sorry about that</p>
</div>

View file

@ -1,8 +0,0 @@
<div class="card">
<div>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M3 2.75A2.75 2.75 0 0 1 5.75 0h14.5a.75.75 0 0 1 .75.75v20.5a.75.75 0 0 1-.75.75h-6a.75.75 0 0 1 0-1.5h5.25v-4H6A1.5 1.5 0 0 0 4.5 18v.75c0 .716.43 1.334 1.05 1.605a.75.75 0 0 1-.6 1.374A3.25 3.25 0 0 1 3 18.75v-16zM19.5 1.5V15H6c-.546 0-1.059.146-1.5.401V2.75c0-.69.56-1.25 1.25-1.25H19.5z" fill="currentColor"/><path d="M7 18.25a.25.25 0 0 1 .25-.25h5a.25.25 0 0 1 .25.25v5.01a.25.25 0 0 1-.397.201l-2.206-1.604a.25.25 0 0 0-.294 0L7.397 23.46a.25.25 0 0 1-.397-.2v-5.01z" fill="currentColor"/></svg>
<p>%s</p>
</div>
<p>%s</p>
<p href="%s" class="button">read more</p>
</div>

View file

@ -1 +0,0 @@
<p class="%s">%s</p>

View file

@ -1,5 +0,0 @@
<div class="resultsFooter">
<svg id="previous"><polygon points="40,10 0,20 40,30"/></svg>
<p>showing %s/%s results<span> (query took %s seconds)</span></p>
<svg id="next"><polygon points="0,10 40,20 0,30"/></svg>
</div>