aboutsummaryrefslogtreecommitdiff
path: root/skin
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2022-07-09 19:40:17 +0300
committerEvgeny Zinoviev <me@ch1p.io>2022-07-09 19:40:17 +0300
commitf7bfdf58def6aadc922e1632f407d1418269a0d7 (patch)
treed7a0b2819e6a26c11d40ee0b27267ea827fbb345 /skin
initial
Diffstat (limited to 'skin')
-rw-r--r--skin/admin.skin.php344
-rw-r--r--skin/base.skin.php190
-rw-r--r--skin/error.skin.php40
-rw-r--r--skin/main.skin.php195
-rw-r--r--skin/markdown.skin.php43
-rw-r--r--skin/rss.skin.php29
6 files changed, 841 insertions, 0 deletions
diff --git a/skin/admin.skin.php b/skin/admin.skin.php
new file mode 100644
index 0000000..f03d7ce
--- /dev/null
+++ b/skin/admin.skin.php
@@ -0,0 +1,344 @@
+<?php
+
+namespace skin\admin;
+
+use Stringable;
+
+// login page
+// ----------
+
+function login($ctx) {
+$html = <<<HTML
+<form action="/admin/login/" method="post" class="form-layout-h">
+ <input type="hidden" name="token" value="{$ctx->csrf('adminlogin')}" />
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label">{$ctx->lang('as_form_password')}:</div>
+ <div class="form-field">
+ <input id="as_password" class="form-field-input" type="password" name="password" size="50" />
+ </div>
+ </div>
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label"></div>
+ <div class="form-field">
+ <button type="submit">{$ctx->lang('submit')}</button>
+ </div>
+ </div>
+</form>
+HTML;
+
+$js = <<<JAVASCRIPT
+ge('as_password').focus();
+JAVASCRIPT;
+
+return [$html, $js];
+}
+
+
+// index page
+// ----------
+
+function index($ctx) {
+ return <<<HTML
+<div class="admin-page">
+<!-- <a href="/admin/log/">Log</a><br/>-->
+ <a href="/admin/logout/?token={$ctx->csrf('logout')}">Sign out</a>
+</div>
+HTML;
+}
+
+
+// uploads page
+// ------------
+
+function uploads($ctx, $uploads, $error) {
+return <<<HTML
+{$ctx->if_true($error, $ctx->formError, $error)}
+
+<div class="blog-upload-form">
+ <form action="/uploads/" method="post" enctype="multipart/form-data" class="form-layout-h">
+ <input type="hidden" name="token" value="{$ctx->csrf('addupl')}" />
+
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label">{$ctx->lang('blog_upload_form_file')}:</div>
+ <div class="form-field">
+ <input type="file" name="files[]" multiple>
+ </div>
+ </div>
+
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label">{$ctx->lang('blog_upload_form_custom_name')}:</div>
+ <div class="form-field">
+ <input type="text" name="name">
+ </div>
+ </div>
+
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label">{$ctx->lang('blog_upload_form_note')}:</div>
+ <div class="form-field">
+ <input type="text" name="note" size="55">
+ </div>
+ </div>
+
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label"></div>
+ <div class="form-field">
+ <input type="submit" value="Upload">
+ </div>
+ </div>
+ </form>
+</div>
+
+<div class="blog-upload-list">
+ {$ctx->for_each($uploads, fn($u) => $ctx->uploadsItem(
+ id: $u->id,
+ name: $u->name,
+ direct_url: $u->getDirectUrl(),
+ note: $u->note,
+ addslashes_note: $u->note,
+ markdown: $u->getMarkdown(),
+ size: $u->getSize(),
+ ))}
+</div>
+HTML;
+}
+
+function uploadsItem($ctx, $id, $direct_url, $note, $addslashes_note, $markdown, $name, $size) {
+return <<<HTML
+<div class="blog-upload-item">
+ <div class="blog-upload-item-actions">
+ <a href="javascript:void(0)" onclick="var mdel = ge('upload{$id}_md'); mdel.style.display = (mdel.style.display === 'none' ? 'block' : 'none')">{$ctx->lang('blog_upload_show_md')}</a>
+ | <a href="javascript:void(0)" onclick="BlogUploadList.submitNoteEdit('/uploads/edit_note/{$id}/?token={$ctx->csrf('editupl'.$id)}', prompt('Note:', '{$addslashes_note}'))">Edit note</a>
+ | <a href="/uploads/delete/{$id}/?token={$ctx->csrf('delupl'.$id)}" onclick="return confirm('{$ctx->lang('blog_upload_delete_confirmation')}')">{$ctx->lang('blog_upload_delete')}</a>
+ </div>
+ <div class="blog-upload-item-name"><a href="{$direct_url}">{$name}</a></div>
+ {$ctx->if_true($note, '<div class="blog-upload-item-note">'.$note.'</div>')}
+ <div class="blog-upload-item-info">{$size}</div>
+ <div class="blog-upload-item-md" id="upload{$id}_md" style="display: none">
+ <input type="text" value="{$markdown}" onclick="this.select()" readonly size="30">
+ </div>
+</div>
+HTML;
+}
+
+function postForm($ctx,
+ string|Stringable $title,
+ string|Stringable $text,
+ string|Stringable $short_name,
+ string|Stringable $tags = '',
+ bool $is_edit = false,
+ $error_code = null,
+ ?bool $saved = null,
+ ?bool $visible = null,
+ string|Stringable|null $post_url = null,
+ ?int $post_id = null): array {
+$form_url = !$is_edit ? '/write/' : $post_url.'edit/';
+
+$html = <<<HTML
+{$ctx->if_true($error_code, '<div class="form-error">'.$ctx->lang('err_blog_'.$error_code).'</div>')}
+{$ctx->if_true($saved, '<div class="form-success">'.$ctx->lang('info_saved').'</div>')}
+<table cellpadding="0" cellspacing="0" class="blog-write-table">
+<tr>
+ <td id="form_first_cell">
+ <form class="blog-write-form form-layout-v" name="postForm" action="{$form_url}" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="token" value="{$ctx->if_then_else($is_edit, $ctx->csrf('editpost'.$post_id), $ctx->csrf('addpost'))}" />
+
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label">{$ctx->lang('blog_write_form_title')}</div>
+ <div class="form-field">
+ <input class="form-field-input" type="text" name="title" value="{$title}" />
+ </div>
+ </div>
+
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label">{$ctx->lang('blog_write_form_text')}</div>
+ <div class="form-field">
+ <textarea class="form-field-input" name="text" wrap="soft">{$text}</textarea><br/>
+ <a class="blog-write-form-toggle-link" id="toggle_wrap" href="">{$ctx->lang('blog_write_form_toggle_wrap')}</a>
+ </div>
+ </div>
+
+ <div class="form-field-wrap clearfix">
+ <table class="blog-write-options-table">
+ <tr>
+ <td>
+ <div class="clearfix">
+ <div class="form-field-label">{$ctx->lang('blog_write_form_tags')}</div>
+ <div class="form-field">
+ <input class="form-field-input" type="text" name="tags" value="{$tags}" />
+ </div>
+ </div>
+ </td>
+ <td>
+ <div class="clearfix">
+ <div class="form-field-label">{$ctx->lang('blog_write_form_options')}</div>
+ <div class="form-field">
+ <label for="visible_cb"><input type="checkbox" id="visible_cb" name="visible"{$ctx->if_true($visible, ' checked="checked"')}> {$ctx->lang('blog_write_form_visible')}</label>
+ </div>
+ </div>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <div class="clearfix">
+ <div class="form-field-label">{$ctx->lang('blog_write_form_short_name')}</div>
+ <div class="form-field">
+ <input class="form-field-input" type="text" name="{$ctx->if_then_else($is_edit, 'new_short_name', 'short_name')}" value="{$short_name}" />
+ </div>
+ </div>
+ </td>
+ <td>
+ <div class="clearfix">
+ <div class="form-field-label">&nbsp;</div>
+ <div class="form-field">
+ <button type="submit" name="submit_btn"><b>{$ctx->lang('blog_write_form_submit_btn')}</b></button>
+ </div>
+ </div>
+ </td>
+ </tr>
+ </table>
+ </div>
+ </form>
+ <div id="form_placeholder"></div>
+ </td>
+ <td>
+ <div class="blog-write-form-preview post_text" id="preview_html"></div>
+ </td>
+</tr>
+</table>
+HTML;
+
+$js_params = json_encode($is_edit
+ ? ['edit' => true, 'id' => $post_id]
+ : (object)[]);
+$js = "AdminWriteForm.init({$js_params});";
+
+return [$html, $js];
+}
+
+
+function pageForm($ctx,
+ string|Stringable $title,
+ string|Stringable $text,
+ string|Stringable $short_name,
+ bool $is_edit = false,
+ $error_code = null,
+ ?bool $saved = null,
+ bool $visible = false): array {
+$form_url = '/'.$short_name.'/'.($is_edit ? 'edit' : 'create').'/';
+$html = <<<HTML
+{$ctx->if_true($error_code, '<div class="form-error">'.$ctx->lang('err_pages_'.$error_code).'</div>')}
+{$ctx->if_true($saved, '<div class="form-success">'.$ctx->lang('info_saved').'</div>')}
+<table cellpadding="0" cellspacing="0" class="blog-write-table">
+<tr>
+ <td id="form_first_cell">
+ <form class="blog-write-form form-layout-v" name="pageForm" action="{$form_url}" method="post">
+ <input type="hidden" name="token" value="{$ctx->if_then_else($is_edit, $ctx->csrf('editpage'.$short_name), $ctx->csrf('addpage'))}" />
+
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label">{$ctx->lang('pages_write_form_title')}</div>
+ <div class="form-field">
+ <input class="form-field-input" type="text" name="title" value="{$title}" />
+ </div>
+ </div>
+
+ <div class="form-field-wrap clearfix">
+ <div class="form-field-label">{$ctx->lang('pages_write_form_text')}</div>
+ <div class="form-field">
+ <textarea class="form-field-input" name="text" wrap="soft">{$text}</textarea><br/>
+ <a class="blog-write-form-toggle-link" id="toggle_wrap" href="">{$ctx->lang('pages_write_form_toggle_wrap')}</a>
+ </div>
+ </div>
+
+ {$ctx->if_then_else($is_edit,
+ fn() => $ctx->pageFormEditOptions($short_name, $visible),
+ fn() => $ctx->pageFormAddOptions($short_name))}
+
+ </form>
+ <div id="form_placeholder"></div>
+ </td>
+ <td>
+ <div class="blog-write-form-preview post_text" id="preview_html"></div>
+ </td>
+</tr>
+</table>
+HTML;
+
+$js_params = json_encode(['pages' => true, 'edit' => $is_edit]);
+$js = <<<JAVASCRIPT
+AdminWriteForm.init({$js_params});
+JAVASCRIPT;
+
+return [$html, $js];
+}
+
+function pageFormEditOptions($ctx, $short_name, $visible) {
+return <<<HTML
+<div class="form-field-wrap clearfix">
+ <table class="blog-write-options-table">
+ <tr>
+ <td>
+ <div class="clearfix">
+ <div class="form-field-label">{$ctx->lang('pages_write_form_short_name')}</div>
+ <div class="form-field">
+ <input class="form-field-input" type="text" name="new_short_name" value="{$short_name}" />
+ </div>
+ </div>
+ </td>
+ <td>
+ <div class="clearfix">
+ <div class="form-field-label">{$ctx->lang('pages_write_form_options')}</div>
+ <div class="form-field">
+ <label for="visible_cb"><input type="checkbox" id="visible_cb" name="visible"{$ctx->if_true($visible, ' checked="checked"')}> {$ctx->lang('pages_write_form_visible')}</label>
+ </div>
+ </div>
+ </td>
+ </tr>
+ <tr>
+ <td rowspan="2">
+ <button type="submit" name="submit_btn"><b>{$ctx->lang('pages_write_form_submit_btn')}</b></button>
+ </td>
+ </tr>
+ </table>
+</div>
+HTML;
+}
+
+function pageFormAddOptions($ctx, $short_name) {
+return <<<HTML
+<div class="form-field-wrap clearfix">
+ <div class="form-field-label"></div>
+ <div class="form-field">
+ <button type="submit" name="submit_btn"><b>{$ctx->lang('pages_write_form_submit_btn')}</b></button>
+ </div>
+</div>
+<input name="short_name" value="{$short_name}" type="hidden" />
+HTML;
+}
+
+function pageNew($ctx, $short_name) {
+return <<<HTML
+<div class="page">
+ <div class="empty">
+ <a href="/{$short_name}/create/">{$ctx->lang('pages_create')}</a>
+ </div>
+</div>
+HTML;
+
+}
+
+// misc
+function formError($ctx, $error) {
+return <<<HTML
+<div class="form-error">{$ctx->lang('error')}: {$error}</div>
+HTML;
+}
+
+function markdownPreview($ctx, $unsafe_html, $title) {
+return <<<HTML
+<div class="blog-post">
+ {$ctx->if_true($title, '<div class="blog-post-title"><h1>'.$title.'</h1></div>')}
+ <div class="blog-post-text">{$unsafe_html}</div>
+</div>
+HTML;
+
+} \ No newline at end of file
diff --git a/skin/base.skin.php b/skin/base.skin.php
new file mode 100644
index 0000000..b0ebac3
--- /dev/null
+++ b/skin/base.skin.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace skin\base;
+
+function layout($ctx, $title, $unsafe_body, $static, $meta, $js, $opts, $exec_time, $unsafe_lang) {
+return <<<HTML
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <link rel="shortcut icon" href="/favicon.ico?4" type="image/x-icon">
+ <link rel="alternate" type="application/rss+xml" href="/feed.rss">
+ <title>{$title}</title>
+ {$ctx->renderMeta($meta)}
+ {$ctx->renderStatic($static)}
+ </head>
+ <body{$ctx->if_true($opts['full_width'], ' class="full-width"')}>
+ {$ctx->renderHeader(renderLogo($ctx, $opts['logo_path_map'], $opts['logo_link_map']))}
+ <div class="page-content base-width">
+ <div class="page-content-inner">{$unsafe_body}</div>
+ </div>
+ {$ctx->if_true($js != '' || !empty($lang) || $opts['dynlogo_enabled'],
+ $ctx->renderScript, $js, $unsafe_lang, $opts['dynlogo_enabled'])}
+ </body>
+</html>
+<!-- exec time: {$exec_time}s -->
+HTML;
+}
+
+function renderScript($ctx, $unsafe_js, $unsafe_lang, $enable_dynlogo) {
+return <<<HTML
+<script type="text/javascript">
+{$ctx->if_true($unsafe_js, '(function(){'.$unsafe_js.'})();')}
+{$ctx->if_true($unsafe_lang, 'extend(__lang, '.$unsafe_lang.');')}
+{$ctx->if_true($enable_dynlogo, 'DynamicLogo.init();')}
+</script>
+HTML;
+}
+
+function renderMeta($ctx, $meta) {
+ if (empty($meta))
+ return '';
+ return implode('', array_map(function(array $item): string {
+ $s = '<meta';
+ foreach ($item as $k => $v)
+ $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"';
+ $s .= '>';
+ return $s;
+ }, $meta));
+}
+
+function renderStatic($ctx, $static) {
+ global $config;
+ $html = [];
+ foreach ($static as $name) {
+ // list($name, $options) = $item;
+ $version = $config['is_dev'] ? time() : $config['static'][substr($name, 1)] ?? 'notfound';
+ if (str_ends_with($name, '.js'))
+ $html[] = jsLink($name, $version);
+ else if (str_ends_with($name, '.css'))
+ $html[] = cssLink($name, $version/*, $options*/);
+ }
+ return implode("\n", $html);
+}
+
+function renderHeader($ctx, $unsafe_logo_html) {
+ return <<<HTML
+<div class="head base-width">
+ <div class="head-inner clearfix">
+ <div class="head-logo">{$unsafe_logo_html}</div>
+ <div class="head-items clearfix">
+ <a class="head-item" href="/"><span><span>blog</span></span></a>
+ <a class="head-item" href="/projects/"><span><span>projects</span></span></a>
+ <a class="head-item" href="https://git.ch1p.io/?s=idle"><span><span>git</span></span></a>
+ <a class="head-item" href="/misc/"><span><span>misc</span></span></a>
+ <a class="head-item" href="/contacts/"><span><span>contacts</span></span></a>
+ {$ctx->if_admin('<a class="head-item" href="/admin/"><span><span>admin</span></span></a>')}
+ </div>
+ </div>
+</div>
+HTML;
+}
+
+// TODO rewrite this fcking crap
+function renderLogo($ctx, array $path_map = [], array $link_map = []): string {
+ $uri = \RequestDispatcher::path();
+
+ if (!\admin::isAdmin()) {
+ $prompt_sign = '<span class="head-logo-dolsign">$</span>';
+ } else {
+ $prompt_sign = '<span class="head-logo-dolsign is_root">#</span>';
+ }
+
+ if ($uri == '/') {
+ $html = '<span class="head-logo-path">/home/'.$ctx->lang('ch1p').'</span> '.$prompt_sign;
+ } else {
+ $uri_len = strlen($uri);
+
+ $html = '<a href="/" id="head_dyn_link">';
+ $close_tags = 0;
+
+ $path_parts = [];
+ $path_links = [];
+
+ $last_pos = 0;
+ $cur_path = '';
+ while ($last_pos < $uri_len) {
+ $first = $last_pos === 0;
+ $end = false;
+
+ $pos = strpos($uri, '/', $last_pos);
+ if ($pos === false || $pos == $uri_len-1) {
+ $pos = $uri_len-1;
+ $end = true;
+ }
+
+ $part = substr($uri, $last_pos, $pos - $last_pos + 1);
+ $cur_path .= $part;
+
+ if ($end) {
+ if (substr($part, -1) == '/')
+ $part = substr($part, 0, strlen($part)-1);
+ $cur_path = '/';
+ $html .= str_repeat('</span>', $close_tags-1);
+ $close_tags = 1;
+ }
+
+ $span_class = 'head-logo-path';
+ if ($first) {
+ $span_class .= ' alwayshover';
+ } else if ($end) {
+ $span_class .= ' neverhover';
+ }
+
+ $html .= '<span class="'.$span_class.'" data-url="$[['.count($path_links).']]">${{'.count($path_parts).'}}';
+ $path_parts[] = ($first ? '~' : '').$part;
+ $path_links[] = $cur_path;
+
+ $last_pos = $pos + 1;
+ $close_tags++;
+ }
+ $html .= str_repeat('</span>', $close_tags).' '.$prompt_sign.' <span class="head-logo-cd">cd <span id="head_cd_text">~</span> <span class="head-logo-enter"><span class="head-logo-enter-icon"></span>Enter</span></span></a>';
+
+ for ($i = count($path_parts)-1, $j = 0; $i >= 0; $i--, $j++) {
+ if (isset($path_map[$j])) {
+ $tmp = htmlescape(strtrim($path_map[$j], 40, $trimmed));
+ if ($trimmed)
+ $tmp .= '&#8230;';
+ $tmp_html = '<span class="head-logo-path-mapped">'.$tmp.'</span>';
+ if ($j > 0)
+ $tmp_html .= '/';
+ $html = str_replace_once('${{'.$i.'}}', $tmp_html, $html);
+ } else {
+ $html = str_replace_once('${{'.$i.'}}', $path_parts[$i], $html);
+ }
+
+ if (isset($link_map[$j])) {
+ $html = str_replace_once('$[['.$i.']]', $link_map[$j], $html);
+ } else {
+ $html = str_replace_once('$[['.$i.']]', $path_links[$i], $html);
+ }
+ }
+ }
+
+ return $html;
+}
+
+function jsLink(string $name, $version = null): string {
+ if ($version !== null)
+ $name .= '?'.$version;
+ return '<script src="'.$name.'" type="text/javascript"></script>';
+}
+
+function cssLink(string $name, $version = null/*, $options = null*/): string {
+ global $config;
+ if ($config['is_dev']) {
+ $bname = basename($name);
+ if (($pos = strrpos($bname, '.')))
+ $bname = substr($bname, 0, $pos);
+ $href = '/sass.php?name='.urlencode($bname);
+ } else {
+ $href = $name.($version !== null ? '?'.$version : '');
+ }
+ $s = '<link rel="stylesheet" type="text/css" href="'.$href.'"';
+ // if (!is_null($options))
+ // $s .= ' media="'.$options.'"';
+ $s .= '>';
+ return $s;
+} \ No newline at end of file
diff --git a/skin/error.skin.php b/skin/error.skin.php
new file mode 100644
index 0000000..b0925d3
--- /dev/null
+++ b/skin/error.skin.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace skin\error;
+
+use Stringable;
+
+function forbidden($ctx, $message) {
+ return $ctx->common(403, 'Forbidden', $message);
+}
+
+function not_found($ctx, $message) {
+ return $ctx->common(404, 'Not Found', $message);
+}
+
+function unauthorized($ctx, $message) {
+ return $ctx->common(401, 'Unauthorized', $message);
+}
+
+function not_implemented($ctx, $message) {
+ return $ctx->common(501, 'Not Implemented', $message);
+}
+
+function common($ctx,
+ int $code,
+ string|Stringable $title,
+ string|Stringable|null $message = null) {
+return <<<HTML
+<html>
+ <head><title>$code $title</title></head>
+ <body>
+ <center><h1>$code $title</h1></center>
+ {$ctx->if_true($message,
+ '<hr><p align="center">'.$message.'</p>'
+ )}
+
+ </body>
+</html>
+HTML;
+
+} \ No newline at end of file
diff --git a/skin/main.skin.php b/skin/main.skin.php
new file mode 100644
index 0000000..40813b9
--- /dev/null
+++ b/skin/main.skin.php
@@ -0,0 +1,195 @@
+<?php
+
+namespace skin\main;
+
+// index page
+// ----------
+
+function index($ctx, array $posts, array $tags) {
+ return empty($posts) ? $ctx->indexEmtpy() : $ctx->indexBlog($posts);
+}
+
+function indexEmtpy($ctx): string {
+return <<<HTML
+<div class="empty">
+ {$ctx->lang('blog_no')}
+ {$ctx->if_admin('<a href="/blog/write/">'.$ctx->lang('write').'</a>')}
+</div>
+HTML;
+}
+
+function indexBlog($ctx, array $posts): string {
+return <<<HTML
+<div class="blog-list">
+ <div class="blog-list-title">
+ all posts
+ {$ctx->if_admin(
+ '<span>
+ <a href="/write/">new</a>
+ <a href="/uploads/">uploads</a>
+ </span>'
+ )}
+ </div>
+ {$ctx->indexPostsTable($posts)}
+</div>
+HTML;
+}
+
+function indexPostsTable($ctx, array $posts): string {
+$ctx->year = 3000;
+return <<<HTML
+<div class="blog-list-table-wrap">
+ <table class="blog-list-table" width="100%" cellspacing="0" cellpadding="0">
+ {$ctx->for_each($posts, fn($post) => $ctx->indexPostRow(
+ $post->getYear(),
+ $post->visible,
+ $post->getDate(),
+ $post->getUrl(),
+ $post->title
+ ))}
+ </table>
+</div>
+HTML;
+}
+
+function indexPostRow($ctx, $year, $is_visible, $date, $url, $title): string {
+return <<<HTML
+{$ctx->if_true($ctx->year > $year, $ctx->indexYearLine, $year)}
+<tr class="blog-item-row{$ctx->if_not($is_visible, ' ishidden')}">
+ <td class="blog-item-date-cell">
+ <span class="blog-item-date">{$date}</span>
+ </td>
+ <td class="blog-item-title-cell">
+ <a class="blog-item-title" href="{$url}">{$title}</a>
+ </td>
+</tr>
+HTML;
+}
+
+function indexYearLine($ctx, $year): string {
+$ctx->year = $year;
+return <<<HTML
+<tr class="blog-item-row-year">
+ <td class="blog-item-date-cell"><span>{$year}</span></td>
+ <td></td>
+</tr>
+HTML;
+}
+
+
+// contacts page
+// -------------
+
+function contacts($ctx, $email) {
+return <<<HTML
+<table class="contacts" cellpadding="0" cellspacing="0">
+ <tr>
+ <td class="wide" colspan="2" style="line-height: 170%; padding-bottom: 18px;">
+ Feel free to contact me by any of the following means:
+ </td>
+ </tr>
+ <tr>
+ <td class="label">Email:</td>
+ <td class="value">
+ <a href="mailto:{$email}">{$email}</a>
+ <div class="note">Please use <a href="/openpgp-pubkey.txt?1">PGP</a>.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="label">Telegram:</td>
+ <td class="value">
+ <a href="https://t.me/eacces">@eacces</a>
+ <div class="note">Please use Secret Chats.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="label">Libera.Chat:</td>
+ <td class="value"><span>ch1p</span></td>
+ </tr>
+</table>
+HTML;
+
+}
+
+
+// any page
+// --------
+
+function page($ctx, $page_url, $short_name, $unsafe_html) {
+return <<<HTML
+<div class="page">
+ {$ctx->if_admin($ctx->pageAdminLinks, $page_url, $short_name)}
+ <div class="blog-post-text">{$unsafe_html}</div>
+</div>
+HTML;
+}
+
+function pageAdminLinks($ctx, $url, $short_name) {
+return <<<HTML
+<div class="page-edit-links">
+ <a href="{$url}edit/">{$ctx->lang('edit')}</a>
+ <a href="{$url}delete/?token={$ctx->csrf('delpage'.$short_name)}" onclick="return confirm('{$ctx->lang('pages_page_delete_confirmation')}')">{$ctx->lang('delete')}</a>
+</div>
+HTML;
+
+}
+
+
+// post page
+// ---------
+
+function post($ctx, $id, $title, $unsafe_html, $date, $visible, $url, $tags, $email, $urlencoded_reply_subject) {
+return <<<HTML
+<div class="blog-post">
+ <div class="blog-post-title">
+ <h1>{$title}</h1>
+ <div class="blog-post-date">
+ {$ctx->if_not($visible, '<b>'.$ctx->lang('blog_post_hidden').'</b> |')}
+ {$date}
+ {$ctx->if_admin($ctx->postAdminLinks, $url, $id)}
+ </div>
+ <div class="blog-post-tags clearfix">
+ {$ctx->for_each($tags, fn($tag) => $ctx->postTag($tag->getUrl(), $tag->tag))}
+ </div>
+ </div>
+ <div class="blog-post-text">{$unsafe_html}</div>
+</div>
+<div class="blog-post-comments">
+ {$ctx->langRaw('blog_comments_text', $email, $urlencoded_reply_subject)}
+</div>
+HTML;
+}
+
+function postAdminLinks($ctx, $url, $id) {
+return <<<HTML
+<a href="{$url}edit/">{$ctx->lang('edit')}</a>
+<a href="{$url}delete/?token={$ctx->csrf('delpost'.$id)}" onclick="return confirm('{$ctx->lang('blog_post_delete_confirmation')}')">{$ctx->lang('delete')}</a>
+HTML;
+}
+
+function postTag($ctx, $url, $name) {
+return <<<HTML
+<a href="{$url}"><span>#</span>{$name}</a>
+HTML;
+
+}
+
+
+// tag page
+// --------
+
+function tag($ctx, $count, $posts, $tag) {
+if (!$count)
+ return <<<HTML
+ <div class="empty">
+ {$ctx->lang('blog_tag_not_found')}
+ </div>
+HTML;
+
+return <<<HTML
+<div class="blog-list">
+ <div class="blog-list-title">#{$tag}</div>
+ {$ctx->indexPostsTable($posts)}
+</div>
+HTML;
+} \ No newline at end of file
diff --git a/skin/markdown.skin.php b/skin/markdown.skin.php
new file mode 100644
index 0000000..02d3a0f
--- /dev/null
+++ b/skin/markdown.skin.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace skin\markdown;
+
+function fileupload($ctx, $name, $direct_url, $note, $size) {
+return <<<HTML
+<div class="md-file-attach">
+ <span class="md-file-attach-icon"></span><a href="{$direct_url}">{$name}</a>
+ {$ctx->if_true($note, '<span class="md-file-attach-note">'.$note.'</span>')}
+ <span class="md-file-attach-size">{$size}</span>
+</div>
+HTML;
+}
+
+function image($ctx,
+ // options
+ $align, $nolabel, $w, $padding_top,
+ // image data
+ $direct_url, $url, $note) {
+return <<<HTML
+<div class="md-image align-{$align}">
+ <div class="md-image-wrap">
+ <a href="{$direct_url}">
+ <div style="background: #f2f2f2 url('{$url}') no-repeat; background-size: contain; width: {$w}px; padding-top: {$padding_top}%;"></div>
+ </a>
+ {$ctx->if_true(
+ $note != '' && !$nolabel,
+ '<div class="md-image-note">'.$note.'</div>'
+ )}
+ </div>
+</div>
+HTML;
+}
+
+function video($ctx, $url, $w, $h) {
+return <<<HTML
+<div class="md-video">
+ <div class="md-video-wrap">
+ <video src="{$url}" controls{$ctx->if_true($w, ' width="'.$w.'"')}{$ctx->if_true($h, ' height="'.$h.'"')}></video>
+ </div>
+</div>
+HTML;
+} \ No newline at end of file
diff --git a/skin/rss.skin.php b/skin/rss.skin.php
new file mode 100644
index 0000000..0806182
--- /dev/null
+++ b/skin/rss.skin.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace skin\rss;
+
+function atom($ctx, $title, $link, $rss_link, $items) {
+return <<<HTML
+<?xml version="1.0" encoding="UTF-8"?>
+<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
+ <channel>
+ <title>{$title}</title>
+ <link>{$link}</link>
+ <description/>
+ <atom:link href="{$rss_link}" rel="self" type="application/rss+xml"/>
+ {$ctx->for_each($items, fn($item) => $ctx->item(...$item))}
+ </channel>
+</rss>
+HTML;
+}
+
+function item($ctx, $title, $link, $pub_date, $description) {
+return <<<HTML
+<item>
+ <title>{$title}</title>
+ <link>{$link}</link>
+ <pubDate>{$pub_date}</pubDate>
+ <description>{$description}</description>
+</item>
+HTML;
+} \ No newline at end of file