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

+

How to install

+

This guide is for Unix-based systems with NGINX, PHP 8.0 and MariaDB installed and configured.

+
    +
  1. Clone this repo.
    git clone https://github.com/VictorWesterlund/victorwesterlund.com /var/www
  2. +
  3. Expose the directory /public to the web.
    This can be done in multiple ways, but an NGINX location block or symlink should do the trick.
  4. +
  5. Rewrite api.php to api/*.
    All requests to example.com/api/* should be routed to the PHP file at /public/api.php.
    Just like the previous step, this can be done in multiple ways. Here is one way with an NGINX location block:
    +
    location ~ /api/* {
    +     try_files /public/api.php =503;
    +     include snippets/fastcgi-php.local.conf;
    +     fastcgi_pass unix:/run/php/php8.0-fpm.sock;
    +}
  6. +
  7. Add support for the .mjs extension.
    NGINX doesn't have an entry for the ECMAScript module (.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
  8. +
  9. Import the standard structure to a MariaDB database.
    A MySQL-compatible .sql can be downloaded here and imported into a database with this command:
    mysql -u username -p database_name < db_structure.sql
    You will have to create an empty database if you don't have one already.
  10. +
  11. Add your MariaDB connection details to /src/database/config.json.
    You can add as many fallback servers as you want +
    +{
    +  "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"
    +      }
    +  ]
    +}
    +
  12. +
+

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

github

+
diff --git a/public/search.html b/public/search.html new file mode 100644 index 0000000..f888da6 --- /dev/null +++ b/public/search.html @@ -0,0 +1,26 @@ + + + + + + Victor Westerlund - Search + + + + + + + +
+

victor westerlund

+
+ +
+

search results will appear here as you type

+
+ + + + diff --git a/src/Globals.php b/src/Globals.php new file mode 100644 index 0000000..3875e25 --- /dev/null +++ b/src/Globals.php @@ -0,0 +1,14 @@ +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; + } + } \ No newline at end of file diff --git a/src/database/config.json b/src/database/config.json new file mode 100644 index 0000000..b79f464 --- /dev/null +++ b/src/database/config.json @@ -0,0 +1,10 @@ +{ + "servers": [ + { + "host": "", + "user": "", + "pass": "", + "db": "" + } + ] +} \ No newline at end of file diff --git a/src/search/Search.php b/src/search/Search.php new file mode 100644 index 0000000..966027d --- /dev/null +++ b/src/search/Search.php @@ -0,0 +1,87 @@ +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; + } + } diff --git a/src/search/templates/card_default.html b/src/search/templates/card_default.html new file mode 100644 index 0000000..29b5117 --- /dev/null +++ b/src/search/templates/card_default.html @@ -0,0 +1,8 @@ +
+
+ +

%s

+
+

%s

+

read more

+
\ No newline at end of file diff --git a/src/search/templates/card_error_display.html b/src/search/templates/card_error_display.html new file mode 100644 index 0000000..8a599c4 --- /dev/null +++ b/src/search/templates/card_error_display.html @@ -0,0 +1,4 @@ +
+

There was a problem displaying this result

+

This is a problem on my side, sorry about that

+
\ No newline at end of file diff --git a/src/search/templates/default.html b/src/search/templates/default.html new file mode 100644 index 0000000..29b5117 --- /dev/null +++ b/src/search/templates/default.html @@ -0,0 +1,8 @@ +
+
+ +

%s

+
+

%s

+

read more

+
\ No newline at end of file diff --git a/src/search/templates/message.html b/src/search/templates/message.html new file mode 100644 index 0000000..6e96bcd --- /dev/null +++ b/src/search/templates/message.html @@ -0,0 +1 @@ +

%s

\ No newline at end of file diff --git a/src/search/templates/result_about.html b/src/search/templates/result_about.html new file mode 100644 index 0000000..8e3ac33 --- /dev/null +++ b/src/search/templates/result_about.html @@ -0,0 +1,5 @@ +
+ +

showing %s/%s results (query took %s seconds)

+ +
\ No newline at end of file