mirror of
https://codeberg.org/vlw/victorwesterlund.com.git
synced 2025-09-13 19:13:42 +02:00
Add back-end core and "search" feature (#20)
Soft initial release of API core and "search" feature.
This commit is contained in:
parent
d5ea7961fe
commit
54da8f9be2
19 changed files with 689 additions and 26 deletions
41
README.md
41
README.md
|
@ -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
52
public/api.php
Normal 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"]);
|
31
public/assets/css/index.css
Normal file
31
public/assets/css/index.css
Normal 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);
|
||||
}
|
||||
}
|
241
public/assets/css/search.css
Normal file
241
public/assets/css/search.css
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
96
public/assets/js/modules/Search.mjs
Normal file
96
public/assets/js/modules/Search.mjs
Normal 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);
|
||||
}
|
||||
}
|
6
public/assets/js/noscript.js
Normal file
6
public/assets/js/noscript.js
Normal 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";
|
9
public/assets/js/search.mjs
Normal file
9
public/assets/js/search.mjs
Normal 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());
|
|
@ -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
26
public/search.html
Normal 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
14
src/Globals.php
Normal 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
47
src/database/Database.php
Normal 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
10
src/database/config.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"servers": [
|
||||
{
|
||||
"host": "",
|
||||
"user": "",
|
||||
"pass": "",
|
||||
"db": ""
|
||||
}
|
||||
]
|
||||
}
|
87
src/search/Search.php
Normal file
87
src/search/Search.php
Normal 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;
|
||||
}
|
||||
}
|
8
src/search/templates/card_default.html
Normal file
8
src/search/templates/card_default.html
Normal 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>
|
4
src/search/templates/card_error_display.html
Normal file
4
src/search/templates/card_error_display.html
Normal 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>
|
8
src/search/templates/default.html
Normal file
8
src/search/templates/default.html
Normal 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>
|
1
src/search/templates/message.html
Normal file
1
src/search/templates/message.html
Normal file
|
@ -0,0 +1 @@
|
|||
<p class="%s">%s</p>
|
5
src/search/templates/result_about.html
Normal file
5
src/search/templates/result_about.html
Normal 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>
|
Loading…
Add table
Reference in a new issue