multilang articles support

This commit is contained in:
E. S. 2024-02-02 07:11:27 +03:00
parent 7fcc080732
commit ac5a798bd3
32 changed files with 1398 additions and 1053 deletions

View File

@ -7,7 +7,6 @@ This is a source code of 4in1.ws web site.
- posts and pages are written in Markdown: - posts and pages are written in Markdown:
- supports syntax highlighting in code blocks - supports syntax highlighting in code blocks
- supports embedding of uploaded files and image resizing - supports embedding of uploaded files and image resizing
- tags
- rss feed - rss feed
- dark theme - dark theme
- ultra fast on backend: - ultra fast on backend:

View File

@ -49,6 +49,8 @@ class mysql {
$count = 0; $count = 0;
foreach ($fields as $k => $v) { foreach ($fields as $k => $v) {
$names[] = $k; $names[] = $k;
if (is_bool($v))
$v = (int)$v;
$values[] = $v; $values[] = $v;
$count++; $count++;
} }
@ -118,9 +120,14 @@ class mysql {
function query(string $sql, ...$args): mysqli_result|bool { function query(string $sql, ...$args): mysqli_result|bool {
$sql = $this->prepareQuery($sql, ...$args); $sql = $this->prepareQuery($sql, ...$args);
$q = false;
try {
$q = $this->link->query($sql); $q = $this->link->query($sql);
if (!$q) if (!$q)
logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace_as_string(1)); logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace_as_string(1));
} catch (mysqli_sql_exception $e) {
logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtrace_as_string(1));
}
return $q; return $q;
} }

View File

@ -42,6 +42,7 @@ enum HTTPCode: int {
case MovedPermanently = 301; case MovedPermanently = 301;
case Found = 302; case Found = 302;
case InvalidRequest = 400;
case Unauthorized = 401; case Unauthorized = 401;
case NotFound = 404; case NotFound = 404;
case Forbidden = 403; case Forbidden = 403;
@ -51,22 +52,33 @@ enum HTTPCode: int {
} }
function http_error(HTTPCode $http_code, string $message = ''): void { function http_error(HTTPCode $http_code, string $message = ''): void {
if (is_xhr_request()) {
$data = [];
if ($message != '')
$data['message'] = $message;
ajax_error((object)$data, $http_code->value);
} else {
$ctx = new SkinContext('\\skin\\error'); $ctx = new SkinContext('\\skin\\error');
$http_message = preg_replace('/(?<!^)([A-Z])/', ' $1', $http_code->name); $http_message = preg_replace('/(?<!^)([A-Z])/', ' $1', $http_code->name);
$html = $ctx->http_error($http_code->value, $http_message, $message); $html = $ctx->http_error($http_code->value, $http_message, $message);
http_response_code($http_code->value); http_response_code($http_code->value);
echo $html; echo $html;
exit; exit;
}
} }
function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): void { function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): void {
if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found])) if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found]))
internal_server_error('invalid http code'); internal_server_error('invalid http code');
if (is_xhr_request()) {
ajax_ok(['redirect' => $url]);
}
http_response_code($code->value); http_response_code($code->value);
header('Location: '.$url); header('Location: '.$url);
exit; exit;
} }
function invalid_request(string $message = '') { http_error(HTTPCode::InvalidRequest, $message); }
function internal_server_error(string $message = '') { http_error(HTTPCode::InternalServerError, $message); } function internal_server_error(string $message = '') { http_error(HTTPCode::InternalServerError, $message); }
function not_found(string $message = '') { http_error(HTTPCode::NotFound, $message); } function not_found(string $message = '') { http_error(HTTPCode::NotFound, $message); }
function forbidden(string $message = '') { http_error(HTTPCode::Forbidden, $message); } function forbidden(string $message = '') { http_error(HTTPCode::Forbidden, $message); }
@ -83,6 +95,10 @@ function ajax_response(mixed $data, int $code = 200): void {
exit; exit;
} }
function ensure_admin() {
if (!is_admin())
forbidden();
}
abstract class request_handler { abstract class request_handler {
function __construct() { function __construct() {
@ -90,7 +106,6 @@ abstract class request_handler {
'css/common.css', 'css/common.css',
'js/common.js' 'js/common.js'
); );
add_skin_strings_re('/^theme_/');
} }
function before_dispatch(string $http_method, string $action) {} function before_dispatch(string $http_method, string $action) {}
@ -124,8 +139,12 @@ enum InputVarType: string {
case ENUM = 'e'; case ENUM = 'e';
} }
function input(string $input): array { function input(string $input, array $options = []): array {
global $RouterInput; global $RouterInput;
$options = array_merge(['trim' => false], $options);
$strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val;
$input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY);
$ret = []; $ret = [];
foreach ($input as $var) { foreach ($input as $var) {
@ -133,7 +152,7 @@ function input(string $input): array {
$enum_default = null; $enum_default = null;
$pos = strpos($var, ':'); $pos = strpos($var, ':');
if ($pos !== false) { if ($pos === 1) { // only one-character type specifiers are supported
$type = substr($var, 0, $pos); $type = substr($var, 0, $pos);
$rest = substr($var, $pos + 1); $rest = substr($var, $pos + 1);
@ -177,14 +196,14 @@ function input(string $input): array {
$val = $_GET[$name]; $val = $_GET[$name];
} }
if (is_array($val)) if (is_array($val))
$val = implode($val); $val = $strval(implode($val));
$ret[] = match($vartype) { $ret[] = match($vartype) {
InputVarType::INTEGER => (int)$val, InputVarType::INTEGER => (int)$val,
InputVarType::FLOAT => (float)$val, InputVarType::FLOAT => (float)$val,
InputVarType::BOOLEAN => (bool)$val, InputVarType::BOOLEAN => (bool)$val,
InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : (string)$val, InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val),
default => (string)$val default => $strval($val)
}; };
} }
return $ret; return $ret;

View File

@ -12,6 +12,9 @@ $SkinState = new class {
'dynlogo_enabled' => true, 'dynlogo_enabled' => true,
'logo_path_map' => [], 'logo_path_map' => [],
'logo_link_map' => [], 'logo_link_map' => [],
'is_index' => false,
'head_section' => null,
'articles_lang' => null,
]; ];
public array $static = []; public array $static = [];
}; };
@ -38,10 +41,14 @@ function render($f, ...$vars): void {
$lang[$key] = lang($key); $lang[$key] = lang($key);
$lang = !empty($lang) ? json_encode($lang, JSON_UNESCAPED_UNICODE) : ''; $lang = !empty($lang) ? json_encode($lang, JSON_UNESCAPED_UNICODE) : '';
$title = $SkinState->title;
if (!$SkinState->options['is_index'])
$title = lang('4in1').' - '.$title;
$html = $layout_ctx->layout( $html = $layout_ctx->layout(
static: $SkinState->static, static: $SkinState->static,
theme: $theme, theme: $theme,
title: $SkinState->title, title: $title,
opts: $SkinState->options, opts: $SkinState->options,
js: $js, js: $js,
meta: $SkinState->meta, meta: $SkinState->meta,
@ -182,6 +189,27 @@ class SkinContext {
return csrf_get($key); return csrf_get($key);
} }
function bc(array $items, ?string $style = null): string {
$buf = implode(array_map(function(array $i): string {
$buf = '';
$has_url = array_key_exists('url', $i);
if ($has_url)
$buf .= '<a class="bc-item" href="'.htmlescape($i['url']).'">';
else
$buf .= '<span class="bc-item">';
$buf .= htmlescape($i['text']);
if ($has_url)
$buf .= ' <span class="bc-arrow">&rsaquo;</span></a>';
else
$buf .= '</span>';
return $buf;
}, $items));
return '<div class="bc"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
}
protected function _if_condition($condition, $callback, ...$args) { protected function _if_condition($condition, $callback, ...$args) {
if (is_string($condition) || $condition instanceof Stringable) if (is_string($condition) || $condition instanceof Stringable)
$condition = (string)$condition !== ''; $condition = (string)$condition !== '';
@ -234,7 +262,7 @@ class SkinString implements Stringable {
return match ($this->modType) { return match ($this->modType) {
SkinStringModificationType::HTML => htmlescape($this->string), SkinStringModificationType::HTML => htmlescape($this->string),
SkinStringModificationType::URL => urlencode($this->string), SkinStringModificationType::URL => urlencode($this->string),
SkinStringModificationType::JSON => json_encode($this->string, JSON_UNESCAPED_UNICODE), SkinStringModificationType::JSON => jsonEncode($this->string),
SkinStringModificationType::ADDSLASHES => addslashes($this->string), SkinStringModificationType::ADDSLASHES => addslashes($this->string),
default => $this->string, default => $this->string,
}; };

View File

@ -23,7 +23,7 @@ function verify_hostname(?string $host = null): void {
} }
} }
if (is_cli() && str_ends_with(dirname(__DIR__), 'www-dev')) if (is_cli() && str_ends_with(__DIR__, 'www-dev'))
$config['is_dev'] = true; $config['is_dev'] = true;
} }

View File

@ -5,6 +5,7 @@ class AdminHandler extends request_handler {
function __construct() { function __construct() {
parent::__construct(); parent::__construct();
add_static('css/admin.css', 'js/admin.js'); add_static('css/admin.css', 'js/admin.js');
add_skin_strings(['error']);
} }
function before_dispatch(string $http_method, string $action) { function before_dispatch(string $http_method, string $action) {
@ -13,8 +14,10 @@ class AdminHandler extends request_handler {
} }
function GET_index() { function GET_index() {
$admin_info = admin_current_info();
set_title('$admin_title'); set_title('$admin_title');
render('admin/index'); render('admin/index',
admin_login: $admin_info['login']);
} }
function GET_login() { function GET_login() {
@ -26,19 +29,15 @@ class AdminHandler extends request_handler {
function POST_login() { function POST_login() {
csrf_check('adminlogin'); csrf_check('adminlogin');
$password = $_POST['password'] ?? ''; list($login, $password) = input('login, password');
$valid = admin_check_password($password); admin_auth($login, $password)
if ($valid) { ? redirect('/admin/')
admin_log_auth(); : forbidden();
admin_set_cookie();
redirect('/admin/');
}
forbidden();
} }
function GET_logout() { function GET_logout() {
csrf_check('logout'); csrf_check('logout');
admin_unset_cookie(); admin_logout();
redirect('/admin/login/', HTTPCode::Found); redirect('/admin/login/', HTTPCode::Found);
} }
@ -157,26 +156,15 @@ class AdminHandler extends request_handler {
$error_code = 'no_text'; $error_code = 'no_text';
} }
if ($error_code) { if ($error_code)
return $this->_get_pageAdd( ajax_error(['code' => $error_code]);
name: $name,
title: $title,
text: $text,
error_code: $error_code
);
}
if (!pages::add([ if (!pages::add([
'short_name' => $name, 'short_name' => $name,
'title' => $title, 'title' => $title,
'md' => $text 'md' => $text
])) { ])) {
return $this->_get_pageAdd( ajax_error(['code' => 'db_err']);
name: $name,
title: $title,
text: $text,
error_code: 'db_err'
);
} }
$page = pages::getByName($name); $page = pages::getByName($name);
@ -184,97 +172,230 @@ class AdminHandler extends request_handler {
} }
function GET_post_add() { function GET_post_add() {
return $this->_get_postAdd(); add_skin_strings_re('/^(err_)?blog_/');
set_title('$blog_write');
static::make_wide();
$js_texts = [];
foreach (PostLanguage::cases() as $pl) {
$js_texts[$pl->value] = [
'title' => '',
'md' => '',
'toc' => false,
];
}
render('admin/postForm',
title: '',
text: '',
langs: PostLanguage::cases(),
short_name: '',
js_texts: $js_texts,
lang: PostLanguage::getDefault()->value);
} }
function POST_post_add() { function POST_post_add() {
csrf_check('addpost'); if (!is_xhr_request())
invalid_request();
list($text, $title, $tags, $visible, $short_name) csrf_check('post_add');
= input('text, title, tags, b:visible, short_name');
$tags = tags::splitString($tags); list($visibility_enabled, $short_name, $langs, $date)
= input('b:visible, short_name, langs, date');
self::_postEditValidateCommonData($date);
$lang_data = [];
$at_least_one_lang_is_written = false;
foreach (PostLanguage::cases() as $lang) {
list($title, $text, $toc_enabled) = input("title:{$lang->value}, text:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]);
if ($title !== '' && $text !== '') {
$lang_data[$lang->value] = [$title, $text, $toc_enabled];
$at_least_one_lang_is_written = true;
}
}
$error_code = null; $error_code = null;
if (!$title) { if (!$at_least_one_lang_is_written) {
$error_code = 'no_title';
} else if (!$text) {
$error_code = 'no_text'; $error_code = 'no_text';
} else if (empty($tags)) {
$error_code = 'no_tags';
} else if (empty($short_name)) { } else if (empty($short_name)) {
$error_code = 'no_short_name'; $error_code = 'no_short_name';
} }
if ($error_code) if ($error_code)
return $this->_get_postAdd( ajax_error(['code' => $error_code]);
title: $title,
text: $text,
tags: $tags,
short_name: $short_name,
error_code: $error_code
);
$id = posts::add([ $post = posts::add([
'title' => $title, 'visible' => $visibility_enabled,
'md' => $text,
'visible' => (int)$visible,
'short_name' => $short_name, 'short_name' => $short_name,
'date' => $date
]); ]);
if (!$id) if (!$post)
$this->_get_postAdd( ajax_error(['code' => 'db_err', 'message' => 'failed to add post']);
// add texts
foreach ($lang_data as $lang => $data) {
list($title, $text, $toc_enabled) = $data;
if (!$post->addText(
lang: PostLanguage::from($lang),
title: $title, title: $title,
text: $text, md: $text,
tags: $tags, toc: $toc_enabled)
short_name: $short_name, ) {
error_code: 'db_err' posts::delete($post);
); ajax_error(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
}
// set tags
$post = posts::get($id);
$tag_ids = array_values(tags::getTags($tags));
$post->setTagIds($tag_ids);
redirect($post->getUrl());
} }
function GET_auto_delete() { // done
ajax_ok(['url' => $post->getUrl()]);
}
protected static function _postEditValidateCommonData($date) {
$dt = DateTime::createFromFormat("Y-m-d", $date);
$date_is_valid = $dt && $dt->format("Y-m-d") === $date;
if (!$date_is_valid)
ajax_error(['code' => 'no_date']);
}
function GET_page_delete() {
list($name) = input('short_name'); list($name) = input('short_name');
$post = posts::getByName($name);
if ($post) {
csrf_check('delpost'.$post->id);
posts::delete($post);
redirect('/');
}
$page = pages::getByName($name); $page = pages::getByName($name);
if ($page) { if (!$page)
not_found();
csrf_check('delpage'.$page->shortName); csrf_check('delpage'.$page->shortName);
pages::delete($page); pages::delete($page);
redirect('/'); redirect('/');
} }
function GET_post_delete() {
list($name) = input('short_name');
$post = posts::getByName($name);
if (!$post)
not_found(); not_found();
csrf_check('delpost'.$post->id);
posts::delete($post);
redirect('/articles/');
}
function GET_post_edit() {
list($short_name, $saved, $lang) = input('short_name, b:saved, lang');
$lang = PostLanguage::from($lang);
$post = posts::getByName($short_name);
if ($post) {
$texts = $post->getTexts();
if (!isset($texts[$lang->value]))
not_found();
$js_texts = [];
foreach (PostLanguage::cases() as $pl) {
if (isset($texts[$pl->value])) {
$text = $texts[$pl->value];
$js_texts[$pl->value] = [
'title' => $text->title,
'md' => $text->md,
'toc' => $text->toc,
];
} else {
$js_texts[$pl->value] = [
'title' => '',
'md' => '',
'toc' => false,
];
}
}
$text = $texts[$lang->value];
add_skin_strings_re('/^(err_)?blog_/');
add_skin_strings(['blog_post_edit_title']);
set_title(lang('blog_post_edit_title', $text->title));
static::make_wide();
render('admin/postForm',
is_edit: true,
post_id: $post->id,
post_url: $post->getUrl(),
title: $text->title,
text: $text->md,
date: $post->getDateForInputField(),
visible: $post->visible,
toc: $text->toc,
saved: $saved,
short_name: $short_name,
langs: PostLanguage::cases(),
lang: $text->lang->value,
js_texts: $js_texts
);
}
not_found();
}
function POST_post_edit() {
if (!is_xhr_request())
invalid_request();
list($old_short_name, $short_name, $langs, $date) = input('short_name, new_short_name, langs, date');
$post = posts::getByName($old_short_name);
if (!$post)
not_found();
csrf_check('editpost'.$post->id);
self::_postEditValidateCommonData($date);
if (empty($short_name))
ajax_error(['code' => 'no_short_name']);
foreach (explode(',', $langs) as $lang) {
$lang = PostLanguage::from($lang);
list($text, $title, $visible, $toc) = input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}");
$error_code = null;
if (!$title)
$error_code = 'no_title';
else if (!$text)
$error_code = 'no_text';
if ($error_code)
ajax_error(['code' => $error_code]);
$pt = $post->getText($lang);
if (!$pt) {
$pt = $post->addText(
lang: $lang,
title: $title,
md: $text,
toc: $toc
);
if (!$pt)
ajax_error(['code' => 'db_err']);
} else {
$pt->edit([
'title' => $title,
'md' => $text,
'toc' => (int)$toc
]);
}
}
$post_data = ['date' => $date, 'visible' => $visible];
if ($post->shortName != $short_name)
$post_data['short_name'] = $short_name;
$post->edit($post_data);
ajax_ok(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
} }
function GET_auto_edit() { function GET_auto_edit() {
list($short_name, $saved) = input('short_name, b:saved'); list($short_name, $saved) = input('short_name, b:saved');
$post = posts::getByName($short_name);
if ($post) {
$tags = $post->getTags();
return $this->_get_postEdit($post,
title: $post->title,
text: $post->md,
tags: $post->getTags(),
visible: $post->visible,
toc: $post->toc,
short_name: $post->shortName,
saved: $saved,
);
}
$page = pages::getByName($short_name); $page = pages::getByName($short_name);
if ($page) { if ($page) {
return $this->_get_pageEdit($page, return $this->_get_pageEdit($page,
@ -291,50 +412,6 @@ class AdminHandler extends request_handler {
function POST_auto_edit() { function POST_auto_edit() {
list($short_name) = input('short_name'); list($short_name) = input('short_name');
$post = posts::getByName($short_name);
if ($post) {
csrf_check('editpost'.$post->id);
list($text, $title, $tags, $visible, $toc, $short_name)
= input('text, title, tags, b:visible, b:toc, new_short_name');
$tags = tags::splitString($tags);
$error_code = null;
if (!$title) {
$error_code = 'no_title';
} else if (!$text) {
$error_code = 'no_text';
} else if (empty($tags)) {
$error_code = 'no_tags';
} else if (empty($short_name)) {
$error_code = 'no_short_name';
}
if ($error_code)
$this->_get_postEdit($post,
title: $title,
text: $text,
tags: $tags,
visible: $visible,
toc: $toc,
short_name: $short_name,
error_code: $error_code
);
$post->edit([
'title' => $title,
'md' => $text,
'visible' => (int)$visible,
'toc' => (int)$toc,
'short_name' => $short_name
]);
$tag_ids = array_values(tags::getTags($tags));
$post->setTagIds($tag_ids);
redirect($post->getUrl().'edit/?saved=1');
}
$page = pages::getByName($short_name); $page = pages::getByName($short_name);
if ($page) { if ($page) {
csrf_check('editpage'.$page->shortName); csrf_check('editpage'.$page->shortName);
@ -359,7 +436,6 @@ class AdminHandler extends request_handler {
title: $title, title: $title,
text: $text, text: $text,
visible: $visible, visible: $visible,
error_code: $error_code
); );
} }
@ -376,7 +452,7 @@ class AdminHandler extends request_handler {
not_found(); not_found();
} }
protected static function setWidePage() { protected static function make_wide() {
set_skin_opts([ set_skin_opts([
'full_width' => true, 'full_width' => true,
'no_footer' => true 'no_footer' => true
@ -386,17 +462,15 @@ class AdminHandler extends request_handler {
protected function _get_pageAdd( protected function _get_pageAdd(
string $name, string $name,
string $title = '', string $title = '',
string $text = '', string $text = ''
?string $error_code = null
) { ) {
add_skin_strings_re('/^(err_)?pages_/'); add_skin_strings_re('/^(err_)?pages_/');
set_title(lang('pages_create_title', $name)); set_title(lang('pages_create_title', $name));
static::setWidePage(); static::make_wide();
render('admin/pageForm', render('admin/pageForm',
short_name: $name, short_name: $name,
title: $title, title: $title,
text: $text, text: $text);
error_code: $error_code);
} }
protected function _get_pageEdit( protected function _get_pageEdit(
@ -409,62 +483,14 @@ class AdminHandler extends request_handler {
) { ) {
add_skin_strings_re('/^(err_)?pages_/'); add_skin_strings_re('/^(err_)?pages_/');
set_title(lang('pages_page_edit_title', $page->shortName.'.html')); set_title(lang('pages_page_edit_title', $page->shortName.'.html'));
static::setWidePage(); static::make_wide();
render('admin/pageForm', render('admin/pageForm',
is_edit: true, is_edit: true,
short_name: $page->shortName, short_name: $page->shortName,
title: $title, title: $title,
text: $text, text: $text,
visible: $visible, visible: $visible,
saved: $saved, saved: $saved);
error_code: $error_code);
}
protected function _get_postEdit(
Post $post,
string $title = '',
string $text = '',
?array $tags = null,
bool $visible = false,
bool $toc = false,
string $short_name = '',
?string $error_code = null,
bool $saved = false,
) {
add_skin_strings_re('/^(err_)?blog_/');
set_title(lang('blog_post_edit_title', $post->title));
static::setWidePage();
render('admin/postForm',
is_edit: true,
post_id: $post->id,
post_url: $post->getUrl(),
title: $title,
text: $text,
tags: $tags ? implode(', ', $tags) : '',
visible: $visible,
toc: $toc,
saved: $saved,
short_name: $short_name,
error_code: $error_code
);
}
protected function _get_postAdd(
string $title = '',
string $text = '',
?array $tags = null,
string $short_name = '',
?string $error_code = null
) {
add_skin_strings_re('/^(err_)?blog_/');
set_title('$blog_write');
static::setWidePage();
render('admin/postForm',
title: $title,
text: $text,
tags: $tags ? implode(', ', $tags) : '',
short_name: $short_name,
error_code: $error_code);
} }
} }

View File

@ -4,29 +4,16 @@ class MainHandler extends request_handler {
function GET_index() { function GET_index() {
set_title('$site_title'); set_title('$site_title');
set_skin_opts(['is_index' => true]);
render('main/index'); render('main/index');
} }
function GET_about() { redirect('/info/'); } function GET_about() { redirect('/info/'); }
function GET_contacts() { redirect('/info/'); } function GET_contacts() { redirect('/info/'); }
function GET_auto() { function GET_page() {
list($name) = input('name'); list($name) = input('name');
if (is_admin()) {
if (is_numeric($name)) {
$post = posts::get((int)$name);
} else {
$post = posts::getByName($name);
}
if ($post)
return $this->renderPost($post);
$tag = tags::get($name);
if ($tag)
return $this->renderTag($tag);
}
$page = pages::getByName($name); $page = pages::getByName($name);
if ($page) if ($page)
return $this->renderPage($page); return $this->renderPage($page);
@ -40,58 +27,77 @@ class MainHandler extends request_handler {
not_found(); not_found();
} }
protected function renderPost(Post $post) { function GET_post() {
global $config; global $config;
ensure_admin();
list($name, $input_lang) = input('name, lang');
$lang = null;
try {
if ($input_lang)
$lang = PostLanguage::from($input_lang);
} catch (ValueError $e) {
not_found($e->getMessage());
}
if (!$lang)
$lang = PostLanguage::getDefault();
$post = posts::getByName($name);
if ($post) {
if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value)
redirect($post->getUrl());
if (!$post->hasLang($lang))
not_found('no text for language '.$lang->name);
if (!$post->visible && !is_admin()) if (!$post->visible && !is_admin())
not_found(); not_found();
$tags = $post->getTags(); $pt = $post->getText($lang);
$other_langs = [];
foreach (PostLanguage::cases() as $pl) {
if ($pl == $lang)
continue;
if ($post->hasLang($pl))
$other_langs[] = $pl->value;
}
add_meta( add_meta(
['property' => 'og:title', 'content' => $post->title], ['property' => 'og:title', 'content' => $pt->title],
['property' => 'og:url', 'content' => $config['domain'].$post->getUrl()] ['property' => 'og:url', 'content' => $config['domain'].$post->getUrl()]
); );
if (($img = $post->getFirstImage()) !== null) if (($img = $pt->getFirstImage()) !== null)
add_meta(['property' => 'og:image', 'content' => $img->getDirectUrl()]); add_meta(['property' => 'og:image', 'content' => $img->getDirectUrl()]);
add_meta([ add_meta([
'name' => 'description', 'name' => 'description',
'property' => 'og:description', 'property' => 'og:description',
'content' => $post->getDescriptionPreview(155) 'content' => $pt->getDescriptionPreview(155)
]); ]);
set_title($post->title); set_skin_opts(['articles_lang' => $lang->value]);
if ($post->toc) set_title($pt->title);
if ($pt->hasTableOfContents())
set_skin_opts(['wide' => true]); set_skin_opts(['wide' => true]);
render('main/post', render('main/post',
title: $post->title, title: $pt->title,
id: $post->id, id: $post->id,
unsafe_html: $post->getHtml(is_retina(), getUserTheme()), unsafe_html: $pt->getHtml(is_retina(), getUserTheme()),
unsafe_toc_html: $post->getToc(), unsafe_toc_html: $pt->getTableOfContentsHtml(),
date: $post->getFullDate(), date: $post->getFullDate(),
tags: $tags,
visible: $post->visible, visible: $post->visible,
url: $post->getUrl(), url: $post->getUrl(),
email: $config['admin_email'], lang: $lang->value,
urlencoded_reply_subject: 'Re: '.$post->title); other_langs: $other_langs);
} }
protected function renderTag(Tag $tag) {
$tag = tags::get($tag);
if (!is_admin() && !$tag->visiblePostsCount)
not_found(); not_found();
$count = posts::getCountByTagId($tag->id, is_admin());
$posts = $count ? posts::getPostsByTagId($tag->id, is_admin()) : [];
set_title('#'.$tag->tag);
render('main/tag',
count: $count,
posts: $posts,
tag: $tag->tag);
} }
protected function renderPage(Page $page) { protected function renderPage(Page $page) {
@ -100,6 +106,9 @@ class MainHandler extends request_handler {
if (!is_admin() && !$page->visible && $page->get_id() != $config['index_page_id']) if (!is_admin() && !$page->visible && $page->get_id() != $config['index_page_id'])
not_found(); not_found();
if ($page->shortName == 'info')
set_skin_opts(['head_section' => 'about']);
set_title($page ? $page->title : '???'); set_title($page ? $page->title : '???');
render('main/page', render('main/page',
unsafe_html: $page->getHtml(is_retina(), getUserTheme()), unsafe_html: $page->getHtml(is_retina(), getUserTheme()),
@ -110,12 +119,16 @@ class MainHandler extends request_handler {
function GET_rss() { function GET_rss() {
global $config; global $config;
$items = array_map(fn(Post $post) => [ $lang = PostLanguage::getDefault();
'title' => $post->title, $items = array_map(function(Post $post) use ($lang) {
$pt = $post->getText($lang);
return [
'title' => $pt->title,
'link' => $post->getUrl(), 'link' => $post->getUrl(),
'pub_date' => date(DATE_RSS, $post->ts), 'pub_date' => date(DATE_RSS, $post->ts),
'description' => $post->getDescriptionPreview(500), 'description' => $pt->getDescriptionPreview(500)
], posts::getList(0, 20)); ];
}, posts::getList(0, 20, filter_by_lang: $lagn));
$ctx = new SkinContext('\\skin\\rss'); $ctx = new SkinContext('\\skin\\rss');
$body = $ctx->atom( $body = $ctx->atom(
@ -130,9 +143,27 @@ class MainHandler extends request_handler {
} }
function GET_articles() { function GET_articles() {
$posts = posts::getList(0, 1000); ensure_admin();
list($lang) = input('lang');
if ($lang) {
$lang = PostLanguage::tryFrom($lang);
if (!$lang || $lang == PostLanguage::getDefault())
redirect('/articles/');
} else {
$lang = PostLanguage::getDefault();
}
$posts = posts::getList(
include_hidden: is_admin(),
filter_by_lang: $lang);
set_title('$articles'); set_title('$articles');
render('main/articles', posts: $posts); set_skin_opts(['head_section' => 'articles']);
render('main/articles',
posts: $posts,
selected_lang: $lang);
} }
} }

19
helper/ArticlesHelper.php Normal file
View File

@ -0,0 +1,19 @@
<?php
class ArticlesHelper {
static function getRequestedLanguage(): array {
list($lang) = input('lang');
$pl = null;
$default_explicitly_requested = false;
if ($lang) {
$pl = PostLanguage::tryFrom($lang);
if ($pl && $pl == PostLanguage::getDefault())
$default_explicitly_requested = true;
}
if (!$pl)
$pl = PostLanguage::getDefault();
return [$pl, $default_explicitly_requested];
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

View File

@ -1 +1,2 @@
var LS = window.localStorage; var LS = window.localStorage;
window.cur = {};

View File

@ -1,29 +1,32 @@
var Draft = { class Draft {
get: function() { constructor(id, lang = 'en') {
if (!LS) return null; this.id = id
this.lang = lang
var title = LS.getItem('draft_title') || null;
var text = LS.getItem('draft_text') || null;
return {
title: title,
text: text
};
},
setTitle: function(text) {
if (!LS) return null;
LS.setItem('draft_title', text);
},
setText: function(text) {
if (!LS) return null;
LS.setItem('draft_text', text);
},
reset: function() {
if (!LS) return;
LS.removeItem('draft_title');
LS.removeItem('draft_text');
} }
};
setLang(lang) {
this.lang = lang
}
getForLang(lang, what) {
return LS.getItem(this.key(what, lang)) || ''
}
key(what, lang = null) {
if (lang === null)
lang = this.lang
return `draft_${this.id}_${what}__${lang}`
}
reset(langs) {
for (const what of ['title', 'text']) {
for (const l of langs)
LS.removeItem(this.key(what, l))
}
}
get title() { return LS.getItem(this.key('title')) || '' }
get text() { return LS.getItem(this.key('text')) || '' }
set title(val) { LS.setItem(this.key('title'), val) }
set text(val) { LS.setItem(this.key('text'), val) }
}

View File

@ -1,101 +1,211 @@
var AdminWriteForm = { class AdminWriteEditForm {
form: null, constructor(opts = {}) {
previewTimeout: null, this.opts = opts
previewRequest: null, this.form = document.forms[this.isPage() ? 'pageForm' : 'postForm']
this.isFixed = false
this.previewTimeout = null
this.previewRequest = null
this.tocByLang = {}
init: function(opts) { if (!this.isEditing()) {
opts = opts || {}; for (const l of opts.langs) {
this.tocByLang[l] = false
this.opts = opts; }
this.form = document.forms[opts.pages ? 'pageForm' : 'postForm'];
this.isFixed = false;
addEvent(this.form, 'submit', this.onSubmit);
if (!opts.pages)
addEvent(this.form.title, 'input', this.onInput);
addEvent(this.form.text, 'input', this.onInput);
addEvent(ge('toggle_wrap'), 'click', this.onToggleWrapClick);
if (this.form.text.value !== '')
this.showPreview();
// TODO make it more clever and context-aware
/*var draft = Draft.get();
if (draft.title)
this.form.title.value = draft.title;
if (draft.text)
this.form.text.value = draft.text;*/
addEvent(window, 'scroll', this.onScroll);
addEvent(window, 'resize', this.onResize);
},
showPreview: function() {
if (this.previewRequest !== null) {
this.previewRequest.abort();
} }
var params = {
md: this.form.elements.text.value,
use_image_previews: this.opts.pages ? 1 : 0
};
if (!this.opts.pages)
params.title = this.form.elements.title.value
this.previewRequest = ajax.post('/admin/markdown-preview.ajax', params, function(err, response) {
if (err)
return console.error(err);
ge('preview_html').innerHTML = response.html;
});
},
onSubmit: function(event) { this.form.addEventListener('submit', this.onSubmit)
if (this.isPost())
this.form.title.addEventListener('input', this.onInput)
this.form.text.addEventListener('input', this.onInput)
ge('toggle_wrap').addEventListener('click', this.onToggleWrapClick)
if (this.isPost()) {
ge('toc_cb').addEventListener('change', (e) => {
this.tocByLang[this.getCurrentLang()] = e.target.checked
})
}
let lang = 'en'
if (this.isPost()) {
lang = this.getCurrentLang()
this.form.lang.addEventListener('change', (e) => {
let newLang = e.target.options[e.target.selectedIndex].value
this.draft.setLang(newLang)
this.fillFromDraft({applyEvenEmpty: true})
ge('toc_cb').checked = this.tocByLang[newLang]
})
}
let draftId, draftType = !this.isPost() ? 'page' : 'post'
if (this.isEditing()) {
draftId = `edit_${draftType}${this.opts.id}`
} else {
draftId = `new_${draftType}`
}
this.draft = new Draft(draftId, lang);
if (this.isEditing()) {
for (let l in opts.texts) {
this.draft.setLang(l)
this.draft.title = opts.texts[l].title
this.draft.text = opts.texts[l].md
this.tocByLang[l] = opts.texts[l].toc
}
this.draft.setLang(lang)
this.showPreview()
} else {
this.fillFromDraft()
}
// window.addEventListener('scroll', this.onScroll)
// window.addEventListener('resize', this.onResize)
}
getCurrentLang() { return this.form.lang.options[this.form.lang.selectedIndex].value }
isPost() { return !this.opts.pages }
isPage() { return !!this.opts.pages }
isEditing() { return !!this.opts.edit }
fillFromDraft({applyEvenEmpty} = {applyEvenEmpty: false}) {
for (const what of ['title', 'text']) {
if (this.draft[what] !== '' || applyEvenEmpty)
this.form[what].value = this.draft[what]
}
if (this.form.text.value !== '' || applyEvenEmpty)
this.showPreview()
}
showPreview() {
if (this.previewRequest !== null)
this.previewRequest.abort()
const params = {
md: this.form.elements.text.value,
use_image_previews: this.isPage() ? 1 : 0
}
if (this.isPost())
params.title = this.form.elements.title.value
this.previewRequest = ajax.post('/admin/markdown-preview.ajax', params, (err, response) => {
if (err)
return console.error(err)
ge('preview_html').innerHTML = response.html
})
}
showError(code, message) {
if (code) {
let el = ge('form-error')
let label = escape(lang(`err_blog_${code}`))
if (message)
label += ' (' + message + ')'
el.innerHTML = label
el.style.display = 'block'
} else if (message) {
alert(lang('error')+': '+message)
}
}
hideError() {
let el = ge('form-error')
el.style.display = 'none'
}
onSubmit = (evt) => {
const fields = []
try { try {
var fields = ['title', 'text']; if (this.isEditing()) {
if (!this.opts.pages)
fields.push('tags');
if (this.opts.edit) {
fields.push('new_short_name'); fields.push('new_short_name');
} else { } else {
fields.push('short_name'); fields.push('short_name');
} }
for (var i = 0; i < fields.length; i++) { for (const field of fields) {
var field = fields[i]; if (evt.target.elements[field].value.trim() === '')
if (event.target.elements[field].value.trim() === '')
throw 'no_'+field throw 'no_'+field
} }
// Draft.reset(); const fd = new FormData()
for (const f of fields) {
console.log(`field: ${f}`)
fd.append(f, evt.target[f].value.trim())
}
// fd.append('lang', this.getCurrentLang())
fd.append('visible', ge('visible_cb').checked ? 1 : 0)
// language-specific fields
let atLeastOneLangIsWritten = false
const writtenLangs = []
for (const l of this.opts.langs) {
let title = this.draft.getForLang(l, 'title')
let text = this.draft.getForLang(l, 'text')
console.log(`lang: ${l}`, title, text)
if (title !== '' && text !== '')
atLeastOneLangIsWritten = true
fd.append(`title:${l}`, title)
fd.append(`text:${l}`, text)
fd.append(`toc:${l}`, this.tocByLang[l] ? 1 : 0)
writtenLangs.push(l)
}
if (!atLeastOneLangIsWritten)
throw 'no_text'
fd.append('langs', writtenLangs.join(','))
// date field
const dateInput = evt.target.elements.date;
if (!dateInput.value)
throw 'no_date'
fd.append('date', dateInput.value)
fd.append('token', this.opts.token)
cancelEvent(evt)
this.hideError();
ajax.post(evt.target.action, fd, (error, response) => {
if (error) {
this.showError(error.code, error.message)
return;
}
if (response.url) {
this.draft.reset(this.opts.langs);
window.location = response.url
}
})
} catch (e) { } catch (e) {
var error = typeof e == 'string' ? lang((this.opts.pages ? 'err_pages_' : 'err_blog_')+e) : e.message; const errorText = typeof e == 'string' ? lang('error')+': '+lang((this.isPage() ? 'err_pages_' : 'err_blog_')+e) : e.message;
alert(error); alert(errorText);
console.error(e); console.error(e);
return cancelEvent(event); return cancelEvent(evt);
}
} }
},
onToggleWrapClick: function(e) { onToggleWrapClick = (e) => {
var textarea = this.form.elements.text; const textarea = this.form.elements.text
if (!hasClass(textarea, 'nowrap')) { if (!hasClass(textarea, 'nowrap')) {
addClass(textarea, 'nowrap'); addClass(textarea, 'nowrap')
} else { } else {
removeClass(textarea, 'nowrap'); removeClass(textarea, 'nowrap')
}
return cancelEvent(e)
} }
return cancelEvent(e);
},
onInput: function(e) { onInput = (e) => {
if (this.previewTimeout !== null) { if (this.previewTimeout !== null)
clearTimeout(this.previewTimeout); clearTimeout(this.previewTimeout);
}
this.previewTimeout = setTimeout(function() { this.previewTimeout = setTimeout(() => {
this.previewTimeout = null; this.previewTimeout = null;
this.showPreview(); this.showPreview();
// Draft[e.target.name === 'title' ? 'setTitle' : 'setText'](e.target.value); const what = e.target.name === 'title' ? 'title' : 'text'
}.bind(this), 300); this.draft[what] = e.target.value
}, }, 300)
}
onScroll: function() { onScroll = () => {
var ANCHOR_TOP = 10; var ANCHOR_TOP = 10;
var y = window.pageYOffset; var y = window.pageYOffset;
@ -124,21 +234,22 @@ var AdminWriteForm = {
this.isFixed = false; this.isFixed = false;
} }
},
onResize: function() {
if (this.isFixed) {
var form = this.form;
var td = ge('form_first_cell');
var ph = ge('form_placeholder');
var rect = td.getBoundingClientRect();
var pr = parseInt(getComputedStyle(td).paddingRight, 10) || 0;
ph.style.height = form.getBoundingClientRect().height+'px';
form.style.width = (rect.width - pr) + 'px';
} }
}
};
bindEventHandlers(AdminWriteForm); onResize = () => {
if (!this.isFixed)
return
const form = this.form
const td = ge('form_first_cell')
const ph = ge('form_placeholder')
const rect = td.getBoundingClientRect()
const pr = parseInt(getComputedStyle(td).paddingRight, 10) || 0
ph.style.height = form.getBoundingClientRect().height+'px'
form.style.width = (rect.width - pr) + 'px'
}
}
// bindEventHandlers(AdminWriteForm);

View File

@ -8,9 +8,8 @@
}; };
function createXMLHttpRequest() { function createXMLHttpRequest() {
if (window.XMLHttpRequest) { if (window.XMLHttpRequest)
return new XMLHttpRequest(); return new XMLHttpRequest();
}
var xhr; var xhr;
try { try {
@ -59,7 +58,7 @@
break; break;
case 'POST': case 'POST':
if (isObject(data)) { if (isObject(data) && !(data instanceof FormData)) {
var sdata = []; var sdata = [];
for (var k in data) { for (var k in data) {
if (data.hasOwnProperty(k)) { if (data.hasOwnProperty(k)) {
@ -77,32 +76,33 @@
xhr.open(method, url); xhr.open(method, url);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
if (method === 'POST') { if (method === 'POST' && !(data instanceof FormData))
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
}
var callbackFired = false;
xhr.onreadystatechange = function() { xhr.onreadystatechange = function() {
if (callbackFired)
return
if (xhr.readyState === 4) { if (xhr.readyState === 4) {
if ('status' in xhr && !/^2|1223/.test(xhr.status)) {
throw new Error('http code '+xhr.status)
}
if (opts.json) { if (opts.json) {
var resp = JSON.parse(xhr.responseText) var resp = JSON.parse(xhr.responseText)
if (!isObject(resp)) { if (!isObject(resp))
throw new Error('ajax: object expected') throw new Error('ajax: object expected')
}
if (resp.error) { callbackFired = true;
throw new Error(resp.error) if (resp.error)
} callback(resp.error, null, xhr.status);
callback(null, resp.response); else
callback(null, resp.response, xhr.status);
} else { } else {
callback(null, xhr.responseText); callback(null, xhr.responseText, xhr.status);
} }
} }
}; };
xhr.onerror = function(e) { xhr.onerror = function(e) {
callback(e); callback(e, null, 0);
}; };
xhr.send(method === 'GET' ? null : data); xhr.send(method === 'GET' ? null : data);

View File

@ -45,14 +45,15 @@
padding-top: 12px; padding-top: 12px;
} }
td:nth-child(1) { td:nth-child(1) {
width: 70%; width: 40%;
} }
td:nth-child(2) { td:nth-child(2),
td:nth-child(3) {
width: 30%; width: 30%;
padding-left: 10px; padding-left: 10px;
} }
tr:first-child td { tr:first-child td {
padding-top: 0px; padding-top: 0;
} }
button[type="submit"] { button[type="submit"] {
margin-left: 3px; margin-left: 3px;
@ -177,27 +178,9 @@ body.wide .blog-post {
color: $grey; color: $grey;
margin-top: 5px; margin-top: 5px;
font-size: $fs - 1px; font-size: $fs - 1px;
> a { //> a {
margin-left: 5px; // margin-left: 5px;
} //}
}
.blog-post-tags {
margin-top: 16px;
margin-bottom: -1px;
}
.blog-post-tags > a {
display: block;
float: left;
font-size: $fs - 1px;
margin-right: 8px;
cursor: pointer;
}
.blog-post-tags > a:last-child {
margin-right: 0;
}
.blog-post-tags > a > span {
opacity: 0.5;
} }
.blog-post-text {} .blog-post-text {}
@ -206,14 +189,14 @@ body.wide .blog-post {
margin: 13px 0; margin: 13px 0;
} }
p { p, center {
margin-top: 13px; margin-top: 20px;
margin-bottom: 13px; margin-bottom: 20px;
} }
p:first-child { p:first-child, center:first-child {
margin-top: 0; margin-top: 0;
} }
p:last-child { p:last-child, center:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@ -246,10 +229,11 @@ body.wide .blog-post {
} }
blockquote { blockquote {
border-left: 3px $border-color solid; border-left: 2px $quote_line solid;
margin-left: 0; margin-left: 0;
padding: 5px 0 5px 12px; padding: 5px 0 5px 12px;
color: $grey; color: $quote_color;
font-style: italic;
&:first-child { &:first-child {
padding-top: 0; padding-top: 0;
margin-top: 0; margin-top: 0;
@ -367,27 +351,22 @@ body.wide .blog-post {
margin-left: 2px; margin-left: 2px;
} }
$blog-tags-width: 175px;
.index-blog-block { .index-blog-block {
margin-top: 23px; margin-top: 23px;
} }
.blog-list {} .blog-list {}
.blog-list.withtags { .blog-item-right-links {
margin-right: $blog-tags-width + $base-padding*2;
}
.blog-list-title {
font-size: 22px;
margin-bottom: 15px;
> span {
margin-left: 2px;
> a {
font-size: 16px; font-size: 16px;
float: right;
> a {
margin-left: 2px; margin-left: 2px;
} }
}
} }
.blog-links-separator {
color: $grey;
}
.blog-list-table-wrap { .blog-list-table-wrap {
padding: 5px 0; padding: 5px 0;
} }
@ -436,41 +415,3 @@ td.blog-item-title-cell {
} }
} }
/*
a.blog-item-view-all-link {
display: inline-block;
padding: 4px 17px;
@include radius(5px);
background-color: #f4f4f4;
color: #555;
margin-top: 2px;
}
a.blog-item-view-all-link:hover {
text-decoration: none;
background-color: #ededed;
}
*/
.blog-tags {
float: right;
width: $blog-tags-width;
padding-left: $base-padding - 10px;
border-left: 1px $border-color solid;
}
.blog-tags-title {
margin-bottom: 15px;
font-size: 22px;
padding: 0 7px;
}
.blog-tag-item {
padding: 6px 10px;
font-size: $fs - 1px;
}
.blog-tag-item > a {
color: $fg;
}
.blog-tag-item-count {
color: #aaa;
margin-left: 6px;
text-decoration: none !important;
}

View File

@ -51,7 +51,6 @@ textarea {
appearance: none; appearance: none;
box-sizing: border-box; box-sizing: border-box;
border: 1px $input-border solid; border: 1px $input-border solid;
border-radius: 0;
background-color: $input-bg; background-color: $input-bg;
color: $fg; color: $fg;
font-family: $ff; font-family: $ff;
@ -64,34 +63,30 @@ textarea {
border-color: $input-border-focused; border-color: $input-border-focused;
} }
} }
textarea { textarea {
resize: vertical; resize: vertical;
} }
//input[type="checkbox"] { button {
// margin-left: 0; @include radius(3px);
//} background-color: $light-bg;
color: $fg;
padding: 6px 12px;
border: 1px $input-border solid;
font-family: $ff;
font-size: $fs;
outline: none;
cursor: pointer;
position: relative;
}
button:hover {
background-color: $input-border;
}
button:active {
border-color: $input-border-focused;
background-color: $input-border-focused;
}
//button {
// border-radius: 2px;
// background-color: $light-bg;
// color: $fg;
// padding: 7px 12px;
// border: none;
// /*box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);*/
// font-family: $ff;
// font-size: $fs - 1px;
// outline: none;
// cursor: pointer;
// position: relative;
//}
//button:hover {
// box-shadow: 0 1px 9px rgba(0, 0, 0, 0.2);
//}
//button:active {
// top: 1px;
//}
a { a {
text-decoration: none; text-decoration: none;
@ -361,3 +356,37 @@ a.index-dl-line {
} }
} }
} }
.bc {
padding-bottom: 15px;
> a.bc-item, > span.bc-item {
font-size: $fs + 2px;
display: inline-block;
padding: 5px 10px;
&:not(:first-child) {
margin-left: 10px;
}
}
> a.bc-item {
background-color: $hover-hl;
border-radius: 4px;
}
> span.bc-item {
padding: 5px;
font-weight: 600;
}
> a.bc-item:hover {
text-decoration: none;
border-bottom-color: $link-color-underline;
background-color: $hover-hl-darker;
}
// arrow
span.bc-arrow {
color: $grey !important;
}
}

View File

@ -2,7 +2,6 @@
display: table; display: table;
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
//border-bottom: 1px $border-color solid;
} }
.head-inner { .head-inner {
display: table-row; display: table-row;
@ -22,23 +21,16 @@
background-color: transparent; background-color: transparent;
display: inline-block; display: inline-block;
&:hover {
border-radius: 4px;
background-color: $hover-hl;
}
> a:hover { > a:hover {
text-decoration: none; text-decoration: none;
} }
&-title { &-title {
color: $fg;
padding-bottom: 3px; padding-bottom: 3px;
&-author { &-author {
font-weight: normal; font-weight: normal;
color: $grey; color: $grey;
//font-size: $fs;
//position: relative;
//top: -5px;
} }
} }
@ -49,7 +41,36 @@
padding-top: 2px; padding-top: 2px;
line-height: 18px; line-height: 18px;
} }
} }
.head.no-subtitle .head-logo {
padding-bottom: 0;
> a {
border-bottom: 1px transparent solid;
display: inline-block;
&:hover {
border-bottom: 1px $border-color solid;
}
.head-logo-subtitle,
.head-logo-title {
display: block;
float: left;
}
.head-logo-subtitle {
margin-left: 10px;
font-size: $fs;
}
.head-logo-subtitle > br {
display: none;
}
}
}
//body:not(.theme-changing) .head-logo { //body:not(.theme-changing) .head-logo {
// @include transition(background-color, 0.03s); // @include transition(background-color, 0.03s);
//} //}
@ -57,8 +78,9 @@
.head-items { .head-items {
text-align: right; text-align: right;
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: top;
color: $dark-grey; // color of separators color: $dark-grey; // color of separators
padding-top: 15px;
} }
a.head-item { a.head-item {
color: $fg; color: $fg;
@ -79,7 +101,7 @@ a.head-item {
} }
} }
&:hover { &:hover, &.is-selected {
border-radius: 4px; border-radius: 4px;
background-color: $hover-hl; background-color: $hover-hl;
text-decoration: none; text-decoration: none;

View File

@ -4,18 +4,26 @@ textarea {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.head, .head-inner, .head-logo-wrap, .head-items, head-logo { .page-content {
padding: 0 15px;
}
.head, .head-inner, .head-logo-wrap, .head-items, .head-logo {
display: block; display: block;
} }
.head {
overflow: hidden;
}
.head-logo-wrap {
margin-left: -20px;
margin-right: -20px;
border-bottom: 1px $border-color solid;
padding-top: 5px;
padding-bottom: 5px;
}
.head-logo { .head-logo {
margin: 0; margin: 0;
}
.head-logo-wrap {
padding-bottom: 4px;
}
.head-logo {
border: 1px $border-color solid;
border-radius: 6px;
display: block; display: block;
text-align: center; text-align: center;
font-size: $fs; font-size: $fs;
@ -23,22 +31,19 @@ textarea {
padding-top: 14px; padding-top: 14px;
padding-bottom: 14px; padding-bottom: 14px;
&:hover { > a:hover {
border-color: $hover-hl; border-bottom-color: transparent !important;
} }
//&-subtitle {
// font-size: $fs - 2px;
//}
} }
.head-items { .head-items {
text-align: center; text-align: center;
padding: 8px 0 16px; padding: 15px 0;
white-space: nowrap; white-space: nowrap;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
margin-left: -10px; //margin-left: -10px;
margin-right: -10px; //margin-right: -10px;
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@ -50,13 +55,6 @@ a.head-item:last-child > span {
border-right: 0; border-right: 0;
} }
// blog
.blog-tags {
display: none;
}
.blog-list.withtags {
margin-right: 0;
}
.blog-post-text code { .blog-post-text code {
word-wrap: break-word; word-wrap: break-word;
} }

View File

@ -6,6 +6,8 @@ $link-color-underline: #69849d;
$hover-hl: rgba(255, 255, 255, 0.09); $hover-hl: rgba(255, 255, 255, 0.09);
$hover-hl-darker: rgba(255, 255, 255, 0.12); $hover-hl-darker: rgba(255, 255, 255, 0.12);
$grey: #798086; $grey: #798086;
$quote_color: #3c9577;
$quote_line: #45544d;
$dark-grey: $grey; $dark-grey: $grey;
$light-grey: $grey; $light-grey: $grey;
$fg: #eee; $fg: #eee;
@ -15,11 +17,13 @@ $code-block-bg: #394146;
$inline-code-block-bg: #394146; $inline-code-block-bg: #394146;
$light-bg: #464c4e; $light-bg: #464c4e;
$light-bg-hover: #dce3e8;
$dark-bg: #444; $dark-bg: #444;
$dark-fg: #999; $dark-fg: #999;
$input-border: #48535a; $input-border: #48535a;
$input-border-focused: #48535a; $input-border-focused: lighten($input-border, 7%);
$input-bg: #30373b; $input-bg: #30373b;
$border-color: #48535a; $border-color: #48535a;

View File

@ -6,6 +6,8 @@ $link-color-underline: #95b5da;
$hover-hl: #f0f0f0; $hover-hl: #f0f0f0;
$hover-hl-darker: #ebebeb; $hover-hl-darker: #ebebeb;
$grey: #888; $grey: #888;
$quote_color: #1f9329;
$quote_line: #d1e0d2;
$dark-grey: #777; $dark-grey: #777;
$light-grey: #999; $light-grey: #999;
$fg: #222; $fg: #222;
@ -15,11 +17,13 @@ $code-block-bg: #f3f3f3;
$inline-code-block-bg: #f1f1f1; $inline-code-block-bg: #f1f1f1;
$light-bg: #efefef; $light-bg: #efefef;
$light-bg-hover: #dce3e8;
$dark-bg: #dfdfdf; $dark-bg: #dfdfdf;
$dark-fg: #999; $dark-fg: #999;
$input-border: #e0e0e0; $input-border: #e0e0e0;
$input-border-focused: #e0e0e0; $input-border-focused: darken($input-border, 7%);
$input-bg: #f7f7f7; $input-bg: #f7f7f7;
$border-color: #e0e0e0; $border-color: #e0e0e0;

View File

@ -15,17 +15,22 @@ set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT);
spl_autoload_register(function($class) { spl_autoload_register(function($class) {
static $libs = [ static $libs = [
'lib/tags' => ['Tag', 'tags'],
'lib/pages' => ['Page', 'pages'], 'lib/pages' => ['Page', 'pages'],
'lib/posts' => ['Post', 'posts'], 'lib/posts' => ['Post', 'PostText', 'PostLanguage', 'posts'],
'lib/uploads' => ['Upload', 'uploads'], 'lib/uploads' => ['Upload', 'uploads'],
'engine/model' => ['model'], 'engine/model' => ['model'],
'engine/skin' => ['SkinContext'], 'engine/skin' => ['SkinContext'],
]; ];
if (str_ends_with($class, 'Handler')) { $path = null;
$path = APP_ROOT.'/handler/'.str_replace('\\', '/', $class).'.php'; foreach (['Handler', 'Helper'] as $sfx) {
} else { if (str_ends_with($class, $sfx)) {
$path = APP_ROOT.'/'.strtolower($sfx).'/'.str_replace('\\', '/', $class).'.php';
break;
}
}
if (is_null($path)) {
foreach ($libs as $lib_file => $class_names) { foreach ($libs as $lib_file => $class_names) {
if (in_array($class, $class_names)) { if (in_array($class, $class_names)) {
$path = APP_ROOT.'/'.$lib_file.'.php'; $path = APP_ROOT.'/'.$lib_file.'.php';
@ -34,7 +39,7 @@ spl_autoload_register(function($class) {
} }
} }
if (!isset($path)) if (is_null($path))
$path = APP_ROOT.'/lib/'.$class.'.php'; $path = APP_ROOT.'/lib/'.$class.'.php';
if (!is_file($path)) if (!is_file($path))

View File

@ -4,48 +4,148 @@ require_once 'lib/stored_config.php';
const ADMIN_SESSION_TIMEOUT = 86400 * 14; const ADMIN_SESSION_TIMEOUT = 86400 * 14;
const ADMIN_COOKIE_NAME = 'admin_key'; const ADMIN_COOKIE_NAME = 'admin_key';
const ADMIN_LOGIN_MAX_LENGTH = 32;
$AdminSession = [
'id' => null,
'auth_id' => 0,
'login' => null,
];
function is_admin(): bool { function is_admin(): bool {
static $is_admin = null; global $AdminSession;
if (is_null($is_admin)) if ($AdminSession['id'] === null)
$is_admin = _admin_verify_key(); _admin_check();
return $is_admin; return $AdminSession['id'] != 0;
} }
function _admin_verify_key(): bool { function admin_current_info(): array {
if (isset($_COOKIE[ADMIN_COOKIE_NAME])) { global $AdminSession;
return [
'id' => $AdminSession['id'],
'login' => $AdminSession['login']
];
}
function _admin_check(): void {
if (!isset($_COOKIE[ADMIN_COOKIE_NAME]))
return;
$cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME]; $cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME];
if ($cookie !== _admin_get_key()) $db = DB();
admin_unset_cookie(); $q = $db->query("SELECT
admin_auth.id AS auth_id,
admin_auth.admin_id AS id,
admins.login AS login
FROM admin_auth
LEFT JOIN admins ON admin_auth.admin_id=admins.id
WHERE admin_auth.token=?
LIMIT 1", $cookie);
if (!$db->numRows($q))
return;
$info = $db->fetch($q);
global $AdminSession;
$AdminSession['id'] = (int)$info['id'];
$AdminSession['login'] = $info['login'];
$AdminSession['auth_id'] = (int)$info['auth_id'];
}
function admin_exists(string $login): bool {
$db = DB();
return (int)$db->result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0;
}
function admin_add(string $login, string $password): int {
$db = DB();
$db->insert('admins', [
'login' => $login,
'password' => salt_password($password)
]);
return $db->insertId();
}
function admin_delete(string $login): bool {
$db = DB();
$id = admin_get_id_by_login($login);
if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false;
if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false;
return true; return true;
} }
function admin_get_id_by_login(string $login): ?int {
$db = DB();
$q = $db->query("SELECT id FROM admins WHERE login=?", $login);
return $db->numRows($q) > 0 ? (int)$db->result($q) : null;
}
function admin_set_password(string $login, string $password): bool {
$db = DB();
$db->query("UPDATE admins SET password=? WHERE login=?", salt_password($password), $login);
return $db->affectedRows() > 0;
}
function admin_auth(string $login, string $password): bool {
global $AdminSession;
$db = DB();
$q = $db->query("SELECT id FROM admins WHERE login=? AND password=?", $login, salt_password($password));
if (!$db->numRows($q))
return false; return false;
$id = (int)$db->result($q);
$time = time();
do {
$token = strgen(32);
} while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0);
$db->insert('admin_auth', [
'admin_id' => $id,
'token' => $token,
'ts' => $time
]);
$db->insert('admin_log', [
'admin_id' => $id,
'ts' => $time,
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
]);
$AdminSession = [
'id' => $id,
'login' => $login,
];
admin_set_cookie($token);
return true;
} }
function admin_check_password(string $pwd): bool { function admin_logout() {
return salt_password($pwd) === scGet('admin_pwd'); if (!is_admin())
return;
global $AdminSession;
$db = DB();
$db->query("DELETE FROM admin_auth WHERE id=?", $AdminSession['auth_id']);
$AdminSession['id'] = null;
$AdminSession['login'] = null;
$AdminSession['auth_id'] = 0;
admin_unset_cookie();
} }
function _admin_get_key(): string { function admin_set_cookie(string $token): void {
$admin_pwd_hash = scGet('admin_pwd');
return salt_password("$admin_pwd_hash|{$_SERVER['REMOTE_ADDR']}");
}
function admin_set_cookie(): void {
global $config; global $config;
$key = _admin_get_key(); setcookie(ADMIN_COOKIE_NAME, $token, time() + ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']);
setcookie(ADMIN_COOKIE_NAME, $key, time() + ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']);
} }
function admin_unset_cookie(): void { function admin_unset_cookie(): void {
global $config; global $config;
setcookie(ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']); setcookie(ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']);
} }
function admin_log_auth(): void {
DB()->insert('admin_log', [
'ts' => time(),
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
]);
}

View File

@ -2,11 +2,7 @@
class cli { class cli {
protected ?array $commandsCache = null; protected array $commands = [];
function __construct(
protected string $ns
) {}
protected function usage($error = null): void { protected function usage($error = null): void {
global $argv; global $argv;
@ -15,19 +11,15 @@ class cli {
echo "error: {$error}\n\n"; echo "error: {$error}\n\n";
echo "Usage: $argv[0] COMMAND\n\nCommands:\n"; echo "Usage: $argv[0] COMMAND\n\nCommands:\n";
foreach ($this->getCommands() as $c) foreach ($this->commands as $c => $tmp)
echo " $c\n"; echo " $c\n";
exit(is_null($error) ? 0 : 1); exit(is_null($error) ? 0 : 1);
} }
function getCommands(): array { function on(string $command, callable $f) {
if (is_null($this->commandsCache)) { $this->commands[$command] = $f;
$funcs = array_filter(get_defined_functions()['user'], fn(string $f) => str_starts_with($f, $this->ns)); return $this;
$funcs = array_map(fn(string $f) => str_replace('_', '-', substr($f, strlen($this->ns.'\\'))), $funcs);
$this->commandsCache = array_values($funcs);
}
return $this->commandsCache;
} }
function run(): void { function run(): void {
@ -39,12 +31,14 @@ class cli {
if ($argc < 2) if ($argc < 2)
$this->usage(); $this->usage();
if (empty($this->commands))
cli::die("no commands added");
$func = $argv[1]; $func = $argv[1];
if (!in_array($func, $this->getCommands())) if (!isset($this->commands[$func]))
self::usage('unknown command "'.$func.'"'); self::usage('unknown command "'.$func.'"');
$func = str_replace('-', '_', $func); $this->commands[$func]();
call_user_func($this->ns.'\\'.$func);
} }
public static function input(string $prompt): string { public static function input(string $prompt): string {

View File

@ -1,28 +1,132 @@
<?php <?php
enum PostLanguage: string {
case Russian = 'ru';
case English = 'en';
public static function getDefault(): PostLanguage {
return self::English;
}
}
class Post extends model { class Post extends model {
const DB_TABLE = 'posts'; const DB_TABLE = 'posts';
public int $id; public int $id;
public string $date;
public bool $visible;
public string $shortName;
protected array $texts = [];
public function addText(PostLanguage $lang, string $title, string $md, bool $toc): ?PostText {
$html = markup::markdownToHtml($md);
$text = markup::htmlToText($html);
$data = [
'title' => $title,
'lang' => $lang->value,
'post_id' => $this->id,
'html' => $html,
'text' => $text,
'md' => $md,
'toc' => $toc,
];
$db = DB();
if (!$db->insert('posts_texts', $data))
return null;
$id = $db->insertId();
$post_text = posts::getText($id);
$post_text->updateImagePreviews();
return $post_text;
}
public function registerText(PostText $postText): void {
if (array_key_exists($postText->lang->value, $this->texts))
throw new Exception("text for language {$postText->lang->value} has already been registered");
$this->texts[$postText->lang->value] = $postText;
}
public function loadTexts() {
if (!empty($this->texts))
return;
$db = DB();
$q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id);
while ($row = $db->fetch($q)) {
$text = new PostText($row);
$this->registerText($text);
}
}
/**
* @return PostText[]
*/
public function getTexts(): array {
$this->loadTexts();
return $this->texts;
}
public function getText(PostLanguage $lang): ?PostText {
$this->loadTexts();
return $this->texts[$lang->value] ?? null;
}
public function hasLang(PostLanguage $lang) {
$this->loadTexts();
foreach ($this->texts as $text) {
if ($text->lang == $lang)
return true;
}
return false;
}
public function getUrl(?PostLanguage $lang = null): string {
$buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/";
if ($lang && $lang != PostLanguage::English)
$buf .= '?lang='.$lang->value;
return $buf;
}
protected function getTimestamp(): int {
return (new DateTime($this->date))->getTimestamp();
}
public function getDate(): string {
return date('j M', $this->getTimestamp());
}
public function getYear(): int {
return (int)date('Y', $this->getTimestamp());
}
public function getFullDate(): string {
return date('j F Y', $this->getTimestamp());
}
public function getDateForInputField(): string {
return date('Y-m-d', $this->getTimestamp());
}
}
class PostText extends model {
const DB_TABLE = 'posts_texts';
public int $id;
public int $postId;
public PostLanguage $lang;
public string $title; public string $title;
public string $md; public string $md;
public string $html; public string $html;
public string $tocHtml;
public string $text; public string $text;
public int $ts;
public int $updateTs;
public bool $visible;
public bool $toc; public bool $toc;
public string $shortName; public string $tocHtml;
function edit(array $fields) {
$cur_ts = time();
if (!$this->visible && $fields['visible'])
$fields['ts'] = $cur_ts;
$fields['update_ts'] = $cur_ts;
public function edit(array $fields) {
if ($fields['md'] != $this->md) { if ($fields['md'] != $this->md) {
$fields['html'] = markup::markdownToHtml($fields['md']); $fields['html'] = markup::markdownToHtml($fields['md']);
$fields['text'] = markup::htmlToText($fields['html']); $fields['text'] = markup::htmlToText($fields['html']);
@ -36,116 +140,42 @@ class Post extends model {
$this->updateImagePreviews(); $this->updateImagePreviews();
} }
function updateHtml() { public function updateHtml(): void {
$html = markup::markdownToHtml($this->md); $html = markup::markdownToHtml($this->md);
$this->html = $html; $this->html = $html;
DB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id);
DB()->query("UPDATE posts SET html=? WHERE id=?", $html, $this->id);
} }
function updateText() { public function updateText(): void {
$html = markup::markdownToHtml($this->md); $html = markup::markdownToHtml($this->md);
$text = markup::htmlToText($html); $text = markup::htmlToText($html);
$this->text = $text; $this->text = $text;
DB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id);
DB()->query("UPDATE posts SET text=? WHERE id=?", $text, $this->id);
} }
function getDescriptionPreview(int $len): string { public function getDescriptionPreview(int $len): string {
if (mb_strlen($this->text) >= $len) if (mb_strlen($this->text) >= $len)
return mb_substr($this->text, 0, $len-3).'...'; return mb_substr($this->text, 0, $len-3).'...';
return $this->text; return $this->text;
} }
function getFirstImage(): ?Upload { public function getFirstImage(): ?Upload {
if (!preg_match('/\{image:([\w]{8})/', $this->md, $match)) if (!preg_match('/\{image:([\w]{8})/', $this->md, $match))
return null; return null;
return uploads::getUploadByRandomId($match[1]); return uploads::getUploadByRandomId($match[1]);
} }
function getUrl(): string { public function getHtml(bool $is_retina, string $theme): string {
return $this->shortName != '' ? "/{$this->shortName}/" : "/{$this->id}/";
}
function getDate(): string {
return date('j M', $this->ts);
}
function getYear(): int {
return (int)date('Y', $this->ts);
}
function getFullDate(): string {
return date('j F Y', $this->ts);
}
function getUpdateDate(): string {
return date('j M', $this->updateTs);
}
function getFullUpdateDate(): string {
return date('j F Y', $this->updateTs);
}
function getHtml(bool $is_retina, string $theme): string {
$html = $this->html; $html = $this->html;
$html = markup::htmlImagesFix($html, $is_retina, $theme); return markup::htmlImagesFix($html, $is_retina, $theme);
return $html;
} }
function getToc(): ?string { public function getTableOfContentsHtml(): ?string {
return $this->toc ? $this->tocHtml : null; return $this->toc ? $this->tocHtml : null;
} }
function isUpdated(): bool { public function hasTableOfContents(): bool {
return $this->updateTs && $this->updateTs != $this->ts; return $this->toc;
}
/**
* @return Tag[]
*/
function getTags(): array {
$db = DB();
$q = $db->query("SELECT tags.* FROM posts_tags
LEFT JOIN tags ON tags.id=posts_tags.tag_id
WHERE posts_tags.post_id=?
ORDER BY posts_tags.tag_id", $this->id);
return array_map('Tag::create_instance', $db->fetchAll($q));
}
/**
* @return int[]
*/
function getTagIds(): array {
$ids = [];
$db = DB();
$q = $db->query("SELECT tag_id FROM posts_tags WHERE post_id=? ORDER BY tag_id", $this->id);
while ($row = $db->fetch($q)) {
$ids[] = (int)$row['tag_id'];
}
return $ids;
}
function setTagIds(array $new_tag_ids) {
$cur_tag_ids = $this->getTagIds();
$add_tag_ids = array_diff($new_tag_ids, $cur_tag_ids);
$rm_tag_ids = array_diff($cur_tag_ids, $new_tag_ids);
$db = DB();
if (!empty($add_tag_ids)) {
$rows = [];
foreach ($add_tag_ids as $id)
$rows[] = ['post_id' => $this->id, 'tag_id' => $id];
$db->multipleInsert('posts_tags', $rows);
}
if (!empty($rm_tag_ids))
$db->query("DELETE FROM posts_tags WHERE post_id=? AND tag_id IN(".implode(',', $rm_tag_ids).")", $this->id);
$upd_tag_ids = array_merge($new_tag_ids, $rm_tag_ids);
$upd_tag_ids = array_unique($upd_tag_ids);
foreach ($upd_tag_ids as $id)
tags::recountTagPosts($id);
} }
/** /**
@ -153,7 +183,7 @@ class Post extends model {
* @return int * @return int
* @throws Exception * @throws Exception
*/ */
function updateImagePreviews(bool $update = false): int { public function updateImagePreviews(bool $update = false): int {
$images = []; $images = [];
if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches)) if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches))
return 0; return 0;
@ -204,80 +234,53 @@ class posts {
return (int)$db->result($db->query($sql)); return (int)$db->result($db->query($sql));
} }
static function getCountByTagId(int $tag_id, bool $include_hidden = false): int {
$db = DB();
if ($include_hidden) {
$sql = "SELECT COUNT(*) FROM posts_tags WHERE tag_id=?";
} else {
$sql = "SELECT COUNT(*) FROM posts_tags
LEFT JOIN posts ON posts.id=posts_tags.post_id
WHERE posts_tags.tag_id=? AND posts.visible=1";
}
return (int)$db->result($db->query($sql, $tag_id));
}
/** /**
* @return Post[] * @return Post[]
*/ */
static function getList(int $offset = 0, int $count = -1, bool $include_hidden = false): array { static function getList(int $offset = 0,
int $count = -1,
bool $include_hidden = false,
?PostLanguage $filter_by_lang = null
): array {
$db = DB(); $db = DB();
$sql = "SELECT * FROM posts"; $sql = "SELECT * FROM posts";
if (!$include_hidden) if (!$include_hidden)
$sql .= " WHERE visible=1"; $sql .= " WHERE visible=1";
$sql .= " ORDER BY ts DESC"; $sql .= " ORDER BY `date` DESC";
if ($offset != 0 && $count != -1) if ($offset != 0 && $count != -1)
$sql .= "LIMIT $offset, $count"; $sql .= "LIMIT $offset, $count";
$q = $db->query($sql); $q = $db->query($sql);
return array_map('Post::create_instance', $db->fetchAll($q)); $posts = [];
while ($row = $db->fetch($q)) {
$posts[$row['id']] = $row;
} }
/** if (!empty($posts)) {
* @return Post[] foreach ($posts as &$post)
*/ $post = new Post($post);
static function getPostsByTagId(int $tag_id, bool $include_hidden = false): array { $q = $db->query("SELECT * FROM posts_texts WHERE post_id IN (".implode(',', array_keys($posts)).")");
$db = DB(); while ($row = $db->fetch($q)) {
$sql = "SELECT posts.* FROM posts_tags $posts[$row['post_id']]->registerText(new PostText($row));
LEFT JOIN posts ON posts.id=posts_tags.post_id }
WHERE posts_tags.tag_id=?";
if (!$include_hidden)
$sql .= " AND posts.visible=1";
$sql .= " ORDER BY posts.ts DESC";
$q = $db->query($sql, $tag_id);
return array_map('Post::create_instance', $db->fetchAll($q));
} }
static function add(array $data = []): int|bool { if ($filter_by_lang !== null)
$posts = array_filter($posts, fn(Post $post) => $post->hasLang($filter_by_lang));
return array_values($posts);
}
static function add(array $data = []): ?Post {
$db = DB(); $db = DB();
$html = \markup::markdownToHtml($data['md']);
$text = \markup::htmlToText($html);
$data += [
'ts' => time(),
'html' => $html,
'text' => $text,
];
if (!$db->insert('posts', $data)) if (!$db->insert('posts', $data))
return false; return null;
return self::get($db->insertId());
$id = $db->insertId();
$post = self::get($id);
$post->updateImagePreviews();
return $id;
} }
static function delete(Post $post): void { static function delete(Post $post): void {
$tags = $post->getTags();
$db = DB(); $db = DB();
$db->query("DELETE FROM posts WHERE id=?", $post->id); $db->query("DELETE FROM posts WHERE id=?", $post->id);
$db->query("DELETE FROM posts_tags WHERE post_id=?", $post->id); $db->query("DELETE FROM posts_texts WHERE post_id=?", $post->id);
foreach ($tags as $tag)
tags::recountTagPosts($tag->id);
} }
static function get(int $id): ?Post { static function get(int $id): ?Post {
@ -286,6 +289,12 @@ class posts {
return $db->numRows($q) ? new Post($db->fetch($q)) : null; return $db->numRows($q) ? new Post($db->fetch($q)) : null;
} }
static function getText(int $text_id): ?PostText {
$db = DB();
$q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id);
return $db->numRows($q) ? new PostText($db->fetch($q)) : null;
}
static function getByName(string $short_name): ?Post { static function getByName(string $short_name): ?Post {
$db = DB(); $db = DB();
$q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name); $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name);

View File

@ -1,87 +0,0 @@
<?php
class Tag extends model implements Stringable {
const DB_TABLE = 'tags';
public int $id;
public string $tag;
public int $postsCount;
public int $visiblePostsCount;
function getUrl(): string {
return '/'.$this->tag.'/';
}
function getPostsCount(bool $is_admin): int {
return $is_admin ? $this->postsCount : $this->visiblePostsCount;
}
function __toString(): string {
return $this->tag;
}
}
class tags {
static function getAll(bool $include_hidden = false): array {
$db = DB();
$field = $include_hidden ? 'posts_count' : 'visible_posts_count';
$q = $db->query("SELECT * FROM tags WHERE $field > 0 ORDER BY $field DESC, tag");
return array_map('Tag::create_instance', $db->fetchAll($q));
}
static function get(string $tag): ?Tag {
$db = DB();
$q = $db->query("SELECT * FROM tags WHERE tag=?", $tag);
return $db->numRows($q) ? new Tag($db->fetch($q)) : null;
}
static function recountTagPosts(int $tag_id): void {
$db = DB();
$count = $db->result($db->query("SELECT COUNT(*) FROM posts_tags WHERE tag_id=?", $tag_id));
$vis_count = $db->result($db->query("SELECT COUNT(*) FROM posts_tags
LEFT JOIN posts ON posts.id=posts_tags.post_id
WHERE posts_tags.tag_id=? AND posts.visible=1", $tag_id));
$db->query("UPDATE tags SET posts_count=?, visible_posts_count=? WHERE id=?",
$count, $vis_count, $tag_id);
}
static function splitString(string $tags): array {
$tags = trim($tags);
if ($tags == '')
return [];
$tags = preg_split('/,\s+/', $tags);
$tags = array_filter($tags, static function($tag) { return trim($tag) != ''; });
$tags = array_map('trim', $tags);
$tags = array_map('mb_strtolower', $tags);
return $tags;
}
static function getTags(array $tags): array {
$found_tags = [];
$map = [];
$db = DB();
$q = $db->query("SELECT id, tag FROM tags
WHERE tag IN ('".implode("','", array_map(fn($tag) => $db->escape($tag), $tags))."')");
while ($row = $db->fetch($q)) {
$found_tags[] = $row['tag'];
$map[$row['tag']] = (int)$row['id'];
}
$notfound_tags = array_diff($tags, $found_tags);
if (!empty($notfound_tags)) {
foreach ($notfound_tags as $tag) {
$db->insert('tags', ['tag' => $tag]);
$map[$tag] = $db->insertId();
}
}
return $map;
}
}

View File

@ -1,5 +1,7 @@
<?php <?php
require_once 'lib/themes.php';
const UPLOADS_ALLOWED_EXTENSIONS = [ const UPLOADS_ALLOWED_EXTENSIONS = [
'jpg', 'png', 'git', 'mp4', 'mp3', 'ogg', 'diff', 'txt', 'gz', 'tar', 'jpg', 'png', 'git', 'mp4', 'mp3', 'ogg', 'diff', 'txt', 'gz', 'tar',
'icc', 'icm', 'patch', 'zip', 'brd', 'pdf', 'lua', 'xpi', 'rar', '7z', 'icc', 'icm', 'patch', 'zip', 'brd', 'pdf', 'lua', 'xpi', 'rar', '7z',
@ -255,7 +257,7 @@ class Upload extends model {
$orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name; $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name;
$updated = false; $updated = false;
foreach (themes::getThemes() as $theme) { foreach (getThemes() as $theme) {
if (!$may_have_alpha && $theme == 'dark') if (!$may_have_alpha && $theme == 'dark')
continue; continue;
@ -273,7 +275,7 @@ class Upload extends model {
} }
$img = imageopen($orig); $img = imageopen($orig);
imageresize($img, $dw, $dh, themes::getThemeAlphaColorAsRGB($theme)); imageresize($img, $dw, $dh, getThemeAlphaColorAsRGB($theme));
imagejpeg($img, $dst, $mult == 1 ? 93 : 67); imagejpeg($img, $dst, $mult == 1 ? 93 : 67);
imagedestroy($img); imagedestroy($img);

View File

@ -6,25 +6,22 @@ return (function() {
'/' => 'index', '/' => 'index',
'{about,contacts}/' => 'about', '{about,contacts}/' => 'about',
'feed.rss' => 'rss', 'feed.rss' => 'rss',
'([a-z0-9-]+)/' => 'auto name=$(1)', '([a-z0-9-]+)/' => 'page name=$(1)',
'articles/' => 'articles',
'articles/([a-z0-9-]+)/' => 'post name=$(1)',
], ],
'Admin' => [ 'Admin' => [
'admin/' => 'index', 'admin/' => 'index',
'admin/{login,logout,log}/' => '${1}', 'admin/{login,logout,log}/' => '${1}',
'([a-z0-9-]+)/{delete,edit}/' => 'auto_${1} short_name=$(1)', '([a-z0-9-]+)/{delete,edit}/' => 'page_${1} short_name=$(1)',
'([a-z0-9-]+)/create/' => 'page_add short_name=$(1)', '([a-z0-9-]+)/create/' => 'page_add short_name=$(1)',
'articles/write/' => 'post_add',
'articles/([a-z0-9-]+)/{delete,edit}/' => 'post_${1} short_name=$(1)',
'admin/markdown-preview.ajax' => 'ajax_md_preview', 'admin/markdown-preview.ajax' => 'ajax_md_preview',
'admin/uploads/' => 'uploads', 'admin/uploads/' => 'uploads',
'admin/uploads/{edit_note,delete}/(\d+)/' => 'upload_${1} id=$(1)' 'admin/uploads/{edit_note,delete}/(\d+)/' => 'upload_${1} id=$(1)'
] ]
]; ];
if (is_dev()) {
$routes['Main'] += [
'articles/' => 'articles'
];
$routes['Admin'] += [
'articles/write/' => 'post_add',
];
}
return $routes; return $routes;
})(); })();

View File

@ -11,16 +11,25 @@ function login($ctx) {
$html = <<<HTML $html = <<<HTML
<form action="/admin/login/" method="post" class="form-layout-h"> <form action="/admin/login/" method="post" class="form-layout-h">
<input type="hidden" name="token" value="{$ctx->csrf('adminlogin')}" /> <input type="hidden" name="token" value="{$ctx->csrf('adminlogin')}" />
<div class="form-field-wrap clearfix"> <div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('as_form_password')}:</div> <div class="form-field-label">{$ctx->lang('admin_login')}:</div>
<div class="form-field"> <div class="form-field">
<input id="as_password" class="form-field-input" type="password" name="password" size="50" /> <input class="form-field-input" type="text" name="login" size="50" />
</div> </div>
</div> </div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('admin_password')}:</div>
<div class="form-field">
<input class="form-field-input" type="password" name="password" size="50" />
</div>
</div>
<div class="form-field-wrap clearfix"> <div class="form-field-wrap clearfix">
<div class="form-field-label"></div> <div class="form-field-label"></div>
<div class="form-field"> <div class="form-field">
<button type="submit">{$ctx->lang('submit')}</button> <button type="submit">{$ctx->lang('sign_in')}</button>
</div> </div>
</div> </div>
</form> </form>
@ -37,9 +46,10 @@ return [$html, $js];
// index page // index page
// ---------- // ----------
function index($ctx) { function index($ctx, $admin_login) {
return <<<HTML return <<<HTML
<div class="admin-page"> <div class="admin-page">
Authorized as <b>{$admin_login}</b><br>
<!--<a href="/admin/log/">Log</a><br/>--> <!--<a href="/admin/log/">Log</a><br/>-->
<a href="/admin/uploads/">Uploads</a><br> <a href="/admin/uploads/">Uploads</a><br>
<a href="/admin/logout/?token={$ctx->csrf('logout')}">Sign out</a> <a href="/admin/logout/?token={$ctx->csrf('logout')}">Sign out</a>
@ -55,6 +65,11 @@ function uploads($ctx, $uploads, $error) {
return <<<HTML return <<<HTML
{$ctx->if_true($error, $ctx->formError, $error)} {$ctx->if_true($error, $ctx->formError, $error)}
{$ctx->bc([
['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
['text' => $ctx->lang('blog_uploads')],
])}
<div class="blog-upload-form"> <div class="blog-upload-form">
<form action="/admin/uploads/" method="post" enctype="multipart/form-data" class="form-layout-h"> <form action="/admin/uploads/" method="post" enctype="multipart/form-data" class="form-layout-h">
<input type="hidden" name="token" value="{$ctx->csrf('addupl')}" /> <input type="hidden" name="token" value="{$ctx->csrf('addupl')}" />
@ -121,29 +136,41 @@ return <<<HTML
HTML; HTML;
} }
function postForm($ctx, function postForm(\SkinContext $ctx,
string|Stringable $title, string|Stringable $title,
string|Stringable $text, string|Stringable $text,
string|Stringable $short_name, string|Stringable $short_name,
string|Stringable $tags = '', array $langs,
array $js_texts,
string|Stringable|null $date = null,
bool $is_edit = false, bool $is_edit = false,
$error_code = null,
?bool $saved = null, ?bool $saved = null,
?bool $visible = null, ?bool $visible = null,
?bool $toc = null, ?bool $toc = null,
string|Stringable|null $post_url = null, string|Stringable|null $post_url = null,
?int $post_id = null): array { ?int $post_id = null,
?string $lang = null): array {
$form_url = !$is_edit ? '/articles/write/' : $post_url.'edit/'; $form_url = !$is_edit ? '/articles/write/' : $post_url.'edit/';
// breadcrumbs
$bc_tree = [
['url' => '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')]
];
if ($is_edit) {
$bc_tree[] = ['url' => $post_url.'?lang='.$lang, 'text' => $ctx->lang('blog_view_post')];
} else {
$bc_tree[] = ['text' => $ctx->lang('blog_new_post')];
}
$html = <<<HTML $html = <<<HTML
{$ctx->if_true($error_code, fn() => '<div class="form-error">'.$ctx->lang('err_blog_'.$error_code).'</div>')} <div class="form-error" id="form-error" style="display:none"></div>
{$ctx->if_true($saved, fn() => '<div class="form-success">'.$ctx->lang('info_saved').'</div>')} {$ctx->if_true($saved, fn() => '<div class="form-success">'.$ctx->lang('info_saved').'</div>')}
{$ctx->bc($bc_tree, 'padding-bottom: 20px')}
<table cellpadding="0" cellspacing="0" class="blog-write-table"> <table cellpadding="0" cellspacing="0" class="blog-write-table">
<tr> <tr>
<td id="form_first_cell"> <td id="form_first_cell">
<form class="blog-write-form form-layout-v" name="postForm" action="{$form_url}" method="post" enctype="multipart/form-data"> <form class="blog-write-form form-layout-v" name="postForm" action="{$form_url}" method="post">
<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-wrap clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_title')}</div> <div class="form-field-label">{$ctx->lang('blog_write_form_title')}</div>
<div class="form-field"> <div class="form-field">
@ -164,24 +191,34 @@ $html = <<<HTML
<tr> <tr>
<td> <td>
<div class="clearfix"> <div class="clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_tags')}</div> <div class="form-field-label">{$ctx->lang('blog_post_options')}</div>
<div class="form-field"> <div class="form-field">
<input class="form-field-input" type="text" name="tags" value="{$tags}" /> <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>
</div> </div>
</td> </td>
<td> <td>
<div class="clearfix"> <div class="clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_options')}</div> <div class="form-field-label">{$ctx->lang('blog_text_options')}</div>
<div class="form-field"> <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>
<label for="toc_cb"><input type="checkbox" id="toc_cb" name="toc"{$ctx->if_true($toc, ' checked="checked"')}> {$ctx->lang('blog_write_form_toc')}</label> <label for="toc_cb"><input type="checkbox" id="toc_cb" name="toc"{$ctx->if_true($toc, ' checked="checked"')}> {$ctx->lang('blog_write_form_toc')}</label>
&nbsp;<select name="lang">
{$ctx->for_each($langs, fn($l) => '<option value="'.$l->value.'"'.($l->value == $lang ? ' selected="selected"' : '').'>'.$l->value.'</option>')}
</select>
</div>
</div>
</td>
<td>
<div class="clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_date')}</div>
<div class="form-field">
<input type="date" name="date"{$ctx->if_true($date, ' value="'.$date.'"')}>
</div> </div>
</div> </div>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td colspan="2">
<div class="clearfix"> <div class="clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_short_name')}</div> <div class="form-field-label">{$ctx->lang('blog_write_form_short_name')}</div>
<div class="form-field"> <div class="form-field">
@ -190,11 +227,9 @@ $html = <<<HTML
</div> </div>
</td> </td>
<td> <td>
<div class="clearfix">
<div class="form-field-label">&nbsp;</div> <div class="form-field-label">&nbsp;</div>
<div class="form-field"> <div class="form-field">
<button type="submit" name="submit_btn"><b>{$ctx->lang('blog_write_form_submit_btn')}</b></button> <button type="submit" name="submit_btn"><b>{$ctx->lang($is_edit ? 'save' : 'blog_write_form_submit_btn')}</b></button>
</div>
</div> </div>
</td> </td>
</tr> </tr>
@ -210,10 +245,21 @@ $html = <<<HTML
</table> </table>
HTML; HTML;
$js_params = json_encode($is_edit $js_params = [
? ['edit' => true, 'id' => $post_id] 'langs' => array_map(fn($lang) => $lang->value, $langs),
: (object)[]); 'token' => $is_edit ? csrf_get('editpost'.$post_id) : csrf_get('post_add')
$js = "AdminWriteForm.init({$js_params});"; ];
if ($is_edit)
$js_params += [
'edit' => true,
'id' => $post_id,
'texts' => $js_texts
];
$js_params = jsonEncode($js_params);
$js = <<<JAVASCRIPT
cur.form = new AdminWriteEditForm({$js_params});
JAVASCRIPT;
return [$html, $js]; return [$html, $js];
} }

View File

@ -34,11 +34,9 @@ return <<<HTML
</head> </head>
<body{$ctx->if_true($body_class, ' class="'.implode(' ', $body_class).'"')}> <body{$ctx->if_true($body_class, ' class="'.implode(' ', $body_class).'"')}>
<div class="page-content base-width"> <div class="page-content base-width">
{$ctx->renderHeader($theme)} {$ctx->renderHeader($theme, $opts['head_section'], $opts['articles_lang'], $opts['is_index'])}
<div class="page-content-inner">{$unsafe_body}</div> <div class="page-content-inner">{$unsafe_body}</div>
<div class="footer"> {$ctx->if_not($opts['full_width'], fn() => $ctx->renderFooter($admin_email))}
Email: <a href="mailto:{$admin_email}">{$admin_email}</a>
</div>
</div> </div>
{$ctx->renderScript($js, $unsafe_lang)} {$ctx->renderScript($js, $unsafe_lang)}
@ -187,28 +185,36 @@ function getStaticVersion(string $name): string {
} }
function renderHeader(SkinContext $ctx, string $theme): string { function renderHeader(SkinContext $ctx,
string $theme,
?string $section,
?string $articles_lang,
bool $show_subtitle): string {
$items = []; $items = [];
if (is_admin()) if (is_admin())
$items[] = ['url' => '/articles/', 'label' => 'articles']; $items[] = ['url' => '/articles/'.($articles_lang ? '?lang='.$articles_lang : ''), 'label' => 'articles', 'selected' => $section === 'articles'];
array_push($items, array_push($items,
['url' => 'https://files.4in1.ws', 'label' => 'materials'], ['url' => 'https://files.4in1.ws', 'label' => 'files', 'selected' => $section === 'files'],
['url' => '/info/', 'label' => 'about'] ['url' => '/info/', 'label' => 'about', 'selected' => $section === 'about']
); );
if (is_admin()) if (is_admin())
$items[] = ['url' => '/admin/', 'label' => $ctx->renderSettingsIcon(), 'type' => 'settings']; $items[] = ['url' => '/admin/', 'label' => $ctx->renderSettingsIcon(), 'type' => 'settings', 'selected' => $section === 'admin'];
$items[] = ['url' => 'javascript:void(0)', 'label' => $ctx->renderMoonIcons(), 'type' => 'theme-switcher', 'type_opts' => $theme]; $items[] = ['url' => 'javascript:void(0)', 'label' => $ctx->renderMoonIcons(), 'type' => 'theme-switcher', 'type_opts' => $theme];
// here, items are rendered using for_each, so that there are no gaps (whitespaces) between tags // here, items are rendered using for_each, so that there are no gaps (whitespaces) between tags
$class = 'head';
if (!$show_subtitle)
$class .= ' no-subtitle';
return <<<HTML return <<<HTML
<div class="head"> <div class="{$class}">
<div class="head-inner"> <div class="head-inner">
<div class="head-logo-wrap"> <div class="head-logo-wrap">
<div class="head-logo"> <div class="head-logo">
<a href="/"> <a href="/">
<div class="head-logo-title">4in1 <span class="head-logo-title-author">by idb</span></div> <div class="head-logo-title">4in1 <span class="head-logo-title-author">by idb</span></div>
<div class="head-logo-subtitle">Mask of Shakespeare, Mysteries of Bacon,<br>Book by Cartier, Secrets of the NSA</div> {$ctx->if_true($show_subtitle, '<div class="head-logo-subtitle">Mask of Shakespeare, Mysteries of Bacon, <br>Book by Cartier, Secrets of the NSA</div>')}
</a> </a>
</div> </div>
</div> </div>
@ -218,6 +224,7 @@ return <<<HTML
$item['label'], $item['label'],
$item['type'] ?? false, $item['type'] ?? false,
$item['type_opts'] ?? null, $item['type_opts'] ?? null,
$item['selected'] ?? false
))} ))}
</div> </div>
</div> </div>
@ -225,12 +232,12 @@ return <<<HTML
HTML; HTML;
} }
function renderHeaderItem(SkinContext $ctx, function renderHeaderItem(SkinContext $ctx,
string $url, string $url,
?Stringable $unsafe_label, ?Stringable $unsafe_label,
?string $type, ?string $type,
?string $type_opts): string { ?string $type_opts,
bool $selected): string {
$args = ''; $args = '';
$class = ''; $class = '';
switch ($type) { switch ($type) {
@ -242,6 +249,9 @@ switch ($type) {
$class = ' is-settings'; $class = ' is-settings';
break; break;
} }
if ($selected)
$class .= ' is-selected';
return <<<HTML return <<<HTML
<a class="head-item{$class}" href="{$url}"{$args}>{$unsafe_label}</a> <a class="head-item{$class}" href="{$url}"{$args}>{$unsafe_label}</a>
HTML; HTML;
@ -272,3 +282,12 @@ return <<<SVG
SVG; SVG;
} }
function renderFooter($ctx, $admin_email): string {
return <<<HTML
<div class="footer">
Email: <a href="mailto:{$admin_email}">{$admin_email}</a>
</div>
HTML;
}

View File

@ -2,8 +2,12 @@
namespace skin\main; namespace skin\main;
// index page // articles page
// ---------- // -------------
use Post;
use PostLanguage;
use function is_admin;
function index($ctx) { function index($ctx) {
return <<<HTML return <<<HTML
@ -39,52 +43,48 @@ HTML;
} }
//function articles($ctx): string { function articles($ctx, array $posts, PostLanguage $selected_lang): string {
//return <<<HTML if (empty($posts))
//<div class="empty"> return $ctx->articlesEmpty($selected_lang);
// {$ctx->lang('blog_no')}
// {$ctx->if_admin('<a href="/write/">'.$ctx->lang('write').'</a>')}
//</div>
//HTML;
//}
function articles($ctx, array $posts): string {
return <<<HTML return <<<HTML
<div class="blog-list"> <div class="blog-list">
<div class="blog-list-title"> {$ctx->articlesPostsTable($posts, $selected_lang)}
<!--all posts-->
{$ctx->if_admin(
'<span>
<a href="/articles/write/">new</a>
</span>'
)}
</div>
{$ctx->indexPostsTable($posts)}
</div> </div>
HTML; HTML;
} }
function indexPostsTable($ctx, array $posts): string { function articlesEmpty($ctx, PostLanguage $selected_lang) {
return <<<HTML
<div class="empty">
{$ctx->lang('blog_no')}
{$ctx->articlesRightLinks($selected_lang->value)}
</div>
HTML;
}
function articlesPostsTable($ctx, array $posts, PostLanguage $selected_lang): string {
$ctx->year = 3000; $ctx->year = 3000;
return <<<HTML return <<<HTML
<div class="blog-list-table-wrap"> <div class="blog-list-table-wrap">
<table class="blog-list-table" width="100%" cellspacing="0" cellpadding="0"> <table class="blog-list-table" width="100%" cellspacing="0" cellpadding="0">
{$ctx->for_each($posts, fn($post) => $ctx->indexPostRow( {$ctx->for_each($posts, fn($post, $i) => $ctx->articlesPostRow($i, $post, $selected_lang))}
$post->getYear(),
$post->visible,
$post->getDate(),
$post->getUrl(),
$post->title
))}
</table> </table>
</div> </div>
HTML; HTML;
} }
function indexPostRow($ctx, $year, $is_visible, $date, $url, $title): string { function articlesPostRow($ctx, int $index, Post $post, PostLanguage $selected_lang): string {
$year = $post->getYear();
$date = $post->getDate();
$url = $post->getUrl($selected_lang);
$pt = $post->getText($selected_lang);
$title = $pt->title;
return <<<HTML return <<<HTML
{$ctx->if_true($ctx->year > $year, $ctx->indexYearLine, $year)} {$ctx->if_true($ctx->year > $year, $ctx->articlesIndexYearLine, $year, $index === 0, $selected_lang->value)}
<tr class="blog-item-row{$ctx->if_not($is_visible, ' ishidden')}"> <tr class="blog-item-row{$ctx->if_not($post->visible, ' ishidden')}">
<td class="blog-item-date-cell"> <td class="blog-item-date-cell">
<span class="blog-item-date">{$date}</span> <span class="blog-item-date">{$date}</span>
</td> </td>
@ -95,17 +95,43 @@ return <<<HTML
HTML; HTML;
} }
function indexYearLine($ctx, $year): string { function articlesIndexYearLine($ctx, $year, $show_right_links, string $selected_lang): string {
$ctx->year = $year; $ctx->year = $year;
return <<<HTML return <<<HTML
<tr class="blog-item-row-year"> <tr class="blog-item-row-year">
<td class="blog-item-date-cell"><span>{$year}</span></td> <td class="blog-item-date-cell"><span>{$year}</span></td>
<td></td> <td>
{$ctx->if_true($show_right_links, $ctx->articlesRightLinks($selected_lang))}
</td>
</tr> </tr>
HTML; HTML;
} }
function articlesRightLinks($ctx, string $selected_lang) {
$links = [
['url' => $selected_lang != 'en' ? '/articles/' : null, 'label' => 'en'],
['url' => $selected_lang != 'ru' ? '/articles/?lang=ru' : null, 'label' => 'ru'],
];
if (is_admin()) {
$links[] = ['url' => '/articles/write/?lang='.$selected_lang, 'label' => 'write'];
}
return <<<HTML
<div class="blog-item-right-links">
{$ctx->for_each($links, fn($link, $index) => $ctx->articlesRightLink($link['url'], $link['label'], $index))}
</div>
HTML;
}
function articlesRightLink($ctx, $url, string $label, int $index) {
$buf = '';
if ($index > 0)
$buf .= ' <span class="blog-links-separator">|</span> ';
$buf .= !$url ? $label : '<a href="'.$url.'">'.$label.'</a>';
return $buf;
}
// any page // any page
// -------- // --------
@ -113,6 +139,9 @@ HTML;
function page($ctx, $page_url, $short_name, $unsafe_html) { function page($ctx, $page_url, $short_name, $unsafe_html) {
$html = <<<HTML $html = <<<HTML
<div class="page"> <div class="page">
<!--<div class="blog-post-title-nav">
<a href="/">{$ctx->lang('index')}</a> <span>&rsaquo;</span>
</div>-->
{$ctx->if_admin($ctx->pageAdminLinks, $page_url, $short_name)} {$ctx->if_admin($ctx->pageAdminLinks, $page_url, $short_name)}
<div class="blog-post-text">{$unsafe_html}</div> <div class="blog-post-text">{$unsafe_html}</div>
</div> </div>
@ -135,20 +164,21 @@ HTML;
// post page // post page
// --------- // ---------
function post($ctx, $id, $title, $unsafe_html, $unsafe_toc_html, $date, $visible, $url, $tags, $email, $urlencoded_reply_subject) { function post($ctx, $id, $title, $unsafe_html, $unsafe_toc_html, $date, $visible, $url, string $lang, $other_langs) {
$html = <<<HTML $html = <<<HTML
<div class="blog-post-wrap2"> <div class="blog-post-wrap2">
<div class="blog-post-wrap1"> <div class="blog-post-wrap1">
<div class="blog-post"> <div class="blog-post">
{$ctx->bc([
['url' => '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')]
])}
<div class="blog-post-title"> <div class="blog-post-title">
<h1>{$title}</h1> <h1>{$title}</h1>
<div class="blog-post-date"> <div class="blog-post-date">
{$ctx->if_not($visible, '<b>'.$ctx->lang('blog_post_hidden').'</b> |')} {$ctx->if_not($visible, $ctx->lang('blog_post_hidden').' |')}
{$date} {$date}
{$ctx->if_admin($ctx->postAdminLinks, $url, $id)} {$ctx->if_true($other_langs, $ctx->postOtherLangs($url, $other_langs))}
</div> {$ctx->if_admin($ctx->postAdminLinks, $url, $id, $lang)}
<div class="blog-post-tags clearfix">
{$ctx->for_each($tags, fn($tag) => $ctx->postTag($tag->getUrl(), $tag->tag))}
</div> </div>
</div> </div>
<div class="blog-post-text">{$unsafe_html}</div> <div class="blog-post-text">{$unsafe_html}</div>
@ -161,6 +191,14 @@ HTML;
return [$html, markdownThemeChangeListener()]; return [$html, markdownThemeChangeListener()];
} }
function postOtherLangs($ctx, $url, $other_langs) {
$buf = '';
foreach ($other_langs as $lang) {
$buf .= ' | <a href="'.$url.'?lang='.$lang.'">'.$ctx->lang('blog_read_in_'.$lang).'</a>';
}
return $buf;
}
function postToc($ctx, $unsafe_toc_html) { function postToc($ctx, $unsafe_toc_html) {
return <<<HTML return <<<HTML
<div class="blog-post-toc"> <div class="blog-post-toc">
@ -175,16 +213,10 @@ HTML;
} }
function postAdminLinks($ctx, $url, $id) { function postAdminLinks($ctx, $url, $id, string $lang) {
return <<<HTML return <<<HTML
<a href="{$url}edit/">{$ctx->lang('edit')}</a> | <a href="{$url}edit/?lang={$lang}">{$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> | <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; HTML;
} }
@ -213,23 +245,3 @@ ThemeSwitcher.addOnChangeListener(function(isDark) {
}); });
JS; JS;
} }
// 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;
}

View File

@ -1,36 +1,26 @@
# common # common
4in1: '4in1' 4in1: '4in1'
site_title: '4in1. Mask of Shakespeare, mysteries of Bacon, book by Cartier, secrets of the NSA' site_title: '4in1. Mask of Shakespeare, mysteries of Bacon, book by Cartier, secrets of the NSA'
index_title: '4in1 | Index' index: 'Index'
posts: 'posts' posts: 'Posts'
all_posts: 'all posts' all_posts: 'All posts'
blog: 'blog' articles: 'Articles'
contacts: 'contacts'
email: 'email'
projects: 'projects'
unknown_error: 'Unknown error' unknown_error: 'Unknown error'
error: 'Error' error: 'Error'
write: 'Write' write: 'Write'
submit: 'submit' submit: 'submit'
sign_in: "Sign in"
edit: 'edit' edit: 'edit'
delete: 'delete' delete: 'delete'
save: "Save"
info_saved: 'Information saved.' info_saved: 'Information saved.'
toc: 'Table of Contents' toc: 'Table of Contents'
# theme switcher
theme_auto: 'auto'
theme_dark: 'dark'
theme_light: 'light'
# contacts
contacts_email: 'email'
contacts_pgp: 'OpenPGP public key'
contacts_tg: 'telegram'
contacts_freenode: 'freenode'
# blog # blog
blog_tags: 'tags' blog_new_post: "New post"
blog_view_post: "View post"
#blog_editing: "Editing..."
blog_latest: 'Latest posts' blog_latest: 'Latest posts'
blog_no: 'No posts yet.' blog_no: 'No posts yet.'
blog_view_all: 'View all' blog_view_all: 'View all'
@ -38,23 +28,24 @@ blog_write: 'Write a post'
blog_post_delete_confirmation: 'Are you sure you want to delete this post?' blog_post_delete_confirmation: 'Are you sure you want to delete this post?'
blog_post_edit_title: 'Edit post "%s"' blog_post_edit_title: 'Edit post "%s"'
blog_post_hidden: 'Hidden' blog_post_hidden: 'Hidden'
blog_tag_title: 'Posts tagged with "%s"'
blog_tag_not_found: 'No posts found.'
blog_comments_text: 'If you have any comments, <a href="mailto:%s?subject=%s">contact me by email</a>.' blog_comments_text: 'If you have any comments, <a href="mailto:%s?subject=%s">contact me by email</a>.'
blog_read_in_ru: "Читать на русском"
blog_read_in_en: "Read in English"
blog_write_form_preview_btn: 'Preview' blog_write_form_preview_btn: 'Preview'
blog_write_form_submit_btn: 'Submit' blog_write_form_submit_btn: 'Submit'
blog_write_form_title: 'Title' blog_write_form_title: 'Title'
blog_write_form_text: 'Text' blog_write_form_text: 'Text'
blog_write_form_date: 'Date'
blog_write_form_preview: 'Preview' blog_write_form_preview: 'Preview'
blog_write_form_enter_text: 'Enter text..' blog_write_form_enter_text: 'Enter text..'
blog_write_form_enter_title: 'Enter title..' blog_write_form_enter_title: 'Enter title..'
blog_write_form_tags: 'Tags'
blog_write_form_visible: 'Visible' blog_write_form_visible: 'Visible'
blog_write_form_toc: 'ToC' blog_write_form_toc: 'ToC'
blog_write_form_short_name: 'Short name' blog_write_form_short_name: 'Short name'
blog_write_form_toggle_wrap: 'Toggle wrap' blog_write_form_toggle_wrap: 'Toggle wrap'
blog_write_form_options: 'Options' blog_post_options: "Post options"
blog_text_options: "Text options"
blog_uploads: 'Uploads' blog_uploads: 'Uploads'
blog_upload: 'Upload files' blog_upload: 'Upload files'
@ -66,9 +57,8 @@ blog_upload_form_custom_name: 'Custom name'
blog_upload_form_note: 'Note' blog_upload_form_note: 'Note'
# blog (errors) # blog (errors)
err_blog_no_title: 'Title not specified' err_blog_no_text: 'Text or title is not specified'
err_blog_no_text: 'Text not specified' err_blog_no_date: 'Date is not specified'
err_blog_no_tags: 'Tags not specified'
err_blog_db_err: 'Database error' err_blog_db_err: 'Database error'
err_blog_no_short_name: 'Short name not specified' err_blog_no_short_name: 'Short name not specified'
err_blog_short_name_exists: 'This short name already exists' err_blog_short_name_exists: 'This short name already exists'
@ -92,12 +82,12 @@ pages_write_form_toggle_wrap: 'Toggle wrap'
pages_write_form_options: 'Options' pages_write_form_options: 'Options'
# pages (errors) # pages (errors)
err_pages_no_title: 'Title not specified' err_pages_no_text: 'Text or title is not specified'
err_pages_no_text: 'Text not specified'
err_pages_no_id: 'ID not specified' err_pages_no_id: 'ID not specified'
err_pages_no_short_name: 'Short name not specified' err_pages_no_short_name: 'Short name not specified'
err_pages_db_err: 'Database error' err_pages_db_err: 'Database error'
# admin-switch # admin-switch
admin_title: "Admin" admin_title: "Admin"
as_form_password: 'Password' admin_password: 'Password'
admin_login: "Login"

View File

@ -1,21 +1,89 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
namespace cli_util; require_once __DIR__.'/../init.php';
require_once 'lib/admin.php';
use lib\Config; (new cli())
use lib\Pages;
use lib\Posts;
use lib\Uploads;
use util\cli;
require_once __DIR__.'/init.php'; ->on('admin-add', function() {
list($login, $password) = _get_admin_login_password_input();
$cli = new cli(__NAMESPACE__); if (admin_exists($login))
$cli->run(); cli::die("Admin ".$login." already exists");
function admin_reset(): void { $id = admin_add($login, $password);
$pwd1 = cli::silentInput("New password: "); echo "ok: id = $id\n";
})
->on('admin-delete', function() {
$login = cli::input('Login: ');
if (!admin_exists($login))
cli::die("No such admin");
if (!admin_delete($login))
cli::die("Database error");
echo "ok\n";
})
->on('admin-set-password', function() {
list($login, $password) = _get_admin_login_password_input();
echo admin_set_password($login, $password) ? 'ok' : 'fail';
echo "\n";
})
->on('blog-erase', function() {
$db = DB();
$tables = ['posts', 'posts_texts'];
foreach ($tables as $t) {
$db->query("TRUNCATE TABLE $t");
}
})
->on('posts-html', function() {
$kw = ['include_hidden' => true];
$posts = posts::getList(0, posts::getCount(...$kw), ...$kw);
foreach ($posts as $p) {
$texts = $p->getTexts();
foreach ($texts as $t) {
$t->updateHtml();
$t->updateText();
}
}
})
->on('posts-images', function() {
$kw = ['include_hidden' => true];
$posts = posts::getList(0, posts::getCount(...$kw), ...$kw);
foreach ($posts as $p) {
$texts = $p->getTexts();
foreach ($texts as $t) {
$t->updateImagePreviews(true);
}
}
})
->on('pages-html', function() {
$pages = Pages::getAll();
foreach ($pages as $p) {
$p->updateHtml();
}
})
->on('add-files-to-uploads', function() {
$path = cli::input('Enter path: ');
if (!file_exists($path))
cli::die("file $path doesn't exists");
$name = basename($path);
$ext = extension($name);
$id = Uploads::add($path, $name, '');
echo "upload id: $id\n";
})
->run();
function _get_admin_login_password_input(): array {
$login = cli::input('Login: ');
$pwd1 = cli::silentInput("Password: ");
$pwd2 = cli::silentInput("Again: "); $pwd2 = cli::silentInput("Again: ");
if ($pwd1 != $pwd2) if ($pwd1 != $pwd2)
@ -24,60 +92,8 @@ function admin_reset(): void {
if (trim($pwd1) == '') if (trim($pwd1) == '')
cli::die("Password can not be empty"); cli::die("Password can not be empty");
if (!Config::set('admin_pwd', salt_password($pwd1))) if (strlen($login) > ADMIN_LOGIN_MAX_LENGTH)
cli::die("Database error"); cli::die("Login is longer than max length (".ADMIN_LOGIN_MAX_LENGTH.")");
}
function admin_check(): void { return [$login, $pwd1];
$pwd = Config::get('admin_pwd');
echo is_null($pwd) ? "Not set" : $pwd;
echo "\n";
}
function blog_erase(): void {
$db = getDb();
$tables = ['posts', 'posts_tags', 'tags'];
foreach ($tables as $t) {
$db->query("TRUNCATE TABLE $t");
}
}
function tags_recount(): void {
$tags = Posts::getAllTags(true);
foreach ($tags as $tag)
Posts::recountPostsWithTag($tag->id);
}
function posts_html(): void {
$kw = ['include_hidden' => true];
$posts = Posts::getPosts(0, Posts::getPostsCount(...$kw), ...$kw);
foreach ($posts as $p) {
$p->updateHtml();
$p->updateText();
}
}
function posts_images(): void {
$kw = ['include_hidden' => true];
$posts = Posts::getPosts(0, Posts::getPostsCount(...$kw), ...$kw);
foreach ($posts as $p) {
$p->updateImagePreviews(true);
}
}
function pages_html(): void {
$pages = Pages::getAll();
foreach ($pages as $p) {
$p->updateHtml();
}
}
function add_files_to_uploads(): void {
$path = cli::input('Enter path: ');
if (!file_exists($path))
cli::die("file $path doesn't exists");
$name = basename($path);
$ext = extension($name);
$id = Uploads::add($path, $name, '');
echo "upload id: $id\n";
} }