victor westerlund
+diff --git a/README.md b/README.md index e9fcd58..5df3583 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,44 @@
www.victorwesterlund.com
The source code for victorwesterlund.com
+This guide is for Unix-based systems with NGINX, PHP 8.0 and MariaDB installed and configured.
+git clone https://github.com/VictorWesterlund/victorwesterlund.com /var/www
/public
to the web.location
block or symlink should do the trick.api.php
to api/*
.example.com/api/*
should be routed to the PHP file at /public/api.php
.location ~ /api/* { + try_files /public/api.php =503; + include snippets/fastcgi-php.local.conf; + fastcgi_pass unix:/run/php/php8.0-fpm.sock; +}
.mjs
extension..mjs
) file extension in its default /etc/nginx/mime.types
file. We need to add this manually:+types { + ... + application/javascript js mjs; + ... +} +PS: If you want to make your
Content-Type
WG compliant, replace application/javascript
with text/javascript
.sql
can be downloaded here and imported into a database with this command:mysql -u username -p database_name < db_structure.sqlYou will have to create an empty database if you don't have one already.
/src/database/config.json
.
+{
+ "servers": [
+ {
+ "host": "db.example.com",
+ "user": "mysql_user",
+ "pass": "mysql_pass",
+ "db": "mysql_db"
+ },
+ {
+ "host": "fallback.db.example.com",
+ "user": "mysql_user",
+ "pass": "mysql_pass",
+ "db": "mysql_db"
+ }
+ ]
+}
+
That was a lot, but now we're done! Navigate to the location you exposed in step 2 and cross your fingers 🤞
diff --git a/public/api.php b/public/api.php new file mode 100644 index 0000000..fec84a1 --- /dev/null +++ b/public/api.php @@ -0,0 +1,52 @@ +services = [ + "search" => function() { + require_once dirname(__DIR__,1)."/src/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"]); \ No newline at end of file diff --git a/public/assets/css/index.css b/public/assets/css/index.css new file mode 100644 index 0000000..eed3603 --- /dev/null +++ b/public/assets/css/index.css @@ -0,0 +1,31 @@ +/* Victor Westerlund - www.victorwesterlund.com */ +a { + font-weight: bold; +} + +a::after { + content: " →"; +} + +main { + display: flex; + flex-direction: column; + gap: 30px; + font-size: 20px; + transform: translateY(0); +} + +/* -- Media Queries -- */ + +@media (max-width: 300px) { + main { + text-align: center; + align-items: center; + } +} + +@media print { + a::after { + content: ": " attr(href); + } +} \ No newline at end of file diff --git a/public/assets/css/search.css b/public/assets/css/search.css new file mode 100644 index 0000000..738f287 --- /dev/null +++ b/public/assets/css/search.css @@ -0,0 +1,241 @@ +/* Victor Westerlund - www.victorwesterlund.com */ +:root { + --padding: 20px; + --max-width: 800px; +} + +html, +body { + justify-content: flex-start; +} + +header { + display: flex; + align-items: center; + height: 100px; + min-height: 80px; + flex: none; +} + +header h1 { + font-size: clamp(16px,5vw,25px); + font-weight: normal; +} + +/* -- Searchbox -- */ + +#search input { + background-color: var(--swatch-contrast); + color: var(--swatch-background); + border: none; + font-size: 16px; + width: calc(100vw - (var(--padding) * 2)); + max-width: var(--max-width); + text-transform: lowercase; + padding: var(--padding); +} + +#search input::placeholder { + color: rgba(var(--palette-background),.4); +} + +#search input::selection { + color: var(--swatch-contrast); + background-color: var(--swatch-background); +} + +#search input:focus { + outline: none; +} + +/* -- Results -- */ + +#results { + width: calc(var(--max-width) + (var(--padding) * 4)); + max-width: 100%; + box-sizing: border-box; + padding: var(--padding); + display: flex; + flex-direction: column; + overflow-y: auto; + gap: var(--padding); +} + +#results > p { + text-align: center; +} + +#results > p.error { + color: red; +} + +#results > p.error::before { + content: "😰 "; +} + +.card { + --padding-multiplier: 1.2; + flex: none; + display: flex; + flex-direction: column; + gap: calc(var(--padding) * var(--padding-multiplier)); + padding: calc(var(--padding) * var(--padding-multiplier)); + box-sizing: border-box; + width: 100%; + overflow: auto; + border: solid 1px var(--swatch-contrast); +} + +.card > div { + --icon-size: 40px; + display: grid; + grid-template-columns: var(--icon-size) 1fr; + align-items: center; + font-weight: bold; + gap: calc(var(--padding) * var(--padding-multiplier)); +} + +.card > div *:not(p) { + width: var(--icon-size); +} + +.card > div p { + font-size: clamp(16px,1vw,20px); + word-break: break-word; +} + +/* -- Results > Types -- */ + +.card.error { + gap: unset; +} + +.card.error p > a { + background-color: rgba(var(--palette-contrast),.1); +} + +.resultsFooter { + display: flex; + justify-content: space-between; + align-items: center; +} + +.resultsFooter p { + text-align: center; + padding: 0 var(--padding); +} + +.resultsFooter p span:last-child { + display: none; +} + +.resultsFooter svg { + width: 41px; + height: 40px; + flex: none; +} + +.resultsFooter svg polygon { + fill: none; + stroke: rgba(var(--palette-contrast),.1); + stroke-width: 1px; +} + +.resultsFooter svg.active polygon { + fill: var(--swatch-contrast); + stroke: var(--swatch-contrast); +} + +/* ---- */ + +.button { + padding: var(--padding); + text-align: center; + background-color: var(--swatch-contrast); + color: var(--swatch-background); + box-shadow: inset 0 0 0 2px var(--swatch-contrast); + user-select: none; +} + +/* -- Media Queries -- */ + +@media (max-width: 300px) { + .card > div { + grid-template-columns: 1fr; + } + + .card > div *:not(p) { + display: none; + } +} + +@media (min-width: 600px) { + .resultsFooter p span:last-child { + display: initial; + } +} + +@media (hover: hover) { + .button:hover { + background-color: rgba(var(--palette-contrast),0); + color: var(--swatch-contrast); + cursor: pointer; + } + + .button:active { + background-color: rgba(var(--palette-contrast),.1); + color: var(--swatch-contrast); + cursor: pointer; + } + + /* ---- */ + + .resultsFooter svg.active:hover polygon { + fill: var(--swatch-background); + cursor: pointer; + } + + .resultsFooter svg.active:active polygon { + fill: rgba(var(--palette-contrast),.1); + } +} + +@media (pointer: fine) { + #search input:focus { + outline: solid 5px rgba(var(--palette-contrast),.2); + } + + /* ---- */ + + #results::-webkit-scrollbar { + width: 10px; + } + + #results::-webkit-scrollbar-track { + background-color: rgba(var(--palette-contrast),.04); + } + + #results::-webkit-scrollbar-thumb { + background: var(--swatch-contrast); + } + + #results::-webkit-scrollbar-thumb:hover { + background: var(--swatch-background); + outline: solid 2px rgba(var(--palette-contrast),1); + } + + #results::-webkit-scrollbar-thumb:active { + background: rgba(var(--palette-contrast),.1); + outline: solid 2px rgba(var(--palette-contrast),1); + } +} + +@media (prefers-color-scheme: dark) { + #search { + --palette-background: 255,255,255; + --palette-contrast: 33,33,33; + + --swatch-background: rgb(var(--palette-background)); + --swatch-contrast: rgb(var(--palette-contrast)); + } +} \ No newline at end of file diff --git a/public/assets/css/style.css b/public/assets/css/style.css index a3f9982..c40ad67 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -43,6 +43,7 @@ body { display: flex; justify-content: center; align-items: center; + flex-direction: column; width: 100%; height: 100%; overflow: hidden; @@ -52,36 +53,10 @@ body { a { display: content; text-decoration: none; - font-weight: bold; -} - -a::after { - content: " →"; -} - -main { - display: flex; - flex-direction: column; - gap: 30px; - font-size: 20px; - transform: translateY(0); } /* -- Media Queries -- */ -@media (max-width: 300px) { - main { - text-align: center; - align-items: center; - } -} - -@media print { - a::after { - content: ": " attr(href); - } -} - @media (pointer: fine) { a:hover { background: rgba(var(--palette-contrast),.1); diff --git a/public/assets/js/modules/Search.mjs b/public/assets/js/modules/Search.mjs new file mode 100644 index 0000000..1570cb5 --- /dev/null +++ b/public/assets/js/modules/Search.mjs @@ -0,0 +1,96 @@ +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); + } +} \ No newline at end of file diff --git a/public/assets/js/noscript.js b/public/assets/js/noscript.js new file mode 100644 index 0000000..b265ca3 --- /dev/null +++ b/public/assets/js/noscript.js @@ -0,0 +1,6 @@ +const search = document.getElementById("search").children[0]; +const results = document.getElementById("results").children[0]; + +search.style.setProperty("display","none"); +results.classList.add("error"); +results.innerText = "Sorry, your browser isn't supported yet"; \ No newline at end of file diff --git a/public/assets/js/search.mjs b/public/assets/js/search.mjs new file mode 100644 index 0000000..cb9b9ef --- /dev/null +++ b/public/assets/js/search.mjs @@ -0,0 +1,9 @@ +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()); \ No newline at end of file diff --git a/public/index.html b/public/index.html index 7a0c73c..902fc24 100644 --- a/public/index.html +++ b/public/index.html @@ -8,11 +8,13 @@ +victor westerlund
+