Add back-end core and "search" feature (#20)

Soft initial release of API core and "search" feature.
This commit is contained in:
Victor Westerlund 2021-11-03 11:10:50 +01:00 committed by GitHub
parent d5ea7961fe
commit 54da8f9be2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 689 additions and 26 deletions

View file

@ -3,3 +3,44 @@
<h3><strong><code>www.victorwesterlund.com</code></strong></h3>
<p>The source code for <a href="https://victorwesterlund.com">victorwesterlund.com</a></p>
</div>
<h1 align="center">How to install</h1>
<p>This guide is for Unix-based systems with NGINX, PHP 8.0 and MariaDB installed and configured.</p>
<ol>
<li><strong>Clone this repo.</strong><br><pre>git clone https://github.com/VictorWesterlund/victorwesterlund.com /var/www</pre></li>
<li><strong>Expose the directory <code>/public</code> to the web.</strong><br>This can be done in multiple ways, but an <a href="http://nginx.org/en/docs/http/ngx_http_core_module.html#location">NGINX <code>location</code> block</a> or symlink should do the trick.</li>
<li><strong>Rewrite <code>api.php</code> to <code>api/*</code>.</strong><br>All requests to <code>example.com/api/*</code> should be routed to the PHP file at <code>/public/api.php</code>.<br>Just like the previous step, this can be done in multiple ways. Here is one way with an NGINX location block:<br>
<pre>location ~ /api/* {
try_files /public/api.php =503;
include snippets/fastcgi-php.local.conf;
fastcgi_pass unix:/run/php/php8.0-fpm.sock;
}</pre></li>
<li><strong>Add support for the <code>.mjs</code> extension.</strong><br>NGINX doesn't have an entry for the <a href="https://jakearchibald.com/2017/es-modules-in-browsers/#mime-types">ECMAScript module <i>(<code>.mjs</code>)</i></a> file extension in its default <code>/etc/nginx/mime.types</code> file. We need to add this manually:<br>
<pre>
types {
...
application/javascript js mjs;
...
}
</pre><i>PS: If you want to make your <code>Content-Type</code> <a href="https://html.spec.whatwg.org/multipage/scripting.html#scriptingLanguages">WG compliant</a>, replace <code>application/javascript</code> with <code>text/javascript</code></i></li>
<li><strong>Import the standard structure to a MariaDB database.</strong><br>A MySQL-compatible <code>.sql</code> can be <a href="https://example.com">downloaded here</a> and imported into a database with this command:<br><pre>mysql -u username -p database_name < db_structure.sql</pre>You will have to create an empty database if you don't have one already.</li>
<li><strong>Add your MariaDB connection details to <code>/src/database/config.json</code>.</strong><br>You can add as many fallback servers as you want
<pre lang="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"
}
]
}
</pre></li>
</ol>
<p>That was a lot, but now we're done! Navigate to the location you exposed in step 2 and cross your fingers 🤞</p>

52
public/api.php Normal file
View file

@ -0,0 +1,52 @@
<?php
require_once dirname(__DIR__,1)."/src/Globals.php";
class APIRouter {
public function __construct($path) {
// List of implemented API services
$this->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"]);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,11 +8,13 @@
<meta name="description" content="Full-stack web developer from Stockholm, Sweden.">
<link rel="icon" href="assets/img/favicon.png">
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="assets/css/index.css">
</head>
<body>
<main class="hidden">
<p>victor westerlund</p>
<p><a href="https://github.com/VictorWesterlund">github</a></p>
<!--<p><a href="search">search</a></p>-->
</main>
</body>
</html>

26
public/search.html Normal file
View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<!-- Victor Westerlund - www.victorwesterlund.com -->
<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.">
<link rel="icon" href="assets/img/favicon.png">
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="assets/css/search.css">
</head>
<body>
<header>
<h1><a href="/">victor westerlund</a></h1>
</header>
<div id="search">
<input type="text" placeholder="start typing to search..">
</div>
<div id="results">
<p>search results will appear here as you type</p>
</div>
<script type="module" src="assets/js/search.mjs"></script>
<script nomodule defer src="assets/js/noscript.js"></script>
</body>
</html>

14
src/Globals.php Normal file
View file

@ -0,0 +1,14 @@
<?php
class Import {
public static function file($file) {
$content = file_get_contents($file);
return $content;
}
public static function json($file) {
$contents = Import::file($file);
$json = json_decode($contents);
return $json;
}
}

47
src/database/Database.php Normal file
View file

@ -0,0 +1,47 @@
<?php
include_once dirname(__DIR__,1)."/Globals.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;
}
}

10
src/database/config.json Normal file
View file

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

87
src/search/Search.php Normal file
View file

@ -0,0 +1,87 @@
<?php
require_once dirname(__DIR__,1)."/Globals.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

@ -0,0 +1,8 @@
<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

@ -0,0 +1,4 @@
<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

@ -0,0 +1,8 @@
<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

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

View file

@ -0,0 +1,5 @@
<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>