Switched templating to Sasae.

This commit is contained in:
flash 2023-10-12 18:45:11 +00:00
parent ada73c23bb
commit 7bd41f4e93
36 changed files with 587 additions and 777 deletions

View file

@ -12,7 +12,7 @@ define('MKI_DIR_LIB', MKI_ROOT . '/lib');
define('MKI_DIR_PUB', MKI_ROOT . '/public');
define('MKI_DIR_PAGES', MKI_ROOT . '/pages');
define('MKI_DIR_CONFIG', MKI_ROOT . '/config');
define('MKI_DIR_TEMPLATES', MKI_ROOT . '/tpl');
define('MKI_DIR_TEMPLATES', MKI_ROOT . '/templates');
require_once MKI_ROOT . '/vendor/autoload.php';

View file

@ -52,14 +52,16 @@ $router = new HttpFx;
$router->setDefaultErrorHandler(function($response, $request, $code, $text) use ($ctx) {
$path = 'errors/' . $code;
$tpl = $ctx->getTemplating();
$response->setContent($tpl->render($tpl->exists($path) ? $path : 'errors/master', [
$name = is_file(sprintf('%s/errors/%s.twig', MKI_DIR_TEMPLATES, $code)) ? sprintf('errors/%s.twig', $code) : 'errors/master';
$response->setContent($tpl->render($name, [
'http_error_code' => $code,
'http_error_title' => $text,
]));
});
$router->get('/error/:code', function($response, $request, $code) {
return intval($code);
$router->get('/error/:code', function($response, $request, string $code) {
return (int)$code;
});
foreach(MKI_REDIRS as $source => $target)
@ -68,7 +70,7 @@ foreach(MKI_REDIRS as $source => $target)
});
$router->use('/', function($response) {
$response->setPoweredBy('Makai+Index');
$response->setPoweredBy('Makai');
});
$router->get('/header-bgs.json', function() use ($ctx) {
@ -123,12 +125,23 @@ $router->get('/', function() use ($ctx) {
}
}
$db = $ctx->getDatabase();
$dbConn = $ctx->getDatabase();
$projectInfos = (new Projects($dbConn))->getFeatured();
$langs = new Languages($dbConn);
$projects = [];
foreach($projectInfos as $projectInfo)
$projects[] = [
'info' => $projectInfo,
'colour' => $projectInfo->hasColour() ? $projectInfo->getColour() : $langs->getProjectColour($projectInfo),
];
$contacts = (new Contacts($dbConn))->getHomePage();
return $ctx->getTemplating()->render('index', [
'projects' => (new Projects($db))->getFeatured(),
'languages' => new Languages($db),
'contacts' => (new Contacts($db))->getHomePage(),
'projects' => $projects,
'contacts' => $contacts,
]);
});
@ -139,8 +152,17 @@ $router->get('/contact', function() use ($ctx) {
});
$router->get('/projects', function() use ($ctx) {
$db = $ctx->getDatabase();
$projects = (new Projects($db))->getAll();
$dbConn = $ctx->getDatabase();
$langs = new Languages($dbConn);
$projectInfos = (new Projects($dbConn))->getAll();
$projects = [];
foreach($projectInfos as $projectInfo)
$projects[] = [
'info' => $projectInfo,
'langs' => $langs->getByProject($projectInfo),
'colour' => $projectInfo->hasColour() ? $projectInfo->getColour() : $langs->getProjectColour($projectInfo),
];
$sections = [
'projects' => [
@ -152,12 +174,16 @@ $router->get('/projects', function() use ($ctx) {
return $ctx->getTemplating()->render('projects', [
'sections' => $sections,
'languages' => new Languages($db),
]);
});
$router->get('/ascii', function() use ($ctx) {
return $ctx->getTemplating()->render('ascii');
$templating = $ctx->getTemplating();
$templating->addFilter('chr', 'chr');
$templating->addFilter('decoct', 'decoct');
$templating->addFilter('dechex', 'dechex');
return $templating->render('ascii');
});
$router->get('/rngstr', function($response, $request) {

View file

@ -2,6 +2,8 @@
namespace Makai;
use Index\WString;
use Index\Colour\Colour;
use Index\Colour\ColourRGB;
class ContactInfo {
private string $name;
@ -48,12 +50,12 @@ class ContactInfo {
return $this->icon;
}
public function getColour(): int {
return $this->colour;
public function getColour(): Colour {
return ColourRGB::fromRawRGB($this->colour);
}
public function getColourHex(): string {
return '#' . str_pad(dechex($this->colour), 6, '0', STR_PAD_LEFT);
public function getColourRaw(): int {
return $this->colour;
}
public function getDisplay(): WString {

24
src/GitInfo.php Normal file
View file

@ -0,0 +1,24 @@
<?php
namespace Makai;
final class GitInfo {
public static function log(string $format, string $args = ''): string {
return trim(shell_exec(sprintf('git log --pretty="%s" %s -n1 HEAD', $format, $args)));
}
public static function hash(bool $long = false): string {
return self::log($long ? '%H' : '%h');
}
public static function branch(): string {
return trim(`git rev-parse --abbrev-ref HEAD`);
}
public static function tag(): string {
return trim(`git describe --abbrev=0 --tags`);
}
public static function version(): string {
return self::branch() === 'HEAD' ? self::tag() : self::hash();
}
}

View file

@ -3,6 +3,8 @@ namespace Makai;
use Index\AString;
use Index\WString;
use Index\Colour\Colour;
use Index\Colour\ColourRGB;
class LanguageInfo {
private string $id;
@ -31,7 +33,11 @@ class LanguageInfo {
return $this->colour !== null;
}
public function getColour(): ?int {
public function getColour(): Colour {
return $this->colour === null ? Colour::none() : ColourRGB::fromRawRGB($this->colour);
}
public function getColourRaw(): ?int {
return $this->colour;
}
}

View file

@ -1,6 +1,8 @@
<?php
namespace Makai;
use Index\Colour\Colour;
use Index\Colour\ColourRGB;
use Index\Data\DbType;
use Index\Data\IDbConnection;
use Index\Data\IDbResult;
@ -31,7 +33,15 @@ class Languages {
return $objs;
}
public function getProjectColour(ProjectInfo $project): ?int {
public function getProjectColour(ProjectInfo $project): Colour {
$raw = $this->getProjectColourRaw($project);
if($raw === null)
return Colour::none();
return ColourRGB::fromRawRGB($raw);
}
public function getProjectColourRaw(ProjectInfo $project): ?int {
$stmt = $this->conn->prepare(self::QUERY_PROJECT_COLOUR);
$stmt->addParameter(1, $project->getId(), DbType::STRING);
$stmt->execute();
@ -40,7 +50,7 @@ class Languages {
if(!$result->next())
return null;
return $result->getInteger(0);
return $result->isNull(0) ? null : $result->getInteger(0);
}
private static function createObject(IDbResult $result): LanguageInfo {

View file

@ -1,30 +1,37 @@
<?php
namespace Makai;
use Index\Environment;
use Index\Data\IDbConnection;
use Sasae\SasaeEnvironment;
final class MakaiContext {
private IDbConnection $db;
private ?TemplateContext $tpl = null;
private IDbConnection $dbConn;
private ?SasaeEnvironment $templating = null;
public function __construct(IDbConnection $db) {
$this->db = $db;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
}
public function getDatabase(): IDbConnection {
return $this->db;
return $this->dbConn;
}
public function getTemplating(): TemplateContext {
if($this->tpl === null) {
$this->tpl = new TemplateContext(MKI_DIR_TEMPLATES);
$this->tpl->setGlobal('makai', $this);
$this->tpl->setGlobal('header_nav', $this->getDefaultNavigation());
$this->tpl->setGlobal('header_bgs', $this->getDefaultHeaders());
$this->tpl->setGlobal('footer_quotes', $this->getFooterQuotes());
public function getTemplating(): SasaeEnvironment {
if($this->templating === null) {
$isDebug = Environment::isDebug();
$this->templating = new SasaeEnvironment(
MKI_DIR_TEMPLATES,
cache: $isDebug ? null : ['Makai', GitInfo::hash(true)],
debug: $isDebug,
);
$this->templating->addGlobal('header_nav', $this->getDefaultNavigation());
$this->templating->addGlobal('header_bgs', $this->getDefaultHeaders());
$this->templating->addGlobal('footer_quotes', $this->getFooterQuotes());
}
return $this->tpl;
return $this->templating;
}
public function getDefaultNavigation(): array {

View file

@ -4,6 +4,8 @@ namespace Makai;
use Index\AString;
use Index\WString;
use Index\DateTime;
use Index\Colour\Colour;
use Index\Colour\ColourRGB;
class ProjectInfo {
private string $id;
@ -86,7 +88,11 @@ class ProjectInfo {
return $this->colour !== null;
}
public function getColour(): ?int {
public function getColour(): Colour {
return $this->colour === null ? Colour::none() : ColourRGB::fromRawRGB($this->colour);
}
public function getColourRaw(): ?int {
return $this->colour;
}

View file

@ -1,48 +0,0 @@
<?php
namespace Makai;
class Template {
public function __construct(
private TemplateContext $context,
private TemplateVars $vars,
private string $path
) {}
public function setVar(string $name, mixed $value): void {
$this->vars->setVar($name, $value);
}
public function removeVar(string $name): void {
$this->vars->removeVar($name);
}
public function render(array $vars = [], ?TemplateSelf $self = null): string {
if(!is_file($this->path))
throw new \RuntimeException('Template file does not exist: ' . $this->path);
$self = new TemplateSelf($this->context->getFunctions() + $vars + $this->vars->getVars(), $self);
$self->tplPath = $this->path;
$self->var = fn(string $name, mixed $default = null) => $this->vars->getVar($name, $default);
$self->setVar = fn(string $name, mixed $value) => $this->vars->setVar($name, $value);
$self->remVar = fn(string $name) => $this->vars->removeVar($name);
$self->block = fn(string $name, mixed $contents) => $this->context->setBlock($name, new TemplateBlock($self, $contents));
$self->include = fn(string $name) => $this->context->render($name, [], $self);
$self->ctx = $this->context;
$self->extends = fn(string $name) => $self->extends = $name;
ob_start();
(static function() use ($self) { include $self->tplPath; })();
$buffer = ob_get_contents();
ob_end_clean();
if(is_string($self->extends)) {
if(!empty($buffer))
throw new \RuntimeException('You cannot output from templates that extend another.');
$buffer = $this->context->render($self->extends, [], $self);
}
return $buffer;
}
}

View file

@ -1,22 +0,0 @@
<?php
namespace Makai;
use Closure;
class TemplateBlock {
public function __construct(
private object $self,
private mixed $body
) {}
public function render(): string {
if(is_callable($this->body)) {
ob_start();
($this->body)($this->self);
$body = ob_get_contents();
ob_end_clean();
} else $body = strval($this->body);
return $body;
}
}

View file

@ -1,56 +0,0 @@
<?php
namespace Makai;
use Closure;
class TemplateContext {
private const EXT = '.php';
private string $pathFormat = '%s' . self::EXT;
private TemplateVars $vars;
private array $blocks = [];
private array $functions = [];
public function __construct($path = '') {
if(!empty($path))
$this->pathFormat = rtrim($path, '\\/') . DIRECTORY_SEPARATOR . '%s' . self::EXT;
$this->vars = new TemplateVars;
$this->defineFunction('global', $this->vars->getVar(...));
$this->defineFunction('getBlock', $this->getBlock(...));
$this->defineFunction('x', fn(string $string) => htmlspecialchars($string, ENT_QUOTES, 'utf-8', true));
}
public function setGlobal(string $name, mixed $value): void {
$this->vars->setVar($name, $value);
}
public function removeGlobal(string $name): void {
$this->vars->removeVar($name);
}
public function defineFunction(string $name, Closure|callable $function): void {
$this->functions[$name] = $function;
}
public function getFunctions(): array {
return $this->functions;
}
public function create(string $path): Template {
return new Template($this, new TemplateVars($this->vars), sprintf($this->pathFormat, $path));
}
public function exists(string $path): bool {
return is_file(sprintf($this->pathFormat, $path));
}
public function render(string $path, array $vars = [], ?TemplateSelf $self = null): string {
return $this->create($path)->render($vars, $self);
}
public function getBlock(string $name, mixed $default = ''): string {
return ($this->blocks[$name] ?? new TemplateBlock(new \stdClass, $default))->render();
}
public function setBlock(string $name, TemplateBlock $block): void {
$this->blocks[$name] = $block;
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace Makai;
class TemplateSelf {
public function __construct(
private array $members = [],
?TemplateSelf $inherit = null
) {
if($inherit !== null)
$this->members += $inherit->members;
}
public function __get(string $name): mixed {
return $this->members[$name];
}
public function __set(string $name, mixed $value): void {
$this->members[$name] = $value;
}
public function __isset(string $name): bool {
return isset($this->members[$name]);
}
public function __unset(string $name): void {
unset($this->members[$name]);
}
public function __call(string $name, array $args): mixed {
return ($this->members[$name])(...$args);
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace Makai;
class TemplateVars {
public function __construct(
private ?TemplateVars $parent = null,
private array $vars = []
) {}
public function getVars(): array {
$vars = $this->vars;
if($this->parent !== null)
$vars += $this->parent->getVars();
return $vars;
}
public function getVar(string $name, mixed $default = null): mixed {
if(isset($this->vars[$name]))
return $this->vars[$name];
if($this->parent !== null)
return $this->parent->getVar($name, $default);
return $default;
}
public function setVar(string $name, mixed $value): void {
$this->vars[$name] = $value;
}
public function removeVar(string $name): void {
unset($this->vars[$name]);
}
}

257
templates/ascii.twig Normal file
View file

@ -0,0 +1,257 @@
{% extends 'master.twig' %}
{% set header_title = 'flash.moe / ascii table' %}
{% set header_minimal = true %}
{% set table = [
['Null character', 'NUL'],
['Start of heading', 'SOH'],
['Start of text', 'STX'],
['End of text', 'ETX'],
['End of transmission', 'EOT'],
['Enquiry', 'ENQ'],
['Acknowledgement', 'ACK'],
['Bell', 'BEL'],
['Backspace', 'BS' ],
['Horizontal tab', 'HT' ],
['Line feed', 'LF' ],
['Vertical tab', 'VT' ],
['Form feed', 'FF' ],
['Carriage return', 'CR' ],
['Shift out/X-On', 'SO' ],
['Shift in/X-Off', 'SI' ],
['Delta line escape', 'DLE'],
['Device control 1 (often XON)', 'DC1'],
['Device control 2', 'DC2'],
['Device control 3 (often XOFF)', 'DC3'],
['Device control 4', 'DC4'],
['Negative acknowledgement', 'NAK'],
['Synchronous idle', 'SYN'],
['End of transmit block', 'ETB'],
['Cancel', 'CAN'],
['End of medium', 'EM' ],
['Substitute', 'SUB'],
['Escape', 'ESC'],
['File separator', 'FS' ],
['Group separator', 'GS' ],
['Record separator', 'RS' ],
['Unit separator', 'US' ],
['Space'],
['Excalamation mark'],
['Double quotes', 'quot'],
['Hash'],
['Dollar'],
['Percent'],
['Ampersand', 'amp'],
['Single quote'],
['Open parenthesis'],
['Close parenthesis'],
['Asterisk'],
['Plus'],
['Comma'],
['Hyphen'],
['Period'],
['Slash'],
['Zero'],
['One'],
['Two'],
['Three'],
['Four'],
['Five'],
['Six'],
['Seven'],
['Eight'],
['Nine'],
['Colon'],
['Semicolon'],
['Less than', 'lt'],
['Equals'],
['Greater than', 'gt'],
['Question mark'],
['At symbol'],
['Uppercase A'],
['Uppercase B'],
['Uppercase C'],
['Uppercase D'],
['Uppercase E'],
['Uppercase F'],
['Uppercase G'],
['Uppercase H'],
['Uppercase I'],
['Uppercase J'],
['Uppercase K'],
['Uppercase L'],
['Uppercase M'],
['Uppercase N'],
['Uppercase O'],
['Uppercase P'],
['Uppercase Q'],
['Uppercase R'],
['Uppercase S'],
['Uppercase T'],
['Uppercase U'],
['Uppercase V'],
['Uppercase W'],
['Uppercase X'],
['Uppercase Y'],
['Uppercase Z'],
['Opening bracket'],
['Backslash'],
['Closing bracket'],
['Caret'],
['Underscore'],
['Accent grave'],
['Lowercase a'],
['Lowercase b'],
['Lowercase c'],
['Lowercase d'],
['Lowercase e'],
['Lowercase f'],
['Lowercase g'],
['Lowercase h'],
['Lowercase i'],
['Lowercase j'],
['Lowercase k'],
['Lowercase l'],
['Lowercase m'],
['Lowercase n'],
['Lowercase o'],
['Lowercase p'],
['Lowercase q'],
['Lowercase r'],
['Lowercase s'],
['Lowercase t'],
['Lowercase u'],
['Lowercase v'],
['Lowercase w'],
['Lowercase x'],
['Lowercase y'],
['Lowercase z'],
['Opening curly brace'],
['Vertical bar'],
['Closing curly brace'],
['Tilde'],
['Delete', 'DEL'],
] %}
{% block container %}
<div class="ascii-wrap">
<div class="ascii-search">
<div class="ascii-search-box">
<input type="search" id="search" placeholder="Filter..." autocomplete="off">
</div>
<div class="ascii-search-hint js-invisible-on-scroll">
Type <em><code>printable</code></em> for all printable characters, or <em><code>control</code></em> for all control characters.
</div>
</div>
<div class="ascii-chars">
{% for code, info in table %}
{% set is_printable = code > 31 and code < 127 %}
{% set has_html = is_printable and info[1] is defined %}
{% set print = is_printable ? code|chr : info[1] %}
<div class="ascii-char" data-key-code="{{ code }}" data-key-desc="{{ info[0] }}" data-key-print="{{ print }}" data-copy="{{ print }}" {% if has_html %}data-key-html="{{ info[1] }}"{% endif %}>
<div class="ascii-char-print">{{ print }}</div>
<div class="ascii-char-desc">{{ info[0] }}</div>
<div class="ascii-char-misc">
<div class="ascii-char-misc-item" data-copy="{{ code }}">
<div class="ascii-char-misc-item-head">Decimal</div>
<div class="ascii-char-misc-item-value">{{ code }}</div>
</div>
<div class="ascii-char-misc-item" data-copy="{{ code|decoct }}">
<div class="ascii-char-misc-item-head">Octal</div>
<div class="ascii-char-misc-item-value">{{ code|decoct }}</div>
</div>
<div class="ascii-char-misc-item" data-copy="{{ code|dechex }}">
<div class="ascii-char-misc-item-head">Hex</div>
<div class="ascii-char-misc-item-value">{{ code|dechex }}</div>
</div>
{% if has_html %}
<div class="ascii-char-misc-item" data-copy="&amp;{{ info[1] }};">
<div class="ascii-char-misc-item-head">HTML</div>
<div class="ascii-char-misc-item-value">&amp;{{ info[1] }};</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
<script type="text/javascript">
var chars = document.getElementsByClassName('ascii-char'),
search = document.getElementById('search');
function charsFilter(filter) {
if(!filter) {
for(var i = 0; i < chars.length; ++i)
chars[i].classList.remove('hidden');
return;
}
filter = filter.toLowerCase();
for(var i = 0; i < chars.length; ++i) {
var chr = chars[i],
code = (chr.dataset.keyCode || 0).toString().toLowerCase(),
print = (chr.dataset.keyPrint || "\0").toString().toLowerCase(),
desc = (chr.dataset.keyDesc || '').toString().toLowerCase(),
html = (chr.dataset.keyHtml || "\0").toString().toLowerCase(),
codeInt = parseInt(code),
isMatch = (filter === 'printable' && (codeInt > 31 && codeInt < 127))
|| (filter === 'control' && (codeInt < 32 || codeInt === 127))
|| code == filter || print == filter
|| html == filter || desc.indexOf(filter) >= 0;
chr.classList[isMatch ? 'remove' : 'add']('hidden');
}
};
window.addEventListener('scroll', function() {
var hidden = document.getElementsByClassName('js-hidden-on-scroll'),
invisible = document.getElementsByClassName('js-invisible-on-scroll'),
atTop = window.scrollY === 0;
for(var i = 0; i < hidden.length; ++i)
hidden[i].classList[atTop ? 'remove' : 'add']('hidden');
for(var i = 0; i < invisible.length; ++i)
invisible[i].classList[atTop ? 'remove' : 'add']('invisible');
});
search.addEventListener('keyup', function() {
location.hash = search.value.trim();
});
window.addEventListener('hashchange', function() {
charsFilter(decodeURIComponent((location.hash || '#').substring(1)));
});
if(location.hash.length > 0) {
search.value = location.hash.substring(1).trim();
charsFilter(search.value);
}
for(var i = 0; i < chars.length; ++i) {
chars[i].addEventListener('click', function(ev) {
var target = ev.target;
while(target !== null && typeof target.dataset.copy === 'undefined') {
target = target.parentNode || null;
if(target.classList.contains('char'))
break;
}
if(target === null || typeof target.dataset.copy === 'undefined')
return;
var doCopy = function() { navigator.clipboard.writeText(target.dataset.copy); };
if(typeof window.mozInnerScreenX !== 'undefined')
doCopy();
else
navigator.permissions.query({name: 'clipboard-write'}).then(function(res) {
if(res.state === 'granted' || res.state === 'prompt')
doCopy();
});
});
}
</script>
{% endblock %}

29
templates/contact.twig Normal file
View file

@ -0,0 +1,29 @@
{% extends 'master.twig' %}
{% set header_title = 'flash.moe / contact' %}
{% block container %}
<div class="section">
<div class="section-content">
<div class="section-background"></div>
<h1>Contact</h1>
</div>
</div>
<div class="socials">
{% for contact in contacts %}
<div class="social social-{{ contact.name }}" style="--social-colour: {{ contact.colour }}">
{% if contact.hasLink %}
<a href="{{ contact.link }}" class="social-background" target="_blank" rel="noopener"></a>
{% else %}
<div class="social-background" onclick="fm.selectTextInElement(this.parentNode.querySelector('.social-handle')); fm.copySelectedText();"></div>
{% endif %}
<div class="social-icon {{ contact.icon }}"></div>
<div class="social-content">
<div class="social-name">{{ contact.title }}</div>
<div class="social-handle">{{ contact.display }}</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,4 @@
{% extends 'errors/master.twig' %}
{% set http_error_image = '/assets/errors/403.jpg' %}
{% set http_error_desc = 'You are not supposed to be here.' %}

View file

@ -0,0 +1,4 @@
{% extends 'errors/master.twig' %}
{% set http_error_image = '/assets/errors/404.jpg' %}
{% set http_error_desc = 'Whatever you\'re looking for is no longer here, or might not have been here in the first place.' %}

View file

@ -0,0 +1,4 @@
{% extends 'errors/master.twig' %}
{% set http_error_image = '/assets/errors/405.jpg' %}
{% set http_error_desc = 'You\'re up to something, aren\'t you?' %}

View file

@ -0,0 +1,11 @@
{% extends 'master.twig' %}
{% block container %}
<div class="http-error">
<h2 class="http-error-head">{{ http_error_title|default('Unknown Error #' ~ http_error_code) }}</h2>
{% if http_error_image is defined %}
<img src="{{ http_error_image }}" alt="{{ http_error_image }}" class="http-error-image">
{% endif %}
<div class="http-error-desc">{{ http_error_desc|default('No additional information is available.') }}</div>
</div>
{% endblock %}

View file

@ -1,12 +1,10 @@
<?php
$self->extends('home/master');
{% extends 'home/master.twig' %}
$self->header_title = 'flash.moe / homepage';
$self->header_full = true;
$self->footer_onload = [['fm.initClock'], ['fm.initIndex', 10]];
{% set header_title = 'flash.moe / homepage' %}
{% set header_full = true %}
{% set footer_onload = [['fm.initClock'], ['fm.initIndex', 10]] %}
$self->block('container', function($self) {
?>
{% block container %}
<div class="php">
<div class="php-time">
<div class="php-time-analog">
@ -42,5 +40,4 @@ $self->block('container', function($self) {
</div>
</form>
</div>
<?php
});
{% endblock %}

View file

@ -0,0 +1 @@
{% extends 'master.twig' %}

73
templates/index.twig Normal file
View file

@ -0,0 +1,73 @@
{% extends 'master.twig' %}
{% set header_title = 'flash.moe' %}
{% set header_is_index = true %}
{% set footer_onload = [['fm.initIndex']] %}
{% block container %}
<div class="index-menu">
{% for link in header_nav|slice(1) %}
<a href="{{ link.link }}">{{ link.title }}</a>
{% endfor %}
</div>
<div class="index-featured">
<div class="index-feature">
<div class="index-feature-header">
<a href="/projects" class="index-feature-header-link"></a>
<div class="index-feature-header-title">Projects</div>
<div class="index-feature-header-more">More</div>
</div>
{% for project in projects %}
<div class="index-project" style="background-color: {{ project.colour }};">
<a href="/projects#{{ project.info.cleanName }}" class="index-project-anchor"></a>
<div class="index-project-content">
<div class="index-project-name">{{ project.info.name }}</div>
{% if project.info.hasSummary %}
<div class="index-project-summary">{{ project.info.summary }}</div>
{% endif %}
</div>
<div class="index-project-links">
{% if project.info.hasHomePageUrl %}
<a class="index-project-link index-project-link-homepage" href="{{ project.info.homePageUrl }}" rel="noopener" target="_blank">Homepage</a>
{% endif %}
{% if project.info.hasSourceUrl %}
<a class="index-project-link index-project-link-repository" href="{{ project.info.sourceUrl }}" rel="noopener" target="_blank">Source</a>
{% endif %}
{% if project.info.hasDiscussionUrl %}
<a class="index-project-link index-project-link-forum" href="{{ project.info.discussionUrl }}" rel="noopener" target="_blank">Discussion</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<div class="index-feature">
<div class="index-feature-header">
<a href="/contact" class="index-feature-header-link"></a>
<div class="index-feature-header-title">Contact</div>
<div class="index-feature-header-more">More</div>
</div>
<div class="index-contact">
{% for contact in contacts %}
<div class="social social-{{ contact.name }}" style="--social-colour: {{ contact.colour }}">
{% if contact.hasLink %}
<a href="{{ contact.link }}" class="social-background" target="_blank" rel="noopener"></a>
{% else %}
<div class="social-background" onclick="fm.selectTextInElement(this.parentNode.querySelector('.social-handle')); fm.copySelectedText();"></div>
{% endif %}
<div class="social-icon {{ contact.icon }}"></div>
<div class="social-content">
<div class="social-name">{{ contact.title }}</div>
<div class="social-handle">{{ contact.display }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View file

@ -2,21 +2,21 @@
<html>
<head>
<meta charset="utf-8">
<title><?=($self->header_title ?? 'flash.moe');?></title>
<title>{% if title is defined %}{{ title }} // {% endif %}flash.moe</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link href="/assets/2021.css" type="text/css" rel="stylesheet">
<link href="/assets/sprite.css" type="text/css" rel="stylesheet">
<link href="/assets/fonts/electrolize/style.css" type="text/css" rel="stylesheet">
</head>
<body class="<?php if(isset($self->header_is_index)) { echo 'index ';} if(isset($self->header_full)) { echo 'fullscreen-header ';} if(isset($self->header_now_playing)) { echo 'now-playing ';} if(isset($self->header_minimal)) { echo 'header-minimal ';} ?>">
<body class="{{ html_classes({ 'index': header_is_index is defined, 'fullscreen-header': header_full is defined, 'now-playing': header_now_playing is defined, 'header-minimal': header_minimal is defined }) }}">
<div class="header">
<div class="header-background">
<img src="<?=($self->header_bgs[array_rand($self->header_bgs)]);?>" alt="">
<img src="{{ random(header_bgs) }}" alt="">
</div>
<div class="header-foreground"<?php if(isset($self->header_offset) && $self->header_offset > 0) echo " style=\"padding-bottom: {$self->header_offset}px\""; ?>>
<div class="header-foreground" {% if header_offset is defined and header_offset > 0 %}style="padding-bottom: {{ header_offset }}px"{% endif %}>
<a class="header-logo" href="/">
<div class="header-flash"><?=($self->header_logo_flash ?? 'flash');?></div>
<div class="header-wave"><?=($self->header_logo_wave ?? 'wave');?></div>
<div class="header-flash">{{ header_logo_flash|default('flash') }}</div>
<div class="header-wave">{{ header_logo_wave|default('wave') }}</div>
</a>
<div class="header-right">
<div class="header-now-playing header-now-playing-hidden">
@ -32,24 +32,25 @@
</div>
</div>
<div class="header-menu">
<?php foreach($self->header_nav as $link): ?>
<a href="<?=$link['link'];?>"<?php if($link['link'][0] === '/' && substr($link['link'], 0, 2) !== '//') { echo ''; } ?>><?=$link['title'];?></a>
<?php endforeach; ?>
{% for link in header_nav %}
<a href="{{ link.link }}">{{ link.title }}</a>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="container">
<?=$self->getBlock('container');?>
{% block container %}
{% endblock %}
</div>
<div class="footer">
<div class="footer-text">&copy; flashwave <?=($self->footer_copy_start ?? '2010');?>-<?=($self->footer_copy_end ?? date('Y'));?> - <?=($self->footer_quotes[array_rand($self->footer_quotes)]);?></div>
<div class="footer-text">&copy; flashwave {{ footer_copy_start|default('2010') }}-{{ footer_copy_end|default(('now'|date('Y'))) }} - {{ random(footer_quotes) }}</div>
</div>
<?php if(isset($self->footer_onload) && is_array($self->footer_onload)): ?>
{% if footer_onload is defined %}
<script type="text/javascript">
window.fm = { onload: <?=json_encode($self->footer_onload);?> };
window.fm = { onload: {{ footer_onload|json_encode|raw }} };
</script>
<?php endif; ?>
{% endif %}
<script src="/assets/2021.js" charset="utf-8" type="text/javascript"></script>
</body>
</html>

6
templates/np.twig Normal file
View file

@ -0,0 +1,6 @@
{% extends 'master.twig' %}
{% set header_title = 'flash.moe / now listening' %}
{% set header_full = true %}
{% set header_now_playing = true %}
{% set footer_onload = [['fm.initIndex', 10]] %}

54
templates/projects.twig Normal file
View file

@ -0,0 +1,54 @@
{% extends 'master.twig' %}
{% set header_title = 'flash.moe / projects' %}
{% block container %}
{% for sectionId, section in sections %}
<div class="section" id="section-{{ sectionId }}">
<div class="section-content">
<div class="section-background"></div>
<h1>{{ section.title }}</h1>
</div>
</div>
{% for project in section.items %}
<div class="project project-type-{{ sectionId }}" id="{{ project.info.cleanName }}" style="--project-colour: {{ project.colour }};">
<div class="project-content">
<div class="project-details">
<h2>{{ project.info.name }}<div class="project-languages">
{% for lang in project.langs %}
<div class="project-language" style="--language-colour: {{ lang.colour }};">{{ lang.name }}</div>
{% endfor %}</div>
</h2>
{% if project.info.hasSummary %}
<p class="project-details-summary">{{ project.info.summary }}</p>
{% endif %}
{% if project.info.hasDescription %}
{% set lines = project.info.description|split("\n") %}
{% for line in lines %}
<p>{{ line|trim }}</p>
{% endfor %}
{% endif %}
</div>
<div class="project-links">
{% if project.info.hasHomePageUrl %}
<a class="project-link project-link-homepage" href="{{ project.info.homePageUrl }}" rel="noopener" target="_blank">Homepage</a>
{% endif %}
{% if project.info.hasSourceUrl %}
<a class="project-link project-link-repository" href="{{ project.info.sourceUrl }}" rel="noopener" target="_blank">Source</a>
{% endif %}
{% if project.info.hasDiscussionUrl %}
<a class="project-link project-link-forum" href="{{ project.info.discussionUrl }}" rel="noopener" target="_blank">Discussion</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endfor %}
{% endblock %}

View file

@ -1,14 +1,12 @@
<?php
$self->extends('master');
{% extends 'master.twig' %}
$self->header_title = 'flash.moe / whois';
$self->header_logo_flash = 'flash.moe&nbsp;';
$self->header_logo_wave = 'whois';
$self->header_minimal = true;
$self->footer_copy_start = '2013';
{% set header_title = 'flash.moe / whois' %}
{% set header_logo_flash = 'flash.moe ' %}
{% set header_logo_wave = 'whois' %}
{% set header_minimal = true %}
{% set footer_copy_start = '2013' %}
$self->block('container', function($self) {
?>
{% block container %}
<div class="whois-container">
<form class="whois-lookup-form" method="get" action="">
<input class="whois-lookup-form-input" type="text" name="domain" placeholder="Enter a domain or IP address to look up!" value="" id="lookup-input">
@ -24,5 +22,4 @@ $self->block('container', function($self) {
</div>
<script type="text/javascript" src="/assets/2021whois.js"></script>
<?php
});
{% endblock %}

View file

@ -1,279 +0,0 @@
<?php
$self->extends('master');
$self->header_title = 'flash.moe / ascii table';
$self->header_minimal = true;
$self->block('container', function($self) {
$table = [
// Control characters
['Null character', 'NUL'],
['Start of heading', 'SOH'],
['Start of text', 'STX'],
['End of text', 'ETX'],
['End of transmission', 'EOT'],
['Enquiry', 'ENQ'],
['Acknowledgement', 'ACK'],
['Bell', 'BEL'],
['Backspace', 'BS' ],
['Horizontal tab', 'HT' ],
['Line feed', 'LF' ],
['Vertical tab', 'VT' ],
['Form feed', 'FF' ],
['Carriage return', 'CR' ],
['Shift out/X-On', 'SO' ],
['Shift in/X-Off', 'SI' ],
['Delta line escape', 'DLE'],
['Device control 1 (often XON)', 'DC1'],
['Device control 2', 'DC2'],
['Device control 3 (often XOFF)', 'DC3'],
['Device control 4', 'DC4'],
['Negative acknowledgement', 'NAK'],
['Synchronous idle', 'SYN'],
['End of transmit block', 'ETB'],
['Cancel', 'CAN'],
['End of medium', 'EM' ],
['Substitute', 'SUB'],
['Escape', 'ESC'],
['File separator', 'FS' ],
['Group separator', 'GS' ],
['Record separator', 'RS' ],
['Unit separator', 'US' ],
// Printable characters
['Space'],
['Excalamation mark'],
['Double quotes', 'quot'],
['Hash'],
['Dollar'],
['Percent'],
['Ampersand', 'amp'],
['Single quote'],
['Open parenthesis'],
['Close parenthesis'],
['Asterisk'],
['Plus'],
['Comma'],
['Hyphen'],
['Period'],
['Slash'],
['Zero'],
['One'],
['Two'],
['Three'],
['Four'],
['Five'],
['Six'],
['Seven'],
['Eight'],
['Nine'],
['Colon'],
['Semicolon'],
['Less than', 'lt'],
['Equals'],
['Greater than', 'gt'],
['Question mark'],
['At symbol'],
['Uppercase A'],
['Uppercase B'],
['Uppercase C'],
['Uppercase D'],
['Uppercase E'],
['Uppercase F'],
['Uppercase G'],
['Uppercase H'],
['Uppercase I'],
['Uppercase J'],
['Uppercase K'],
['Uppercase L'],
['Uppercase M'],
['Uppercase N'],
['Uppercase O'],
['Uppercase P'],
['Uppercase Q'],
['Uppercase R'],
['Uppercase S'],
['Uppercase T'],
['Uppercase U'],
['Uppercase V'],
['Uppercase W'],
['Uppercase X'],
['Uppercase Y'],
['Uppercase Z'],
['Opening bracket'],
['Backslash'],
['Closing bracket'],
['Caret'],
['Underscore'],
['Accent grave'],
['Lowercase a'],
['Lowercase b'],
['Lowercase c'],
['Lowercase d'],
['Lowercase e'],
['Lowercase f'],
['Lowercase g'],
['Lowercase h'],
['Lowercase i'],
['Lowercase j'],
['Lowercase k'],
['Lowercase l'],
['Lowercase m'],
['Lowercase n'],
['Lowercase o'],
['Lowercase p'],
['Lowercase q'],
['Lowercase r'],
['Lowercase s'],
['Lowercase t'],
['Lowercase u'],
['Lowercase v'],
['Lowercase w'],
['Lowercase x'],
['Lowercase y'],
['Lowercase z'],
['Opening curly brace'],
['Vertical bar'],
['Closing curly brace'],
['Tilde'],
// Delete
['Delete', 'DEL'],
];
?>
<div class="ascii-wrap">
<div class="ascii-search">
<div class="ascii-search-box">
<input type="search" id="search" placeholder="Filter..." autocomplete="off">
</div>
<div class="ascii-search-hint js-invisible-on-scroll">
Type <em><code>printable</code></em> for all printable characters, or <em><code>control</code></em> for all control characters.
</div>
</div>
<div class="ascii-chars"><?php
foreach($table as $code => $info):
$isPrintable = ($code > 31 && $code < 127);
$print = $isPrintable ? chr($code) : $info[1];
$attrs = ['data-key-code' => $code, 'data-key-desc' => $info[0], 'data-key-print' => $print, 'data-copy' => $print];
$attrStr = '';
if($isPrintable && isset($info[1]))
$attrs['data-key-html'] = $info[1];
foreach($attrs as $name => $value)
$attrStr .= $name . '="' . htmlentities($value) . '" ';
?><div class="ascii-char" <?=trim($attrStr);?>>
<div class="ascii-char-print"><?=$print;?></div>
<div class="ascii-char-desc"><?=$info[0];?></div>
<div class="ascii-char-misc">
<div class="ascii-char-misc-item" data-copy="<?=$code;?>">
<div class="ascii-char-misc-item-head">Decimal</div>
<div class="ascii-char-misc-item-value"><?=$code;?></div>
</div>
<div class="ascii-char-misc-item" data-copy="<?=decoct($code);?>">
<div class="ascii-char-misc-item-head">Octal</div>
<div class="ascii-char-misc-item-value"><?=decoct($code);?></div>
</div>
<div class="ascii-char-misc-item" data-copy="<?=dechex($code);?>">
<div class="ascii-char-misc-item-head">Hex</div>
<div class="ascii-char-misc-item-value"><?=dechex($code);?></div>
</div>
<?php if(isset($attrs['data-key-html'])): ?>
<div class="ascii-char-misc-item" data-copy="&amp;<?=$attrs['data-key-html'];?>;">
<div class="ascii-char-misc-item-head">HTML</div>
<div class="ascii-char-misc-item-value">&amp;<?=$attrs['data-key-html'];?>;</div>
</div>
<?php endif; ?>
</div>
</div><?php endforeach; ?></div>
</div>
<script type="text/javascript">
var chars = document.getElementsByClassName('ascii-char'),
search = document.getElementById('search');
function charsFilter(filter) {
if(!filter) {
for(var i = 0; i < chars.length; ++i)
chars[i].classList.remove('hidden');
return;
}
filter = filter.toLowerCase();
for(var i = 0; i < chars.length; ++i) {
var chr = chars[i],
code = (chr.dataset.keyCode || 0).toString().toLowerCase(),
print = (chr.dataset.keyPrint || "\0").toString().toLowerCase(),
desc = (chr.dataset.keyDesc || '').toString().toLowerCase(),
html = (chr.dataset.keyHtml || "\0").toString().toLowerCase(),
codeInt = parseInt(code),
isMatch = (filter === 'printable' && (codeInt > 31 && codeInt < 127))
|| (filter === 'control' && (codeInt < 32 || codeInt === 127))
|| code == filter || print == filter
|| html == filter || desc.indexOf(filter) >= 0;
chr.classList[isMatch ? 'remove' : 'add']('hidden');
}
};
window.addEventListener('scroll', function() {
var hidden = document.getElementsByClassName('js-hidden-on-scroll'),
invisible = document.getElementsByClassName('js-invisible-on-scroll'),
atTop = window.scrollY === 0;
for(var i = 0; i < hidden.length; ++i)
hidden[i].classList[atTop ? 'remove' : 'add']('hidden');
for(var i = 0; i < invisible.length; ++i)
invisible[i].classList[atTop ? 'remove' : 'add']('invisible');
});
search.addEventListener('keyup', function() {
location.hash = search.value.trim();
});
window.addEventListener('hashchange', function() {
charsFilter(decodeURIComponent((location.hash || '#').substring(1)));
});
if(location.hash.length > 0) {
search.value = location.hash.substring(1).trim();
charsFilter(search.value);
}
for(var i = 0; i < chars.length; ++i) {
chars[i].addEventListener('click', function(ev) {
var target = ev.target;
while(target !== null && typeof target.dataset.copy === 'undefined') {
target = target.parentNode || null;
if(target.classList.contains('char'))
break;
}
if(target === null || typeof target.dataset.copy === 'undefined')
return;
// Clipboard interactions are fucking horrendous
/*if(document.execCommand) {
/*var clipfriend = document.createElement('input');
clipfriend.type = 'text';
clipfriend.value = target.dataset.copy;
clipfriend.className = 'hidden';
document.body.appendChild(clipfriend);
clipfriend.select();
clipfriend.setSelectionRange(0, clipfriend.value.length);
document.execCommand('copy');
document.body.removeChild(clipfriend);
} else {*/
var doCopy = function() { navigator.clipboard.writeText(target.dataset.copy); };
if(typeof window.mozInnerScreenX !== 'undefined')
doCopy();
else
navigator.permissions.query({name: 'clipboard-write'}).then(function(res) {
if(res.state === 'granted' || res.state === 'prompt')
doCopy();
});
//}
});
}
</script>
<?php
});

View file

@ -1,33 +0,0 @@
<?php
$self->extends('master');
$self->header_title = 'flash.moe / contact';
$self->block('container', function($self) {
?>
<div class="section">
<div class="section-content">
<div class="section-background"></div>
<h1>Contact</h1>
</div>
</div>
<div class="socials">
<?php foreach($self->contacts as $contact): ?>
<div class="social social-<?=$contact->getName();?>" style="--social-colour: <?=$contact->getColourHex();?>">
<?php if($contact->hasLink()): ?>
<a href="<?=$contact->getLink();?>" class="social-background" target="_blank" rel="noopener"></a>
<?php else: ?>
<div class="social-background" onclick="fm.selectTextInElement(this.parentNode.querySelector('.social-handle')); fm.copySelectedText();"></div>
<?php endif; ?>
<div class="social-icon <?=$contact->getIcon();?>"></div>
<div class="social-content">
<div class="social-name"><?=$contact->getTitle();?></div>
<div class="social-handle"><?=$contact->getDisplay();?></div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
});

View file

@ -1,5 +0,0 @@
<?php
$self->extends('errors/master');
$self->http_error_image = '/assets/errors/403.jpg';
$self->http_error_desc = 'You are not supposed to be here.';

View file

@ -1,5 +0,0 @@
<?php
$self->extends('errors/master');
$self->http_error_image = '/assets/errors/404.jpg';
$self->http_error_desc = 'Whatever you\'re looking for is no longer here, or might not have been here in the first place.';

View file

@ -1,5 +0,0 @@
<?php
$self->extends('errors/master');
$self->http_error_image = '/assets/errors/405.jpg';
$self->http_error_desc = 'You\'re up to something, aren\'t you?';

View file

@ -1,14 +0,0 @@
<?php
$self->extends('master');
$self->block('container', function($self) {
?>
<div class="http-error">
<h2 class="http-error-head"><?=($self->http_error_title ?? ('Unknown Error #' . $self->http_error_code));?></h2>
<?php if(isset($self->http_error_image)): ?>
<img src="<?=$self->http_error_image;?>" alt="<?=$self->http_error_image;?>" class="http-error-image">
<?php endif; ?>
<div class="http-error-desc"><?=($self->http_error_desc ?? 'No additional information is available.');?></div>
</div>
<?php
});

View file

@ -1,2 +0,0 @@
<?php
$self->extends('master');

View file

@ -1,85 +0,0 @@
<?php
$self->extends('master');
$self->header_title = 'flash.moe';
$self->header_is_index = true;
$self->footer_onload = [['fm.initIndex']];
$self->block('container', function($self) {
?>
<div class="index-menu">
<?php for($i = 1; $i < count($self->header_nav); ++$i): $link = $self->header_nav[$i]; ?>
<a href="<?=$link['link'];?>"><?=$link['title'];?></a>
<?php endfor; ?>
</div>
<div class="index-featured">
<div class="index-feature">
<div class="index-feature-header">
<a href="/projects" class="index-feature-header-link"></a>
<div class="index-feature-header-title">Projects</div>
<div class="index-feature-header-more">More</div>
</div>
<?php
foreach($self->projects as $project):
$links = [];
if($project->hasHomePageUrl())
$links[] = ['class' => 'homepage', 'text' => 'Homepage', 'url' => $project->getHomePageUrl()];
if($project->hasSourceUrl())
$links[] = ['class' => 'repository', 'text' => 'Source', 'url' => $project->getSourceUrl()];
if($project->hasDiscussionUrl())
$links[] = ['class' => 'forum', 'text' => 'Discussion', 'url' => $project->getDiscussionUrl()];
$colour = $project->hasColour() ? $project->getColour() : $self->languages->getProjectColour($project);
$colour = str_pad(dechex($colour), 6, '0', STR_PAD_LEFT);
?>
<div class="index-project" style="background-color: #<?=$colour;?>;">
<a href="/projects#<?=$project->getCleanName();?>" class="index-project-anchor"></a>
<div class="index-project-content">
<div class="index-project-name"><?=$project->getName();?></div>
<?php if($project->hasSummary()): ?>
<div class="index-project-summary"><?=$project->getSummary();?></div>
<?php endif; ?>
</div>
<?php if(!empty($links)): ?>
<div class="index-project-links">
<?php foreach($links as $link): ?>
<a class="index-project-link index-project-link-<?=$link['class'];?>" href="<?=$link['url'];?>" rel="noopener" target="_blank"><?=$link['text'];?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="index-feature">
<div class="index-feature-header">
<a href="/contact" class="index-feature-header-link"></a>
<div class="index-feature-header-title">Contact</div>
<div class="index-feature-header-more">More</div>
</div>
<div class="index-contact">
<?php foreach($self->contacts as $contact): ?>
<div class="social social-<?=$contact->getName();?>" style="--social-colour: <?=$contact->getColourHex();?>">
<?php if($contact->hasLink()): ?>
<a href="<?=$contact->getLink();?>" class="social-background" target="_blank" rel="noopener"></a>
<?php else: ?>
<div class="social-background" onclick="fm.selectTextInElement(this.parentNode.querySelector('.social-handle')); fm.copySelectedText();"></div>
<?php endif; ?>
<div class="social-icon <?=$contact->getIcon();?>"></div>
<div class="social-content">
<div class="social-name"><?=$contact->getTitle();?></div>
<div class="social-handle"><?=$contact->getDisplay();?></div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php
});

View file

@ -1,7 +0,0 @@
<?php
$self->extends('master');
$self->header_title = 'flash.moe / now listening';
$self->header_full = true;
$self->header_now_playing = true;
$self->footer_onload = [['fm.initIndex', 10]];

View file

@ -1,84 +0,0 @@
<?php
$self->extends('master');
$self->header_title = 'flash.moe / projects';
$self->block('container', function($self) {
?>
<?php foreach($self->sections as $sectionId => $section): ?>
<div class="section" id="section-<?=$sectionId;?>">
<div class="section-content">
<div class="section-background"></div>
<h1><?=$section['title'];?></h1>
</div>
</div>
<?php
foreach($section['items'] as $project):
$links = [];
if($project->hasHomePageUrl())
$links[] = ['class' => 'homepage', 'text' => 'Homepage', 'url' => $project->getHomePageUrl()];
if($project->hasSourceUrl())
$links[] = ['class' => 'repository', 'text' => 'Source', 'url' => $project->getSourceUrl()];
if($project->hasDiscussionUrl())
$links[] = ['class' => 'forum', 'text' => 'Discussion', 'url' => $project->getDiscussionUrl()];
$descLines = $project->hasDescription() ? $project->getDescription()->trim()->split("\n") : [];
$langs = $self->languages->getByProject($project);
if($project->hasColour())
$colour = $project->getColour();
else
foreach($langs as $lang)
if($lang->hasColour()) {
$colour = $lang->getColour();
break;
}
$colour = str_pad(dechex($colour), 6, '0', STR_PAD_LEFT);
?>
<div class="project project-type-<?=$sectionId;?>" id="<?=$project->getCleanName();?>" style="--project-colour: #<?=$colour;?>;">
<div class="project-content">
<div class="project-details">
<h2><?=$project->getName();?><div class="project-languages">
<?php
foreach($langs as $lang):
$langColour = str_pad(dechex($lang->getColour() ?? 0), 6, '0', STR_PAD_LEFT);
?>
<div class="project-language" style="--language-colour: #<?=$langColour;?>;"><?=$lang->getName();?></div>
<?php endforeach; ?>
</div></h2>
<?php if($project->hasSummary()): ?>
<p class="project-details-summary"><?=$project->getSummary();?></p>
<?php endif; ?>
<?php
foreach($descLines as $line):
$line = $line->trim();
if($line->isEmpty())
continue;
?>
<p><?=$line;?></p>
<?php endforeach; ?>
</div>
<?php if(!empty($links)): ?>
<div class="project-links">
<?php foreach($links as $link): ?>
<a class="project-link project-link-<?=$link['class'];?>" href="<?=$link['url'];?>" rel="noopener" target="_blank"><?=$link['text'];?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
<?php
});