diff --git a/composer.json b/composer.json index c07e5dd..7649af7 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,7 @@ "gch1p/parsedown-highlight": "master", "gch1p/parsedown-highlight-extended": "dev-main", "erusev/parsedown": "1.8.0-beta-7", + "gigablah/sphinxphp": "2.0.*", "ext-mbstring": "*", "ext-gd": "*", "ext-mysqli": "*", diff --git a/composer.lock b/composer.lock index 9454cb5..be528c8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ca8ca355a9f6ce85170f473238a57d6b", + "content-hash": "dba6710f07144861b58159926e3c0cc7", "packages": [ { "name": "erusev/parsedown", @@ -210,6 +210,61 @@ ], "time": "2023-03-01T22:30:01+00:00" }, + { + "name": "gigablah/sphinxphp", + "version": "2.0.8", + "source": { + "type": "git", + "url": "https://github.com/gigablah/sphinxphp.git", + "reference": "6d5e97fdd33c1129ca372203d1330827c1cbc46c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gigablah/sphinxphp/zipball/6d5e97fdd33c1129ca372203d1330827c1cbc46c", + "reference": "6d5e97fdd33c1129ca372203d1330827c1cbc46c", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "3.7.*", + "satooshi/php-coveralls": "dev-master" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-0": { + "Sphinx": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0" + ], + "authors": [ + { + "name": "Andrew Aksyonoff", + "homepage": "http://sphinxsearch.com/" + } + ], + "description": "Sphinx Search PHP API", + "homepage": "http://sphinxsearch.com/", + "keywords": [ + "api", + "search", + "sphinx" + ], + "support": { + "issues": "https://github.com/gigablah/sphinxphp/issues", + "source": "https://github.com/gigablah/sphinxphp/tree/2.0.x" + }, + "time": "2013-08-22T08:05:44+00:00" + }, { "name": "scrivo/highlight.php", "version": "v9.18.1.10", @@ -301,7 +356,8 @@ "ext-mbstring": "*", "ext-gd": "*", "ext-mysqli": "*", - "ext-json": "*" + "ext-json": "*", + "ext-yaml": "*" }, "platform-dev": [], "plugin-api-version": "2.3.0" diff --git a/config.yaml.example b/config.yaml.example index 4acc8b4..04fe822 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -14,6 +14,8 @@ mysql: log: false log_stat: false +sphinx: + host: "127.0.0.1" umask: 0002 group: www-data @@ -25,6 +27,7 @@ password_salt: "456" uploads_dir: /home/user/files.example.org uploads_path: /uploads +files_domain: files.example.org # deploy config git_repo: git@github.com:example/example_org.git diff --git a/engine/mysql.php b/engine/mysql.php index cda4c7a..2c97e38 100644 --- a/engine/mysql.php +++ b/engine/mysql.php @@ -24,6 +24,8 @@ class mysql { $arg_val = $args[$i]; if (is_null($arg_val)) { $v = 'NULL'; + } elseif ($arg_val instanceof BackedEnum) { + $v = '\''.$this->escape($arg_val->value).'\''; } else { $v = '\''.$this->escape($arg_val).'\''; } @@ -230,8 +232,6 @@ class mysql_bitfield { if ($bit < 0 || $bit >= $this->size) throw new Exception('invalid bit '.$bit.', allowed range: [0..'.$this->size.')'); } - - } function DB(): mysql|null { diff --git a/engine/request.php b/engine/request.php index e0f3361..b4d160d 100644 --- a/engine/request.php +++ b/engine/request.php @@ -58,7 +58,7 @@ function http_error(HTTPCode $http_code, string $message = ''): void { $data['message'] = $message; ajax_error((object)$data, $http_code->value); } else { - $ctx = new SkinContext('\\skin\\error'); + $ctx = skin('error'); $http_message = preg_replace('/(?name); $html = $ctx->http_error($http_code->value, $http_message, $message); http_response_code($http_code->value); diff --git a/engine/router.php b/engine/router.php index ce02021..d7d7882 100644 --- a/engine/router.php +++ b/engine/router.php @@ -1,6 +1,6 @@ false, 'wide' => false, - 'dynlogo_enabled' => true, 'logo_path_map' => [], 'logo_link_map' => [], 'is_index' => false, @@ -17,13 +16,15 @@ $SkinState = new class { 'articles_lang' => null, ]; public array $static = []; + public array $svg_defs = []; }; function render($f, ...$vars): void { global $SkinState, $config; - $f = '\\skin\\'.str_replace('/', '\\', $f); - $ctx = new SkinContext(substr($f, 0, ($pos = strrpos($f, '\\')))); + add_skin_strings(['4in1']); + + $ctx = skin(substr($f, 0, ($pos = strrpos(str_replace('/', '\\', $f), '\\')))); $body = call_user_func_array([$ctx, substr($f, $pos + 1)], $vars); if (is_array($body)) list($body, $js) = $body; @@ -34,12 +35,12 @@ function render($f, ...$vars): void { if ($theme != 'auto' && !themeExists($theme)) $theme = 'auto'; - $layout_ctx = new SkinContext('\\skin\\base'); + $layout_ctx = skin('base'); $lang = []; foreach ($SkinState->lang as $key) $lang[$key] = lang($key); - $lang = !empty($lang) ? jsonEncode($lang, JSON_UNESCAPED_UNICODE) : ''; + $lang = !empty($lang) ? jsonEncode($lang) : ''; $title = $SkinState->title; if (!$SkinState->options['is_index']) @@ -56,6 +57,7 @@ function render($f, ...$vars): void { unsafe_body: $body, exec_time: exectime(), admin_email: $config['admin_email'], + svg_defs: $SkinState->svg_defs ); echo $html; exit; @@ -127,6 +129,9 @@ class SkinContext { continue; } + if ($plain_args && !isset($arguments[$key])) + break; + if (is_string($arguments[$key]) || $arguments[$key] instanceof SkinString) { if (is_string($arguments[$key])) $arguments[$key] = new SkinString($arguments[$key]); @@ -189,8 +194,10 @@ class SkinContext { return csrf_get($key); } - function bc(array $items, ?string $style = null): string { - $buf = implode(array_map(function(array $i): string { + function bc(array $items, ?string $style = null, bool $mt = false): string { + static $chevron = ''; + + $buf = implode(array_map(function(array $i) use ($chevron): string { $buf = ''; $has_url = array_key_exists('url', $i); @@ -201,13 +208,16 @@ class SkinContext { $buf .= htmlescape($i['text']); if ($has_url) - $buf .= ' '; + $buf .= ' '.$chevron.''; else $buf .= ''; return $buf; }, $items)); - return '
'.$buf.'
'; + $class = 'bc'; + if ($mt) + $class .= ' mt'; + return '
'.$buf.'
'; } protected function _if_condition($condition, $callback, ...$args) { @@ -236,6 +246,10 @@ class SkinContext { return htmlescape($this->langRaw(...$args)); } + function lang_num(...$args): string { + return htmlescape(lang_num(...$args)); + } + function langRaw(string $key, ...$args) { $val = lang($key); return empty($args) ? $val : sprintf($val, ...$args); @@ -243,6 +257,79 @@ class SkinContext { } +class SVGSkinContext extends SkinContext { + + function __construct() { + parent::__construct('\\skin\\icons'); + } + + function __call($name, array $arguments) { + global $SkinState; + + $already_defined = isset($SkinState->svg_defs[$name]); + if (!array_is_list($arguments)) { + $in_place = isset($arguments['in_place']) && $arguments['in_place'] === true; + $preload_symbol = isset($arguments['preload_symbol']) && $arguments['preload_symbol'] === true; + } else { + $in_place = false; + $preload_symbol = false; + } + + if ($already_defined && $preload_symbol) + return null; + + if ($in_place || !$already_defined) { + if (!preg_match_all('/\d+/', $name, $matches)) + throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]'); + $size = array_slice($matches[0], -2); + $width = $size[0]; + $height = $size[1] ?? $size[0]; + } + + if (!$in_place && (!$already_defined || $preload_symbol)) { + $SkinState->svg_defs[$name] = [ + 'svg' => parent::__call($name, !$preload_symbol ? $arguments : []), + 'width' => $width, + 'height' => $height + ]; + } + + if ($preload_symbol) + return null; + + if ($already_defined && !isset($width)) { + $width = $SkinState->svg_defs[$name]['width']; + $height = $SkinState->svg_defs[$name]['height']; + } + + if ($in_place) { + $content = parent::__call($name, []); + return <<{$content} +SVG; + } else { + return << +SVG; + } + } + +} + +function skin($name): SkinContext { + static $cache = []; + if (!isset($cache[$name])) { + $cache[$name] = new SkinContext('\\skin\\'.$name); + } + return $cache[$name]; +} + +function svg(): SVGSkinContext { + static $svg = null; + if ($svg === null) + $svg = new SVGSkinContext(); + return $svg; +} enum SkinStringModificationType { case RAW; diff --git a/engine/sphinx.php b/engine/sphinx.php new file mode 100644 index 0000000..da07e5d --- /dev/null +++ b/engine/sphinx.php @@ -0,0 +1,104 @@ + 1) { + $mark_count = substr_count($sql, '?'); + $positions = array(); + $last_pos = -1; + for ($i = 0; $i < $mark_count; $i++) { + $last_pos = strpos($sql, '?', $last_pos + 1); + $positions[] = $last_pos; + } + for ($i = $mark_count - 1; $i >= 0; $i--) { + $arg = func_get_arg($i + 1); + if (is_string($arg)) + $arg = _sphinx_normalize($arg); + $v = '\''.$link->real_escape_string($arg).'\''; + $sql = substr_replace($sql, $v, $positions[$i], 1); + } + } + + $q = $link->query($sql); + + $error = sphinx_error(); + if ($error) + logError(__FUNCTION__, $error); + + return $q; +} + +function sphinx_error() { + $link = _sphinxql_link(auto_create: false); + return $link?->error; +} + +function sphinx_mkquery($q, array $opts = []) { + $defaults = [ + 'any_word' => false, + 'star' => false, + 'and' => false, + 'exact_first' => false + ]; + $opts = array_merge($defaults, $opts); + $q = preg_replace('/\s+/', ' ', $q); + $q = _sphinx_normalize($q); + $q = trim($q); + $q = sphinx_client()->escapeString($q); + if ($opts['star']) { + $words = explode(' ', $q); + $words = array_map(fn($word) => $word.'*', $words); + $q = implode(' ', $words); + } + if ($opts['any_word']) { + $q = str_replace(' ', ' | ', $q); + } else if ($opts['and']) { + $q = str_replace(' ', ' AND ', $q); + } + if ($opts['exact_first']) { + $q = '"^'.$q.'$" | "'.$q.'" | ('.$q.')'; + } + return $q; +} + +function sphinx_client(): Sphinx\SphinxClient { + static $cl = null; + if (!is_null($cl)) + return $cl; + return $cl = new Sphinx\SphinxClient; +} + +function _sphinx_normalize(string $origstr): string { + $buf = preg_replace('/[Ёё]/iu', 'е', $origstr); + if (!pcre_check_error($buf, no_error: true)) { + $origstr = mb_convert_encoding($origstr, 'UTF-8', 'UTF-8'); + $buf = preg_replace('/[Ёё]/iu', 'е', $origstr); + pcre_check_error($buf); + } + if ($buf === null) { + logError(__METHOD__.': preg_replace() failed with error: '.preg_last_error().': '.preg_last_error_msg()); + $buf = $origstr; + } + return preg_replace('/[!\?]/', '', $buf); +} + +function _sphinxql_link($auto_create = true) { + global $config; + + /** @var ?mysqli $link */ + static $link = null; + if (!is_null($link) || !$auto_create) + return $link; + + $link = new mysqli(); + $link->real_connect( + $config['sphinx']['host'], + ini_get('mysql.default_user'), + ini_get('mysql.default_password'), + null, + 9306); + $link->set_charset('utf8'); + + return $link; +} diff --git a/functions.php b/functions.php index c139110..fa9cd0e 100644 --- a/functions.php +++ b/functions.php @@ -37,16 +37,6 @@ function htmlescape(string|array $s): string|array { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); } -function strtrim(string $str, int $len, bool &$trimmed): string { - if (mb_strlen($str) > $len) { - $str = mb_substr($str, 0, $len); - $trimmed = true; - } else { - $trimmed = false; - } - return $str; -} - function sizeString(int $size): string { $ks = array('B', 'KiB', 'MiB', 'GiB'); foreach ($ks as $i => $k) { @@ -295,6 +285,10 @@ function lang() { global $__lang; return call_user_func_array([$__lang, 'get'], func_get_args()); } +function lang_num() { + global $__lang; + return call_user_func_array([$__lang, 'num'], func_get_args()); +} function is_dev(): bool { global $config; return $config['is_dev']; } function is_cli(): bool { return PHP_SAPI == 'cli'; }; @@ -302,3 +296,70 @@ function is_retina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is function jsonEncode($obj): ?string { return json_encode($obj, JSON_UNESCAPED_UNICODE) ?: null; } function jsonDecode($json) { return json_decode($json, true); } + +function pcre_check_error(mixed &$result, bool $no_error = false): bool { + if ($result === null) { + if (preg_last_error() !== PREG_NO_ERROR) { + if (!$no_error) + logError('an error occurred while PCRE regex execution: '.preg_last_error_msg()); + return false; + } + } + return true; +} + +/** + * @param string $s + * @param string|string[]|null $keywords + * @return string + */ +function hl_matched(string $s, string|array|null $keywords = []): string { + if (is_null($keywords)) + return htmlescape($s); + + if (is_string($keywords)) + $keywords = preg_split('/\s+/', $keywords); + + $charset = 'utf-8'; + $s_len = mb_strlen($s, $charset); + + usort($keywords, function($a, $b) use (&$charset) { + return mb_strlen($b, $charset) - mb_strlen($a, $charset); + }); + + $all = []; + foreach ($keywords as $kw) { + $kw_len = mb_strlen($kw, $charset); + $offset = 0; + while (($pos = mb_stripos($s, $kw, $offset, $charset)) !== false) { + $offset = $pos + 1; + $all[] = [$pos, $kw_len]; + } + } + + usort($all, function($a, $b) { + return $a[0] - $b[0]; + }); + + $last_index = 0; + $buf = ''; + foreach ($all as $range) { + list($start_pos, $len) = $range; + if ($start_pos < $last_index) { + continue; + } + + if ($start_pos > $last_index) { + $buf .= htmlescape(mb_substr($s, $last_index, $start_pos - $last_index, $charset)); + } + + $buf .= ''.htmlescape(mb_substr($s, $start_pos, $len, $charset)).''; + $last_index = $start_pos + $len; + } + + if ($last_index < $s_len) { + $buf .= htmlescape(mb_substr($s, $last_index, $s_len - $last_index, $charset)); + } + + return $buf; +} \ No newline at end of file diff --git a/handler/AdminHandler.php b/handler/AdminHandler.php index ee6e315..06e27c9 100644 --- a/handler/AdminHandler.php +++ b/handler/AdminHandler.php @@ -132,7 +132,7 @@ class AdminHandler extends request_handler { ensure_xhr(); list($md, $title, $use_image_previews) = input('md, title, b:use_image_previews'); $html = markup::markdownToHtml($md, $use_image_previews); - $ctx = new SkinContext('\\skin\\admin'); + $ctx = skin('admin'); $html = $ctx->markdownPreview( unsafe_html: $html, title: $title @@ -480,7 +480,11 @@ class AdminHandler extends request_handler { admin_log(new \AdminActions\PostEdit($post->id)); ajax_ok(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]); + } + function GET_books() { + set_title('$admin_books'); + render('admin/books'); } protected static function _postEditValidateCommonData($date) { diff --git a/handler/FilesHandler.php b/handler/FilesHandler.php new file mode 100644 index 0000000..337c835 --- /dev/null +++ b/handler/FilesHandler.php @@ -0,0 +1,133 @@ + 'files']); + $collections = array_map(fn(FilesCollection $c) => new CollectionItem($c), FilesCollection::cases()); + $books = books_get(); + $misc = books_get(category: BookCategory::MISC); + render('files/index', + collections: $collections, + books: $books, + misc: $misc); + } + + function GET_folder() { + list($folder_id) = input('i:folder_id'); + $folder = books_get_folder($folder_id); + if (!$folder) + not_found(); + $files = books_get($folder_id); + set_title(lang('files').' - '.$folder->title); + render('files/folder', + folder: $folder, + files: $files); + } + + function GET_collection() { + list($collection, $folder_id, $query, $offset) = input('collection, i:folder_id, q, i:offset'); + $collection = FilesCollection::from($collection); + $files = []; + $parents = null; + + $query = trim($query); + if (!$query) + $query = null; + + add_skin_strings_re('/^files_(.*?)_collection$/'); + add_skin_strings([ + 'files_search_results_count' + ]); + + $vars = []; + $text_excerpts = null; + + switch ($collection) { + case FilesCollection::WilliamFriedman: + if ($query !== null) { + $files = wff_search($query, $offset, self::SEARCH_RESULTS_PER_PAGE); + $vars += [ + 'search_count' => $files['count'], + 'search_query' => $query + ]; + + /** @var WFFCollectionItem[] $files */ + $files = $files['items']; + + $query_words = array_map('mb_strtolower', preg_split('/\s+/', $query)); + $found = []; + $result_ids = []; + foreach ($files as $file) { + if ($file->isFolder()) + continue; + $result_ids[] = $file->id; + + foreach ([ + mb_strtolower($file->getTitle()), + strtolower($file->documentId) + ] as $haystack) { + foreach ($query_words as $qw) { + if (mb_strpos($haystack, $qw) !== false) { + $found[$file->id] = true; + continue 2; + } + } + } + } + + $found = array_map('intval', array_keys($found)); + $not_found = array_diff($result_ids, $found); + if (!empty($not_found)) + $text_excerpts = wff_get_text_excerpts($not_found, $query_words); + + if (is_xhr_request()) { + ajax_ok([ + ...$vars, + 'new_offset' => $offset + count($files), + 'html' => skin('files')->collection_files($files, $query, $text_excerpts) + ]); + } + } else { + if ($folder_id) { + $parents = wff_get_folder($folder_id, true); + if (!$parents) + not_found(); + if (count($parents) > 1) + $parents = array_reverse($parents); + } + $files = wff_get($folder_id); + } + + $title = lang('files_wff_collection'); + if ($folder_id) + $title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle()); + if ($query) + $title .= ' - '.htmlescape($query); + set_title($title); + + break; + + case FilesCollection::MercureDeFrance: + $files = mdf_get(); + set_title('$files_mdf_collection'); + break; + } + + render('files/collection', + ...$vars, + collection: $collection, + files: $files, + parents: $parents, + search_results_per_page: self::SEARCH_RESULTS_PER_PAGE, + search_min_query_length: self::SEARCH_MIN_QUERY_LENGTH, + text_excerpts: $text_excerpts); + } + +} \ No newline at end of file diff --git a/handler/MainHandler.php b/handler/MainHandler.php index 8d407b6..8c6f259 100644 --- a/handler/MainHandler.php +++ b/handler/MainHandler.php @@ -123,7 +123,7 @@ class MainHandler extends request_handler { ]; }, is_admin() ? posts::getList(0, 20, filter_by_lang: $lang) : []); - $ctx = new SkinContext('\\skin\\rss'); + $ctx = skin('rss'); $body = $ctx->atom( title: lang('site_title'), link: 'https://'.$config['domain'], diff --git a/htdocs/js/admin/00-common.js b/htdocs/js/admin/00-common.js deleted file mode 100644 index ad01c55..0000000 --- a/htdocs/js/admin/00-common.js +++ /dev/null @@ -1,2 +0,0 @@ -var LS = window.localStorage; -window.cur = {}; \ No newline at end of file diff --git a/htdocs/js/admin/10-draft.js b/htdocs/js/admin/10-draft.js index e04b54c..9a28351 100644 --- a/htdocs/js/admin/10-draft.js +++ b/htdocs/js/admin/10-draft.js @@ -1,32 +1,49 @@ -class Draft { - constructor(id, lang = 'en') { - this.id = id - this.lang = lang - } +function Draft(id, lang) { + if (!lang) + lang = 'en' + this.id = id + this.lang = lang +} - setLang(lang) { +extend(Draft.prototype, { + setLang: function(lang) { this.lang = lang - } + }, - getForLang(lang, what) { + getForLang: function(lang, what) { return LS.getItem(this.key(what, lang)) || '' - } + }, - key(what, lang = null) { + key: function(what, lang) { + if (!lang) + lang = null if (lang === null) lang = this.lang - return `draft_${this.id}_${what}__${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)) + reset: function(langs) { + var types = ['title', 'text']; + for (var i = 0; i < types.length; i++) { + var what = types[i]; + for (var j = 0; j < langs.length; j++) + LS.removeItem(this.key(what, langs[i])); } - } + }, - 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) } -} + get: function(what) { + return what === 'title' ? this.getTitle() : this.getText() + }, + + set: function(what, val) { + if (what === 'title') + this.setTitle(val) + else + this.setText(val) + }, + + getTitle: function() { return LS.getItem(this.key('title')) || '' }, + getText: function() { return LS.getItem(this.key('text')) || '' }, + setTitle: function(val) { LS.setItem(this.key('title'), val) }, + setText: function(val) { LS.setItem(this.key('text'), val) } +}); \ No newline at end of file diff --git a/htdocs/js/admin/11-write-form.js b/htdocs/js/admin/11-write-form.js index aaf93f5..722ab65 100644 --- a/htdocs/js/admin/11-write-form.js +++ b/htdocs/js/admin/11-write-form.js @@ -1,105 +1,101 @@ -class AdminWriteEditForm { - constructor(opts = {}) { - this.opts = opts - this.form = document.forms[this.isPage() ? 'pageForm' : 'postForm'] - this.isFixed = false - this.previewTimeout = null - this.previewRequest = null - this.tocByLang = {} +function AdminWriteEditForm(opts) { + bindEvents(this); + opts = opts || {}; - if (!this.isEditing() && this.isPost()) { - for (const l of opts.langs) { - this.tocByLang[l] = false - } + this.opts = opts; + this.form = document.forms[this.isPage() ? 'pageForm' : 'postForm']; + this.previewTimeout = null; + this.previewRequest = null; + this.tocByLang = {}; + + if (!this.isEditing() && this.isPost()) { + for (var i = 0; i < opts.langs.length; i++) { + this.tocByLang[opts.langs[i]] = false; } - - this.form.addEventListener('submit', this.onSubmit) - 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()) { - if (this.isPost()) { - 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) - } else { - this.draft.title = opts.text.title - this.draft.text = opts.text.text - } - 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 } + this.form.addEventListener('submit', this.onSubmit); + this.form.title.addEventListener('input', this.onInput); + this.form.text.addEventListener('input', this.onInput); + ge('toggle_wrap').addEventListener('click', this.onToggleWrapClick); - 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() + if (this.isPost()) { + ge('toc_cb').addEventListener('change', this.onToCCheckboxChange); } - showPreview() { + var lang = 'en'; + if (this.isPost()) { + lang = this.getCurrentLang(); + this.form.lang.addEventListener('change', this.onLangChanged); + } + + var 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()) { + if (this.isPost()) { + for (var l in opts.texts) { + this.draft.setLang(l) + this.draft.setTitle(opts.texts[l].title) + this.draft.setText(opts.texts[l].md) + this.tocByLang[l] = opts.texts[l].toc + } + this.draft.setLang(lang) + } else { + this.draft.setTitle(opts.text.title) + this.draft.setText(opts.text.text) + } + this.showPreview() + } else { + this.fillFromDraft() + } +} + +extend(AdminWriteEditForm.prototype, { + getCurrentLang: function () { + return this.form.lang.options[this.form.lang.selectedIndex].value; + }, + isPost: function() { return !this.opts.pages }, + isPage: function() { return !!this.opts.pages }, + isEditing: function() { return !!this.opts.edit }, + + fillFromDraft: function(opts) { + opts = opts || {applyEventEmpty: false}; + var whats = ['title', 'text']; + for (var i = 0; i < whats.length; i++) { + var what = whats[i]; + if (this.draft.get(what) !== '' || opts.applyEvenEmpty) + this.form[what].value = this.draft.get(what); + } + if (this.form.text.value !== '' || opts.applyEvenEmpty) + this.showPreview(); + }, + + showPreview: function() { if (this.previewRequest !== null) - this.previewRequest.abort() - const params = { + this.previewRequest.abort(); + var 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) => { + 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 - }) - } + return console.error(err); + ge('preview_html').innerHTML = response.html; + }); + }, - showError(code, message) { + showError: function(code, message) { if (code) { - let el = ge('form-error') - let label = escape(lang(`err_blog_${code}`)) + var el = ge('form-error') + var label = escape(lang('err_blog_'+code)) if (message) label += ' (' + message + ')' el.innerHTML = label @@ -107,165 +103,129 @@ class AdminWriteEditForm { } else if (message) { alert(lang('error')+': '+message) } - } + }, - hideError() { - let el = ge('form-error') + hideError: function() { + var el = ge('form-error') el.style.display = 'none' - } + }, - onSubmit = (evt) => { - const fields = [] + onSubmit: function(evt) { + var fields = [] try { if (this.isEditing()) { fields.push('new_short_name'); } else { fields.push('short_name'); } - for (const field of fields) { + fields.forEach(function(field) { if (evt.target.elements[field].value.trim() === '') throw 'no_'+field - } + }) - const fd = new FormData() - for (const f of fields) { - console.log(`field: ${f}`) - fd.append(f, evt.target[f].value.trim()) - } + var fd = new FormData(); + fields.forEach(function(f) { + console.log('field: ' + f) + fd.append(f, evt.target[f].value.trim()); + }) // fd.append('lang', this.getCurrentLang()) if (this.isPost() || this.isEditing()) - fd.append('visible', ge('visible_cb').checked ? 1 : 0) + fd.append('visible', ge('visible_cb').checked ? 1 : 0); // text-specific fields - let atLeastOneLangIsWritten = false - const writtenLangs = [] + var atLeastOneLangIsWritten = false; + var writtenLangs = []; if (this.isPost()) { - for (const l of this.opts.langs) { - const title = this.draft.getForLang(l, 'title') - const 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) - } + this.opts.langs.forEach(function(l) { + var title = this.draft.getForLang(l, 'title'); + var text = this.draft.getForLang(l, '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); + } + }.bind(this)) } else { - const title = this.draft.title - const text = this.draft.text + var title = this.draft.getTitle() + var text = this.draft.getText() if (title !== '' && text !== '') { - atLeastOneLangIsWritten = true - fd.append('title', title) - fd.append('text', text) + atLeastOneLangIsWritten = true; + fd.append('title', title); + fd.append('text', text); } } + if (!atLeastOneLangIsWritten) throw 'no_text' - fd.append('langs', writtenLangs.join(',')) + fd.append('langs', writtenLangs.join(',')); // date field if (this.isPost()) { - const dateInput = evt.target.elements.date; + var dateInput = evt.target.elements.date; if (!dateInput.value) throw 'no_date' - fd.append('date', dateInput.value) + fd.append('date', dateInput.value); } - fd.append('token', this.opts.token) - cancelEvent(evt) + fd.append('token', this.opts.token); + cancelEvent(evt); this.hideError(); - ajax.post(evt.target.action, fd, (error, response) => { + ajax.post(evt.target.action, fd, function(error, response) { if (error) { - this.showError(error.code, error.message) + this.showError(error.code, error.message); return; } if (response.url) { this.draft.reset(this.opts.langs); - window.location = response.url + window.location = response.url; } - }) + }.bind(this)); } catch (e) { - const errorText = typeof e == 'string' ? lang('error')+': '+lang((this.isPage() ? 'err_pages_' : 'err_blog_')+e) : e.message; + var errorText = typeof e == 'string' ? lang('error')+': '+lang((this.isPage() ? 'err_pages_' : 'err_blog_')+e) : e.message; alert(errorText); console.error(e); return cancelEvent(evt); } - } + }, - onToggleWrapClick = (e) => { - const textarea = this.form.elements.text + onToggleWrapClick: function(e) { + var textarea = this.form.elements.text if (!hasClass(textarea, 'nowrap')) { - addClass(textarea, 'nowrap') + addClass(textarea, 'nowrap'); } else { - removeClass(textarea, 'nowrap') + removeClass(textarea, 'nowrap'); } - return cancelEvent(e) - } + return cancelEvent(e); + }, - onInput = (e) => { + onInput: function(e) { if (this.previewTimeout !== null) clearTimeout(this.previewTimeout); - this.previewTimeout = setTimeout(() => { + this.previewTimeout = setTimeout(function() { this.previewTimeout = null; this.showPreview(); - const what = e.target.name === 'title' ? 'title' : 'text' - this.draft[what] = e.target.value - }, 300) + var what = e.target.name === 'title' ? 'title' : 'text'; + this.draft.set(what, e.target.value); + }.bind(this), 300); + }, + + onLangChanged: function(e) { + var newLang = e.target.options[e.target.selectedIndex].value; + this.draft.setLang(newLang); + this.fillFromDraft({applyEvenEmpty: true}); + ge('toc_cb').checked = this.tocByLang[newLang]; + }, + + onToCCheckboxChange: function(e) { + this.tocByLang[this.getCurrentLang()] = e.target.checked; } - - onScroll = () => { - var ANCHOR_TOP = 10; - - var y = window.pageYOffset; - var form = this.form; - var td = ge('form_first_cell'); - var ph = ge('form_placeholder'); - - var rect = td.getBoundingClientRect(); - - if (rect.top <= ANCHOR_TOP && !this.isFixed) { - ph.style.height = form.getBoundingClientRect().height+'px'; - - var w = (rect.width - (parseInt(getComputedStyle(td).paddingRight, 10) || 0)); - form.style.display = 'block'; - form.style.width = w+'px'; - form.style.position = 'fixed'; - form.style.top = ANCHOR_TOP+'px'; - - this.isFixed = true; - } else if (rect.top > ANCHOR_TOP && this.isFixed) { - form.style.display = ''; - form.style.width = ''; - form.style.position = ''; - form.style.position = ''; - ph.style.height = ''; - - this.isFixed = false; - } - } - - 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); +}) diff --git a/htdocs/js/common/00-common.js b/htdocs/js/common/00-common.js new file mode 100644 index 0000000..ef77b83 --- /dev/null +++ b/htdocs/js/common/00-common.js @@ -0,0 +1,2 @@ +window.LS = window.localStorage; +window.cur = {}; \ No newline at end of file diff --git a/htdocs/js/common/00-polyfills.js b/htdocs/js/common/00-polyfills.js index 74ec195..c7b18ab 100644 --- a/htdocs/js/common/00-polyfills.js +++ b/htdocs/js/common/00-polyfills.js @@ -44,4 +44,29 @@ if (!Object.assign) { return to; } }); -} \ No newline at end of file +} + +(function(window) { + var lastTime = 0; + var vendors = ['ms', 'moz', 'webkit', 'o']; + for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame']; + window.cancelAnimationFrame = window[vendors[x]+'CancelAnimationFrame'] + || window[vendors[x]+'CancelRequestAnimationFrame']; + } + + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function(callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function() { callback(currTime + timeToCall); }, + timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function(id) { + clearTimeout(id); + }; +}(window)); \ No newline at end of file diff --git a/htdocs/js/common/01-logger.js b/htdocs/js/common/01-logger.js new file mode 100644 index 0000000..c12d019 --- /dev/null +++ b/htdocs/js/common/01-logger.js @@ -0,0 +1,37 @@ +function getLogger(moduleName) { + var levels = ['log', 'info', 'warning', 'warn', 'debug', 'error']; + + if (!window.console) { + var noop = function() {}; + var result = {func: function() { return this; }}; + levels.forEach(function(level) { + result[level] = noop; + }) + } + + function logWithPrefix(prefix, method, args) { + var message = Array.prototype.slice.call(args); + message.unshift('[' + prefix + ']:'); + console[method].apply(console, message); + } + + var logger = {}; + + levels.forEach(function(level) { + logger[level] = function() { + logWithPrefix(moduleName, level === 'warning' ? 'warn' : level, arguments); + }; + }); + + logger.local = function(funcName) { + var funcLogger = {}; + levels.forEach(function(level) { + funcLogger[level] = function() { + logWithPrefix(moduleName + '/' + funcName, level === 'warning' ? 'warn' : level, arguments); + }; + }); + return funcLogger; + }; + + return logger; +} \ No newline at end of file diff --git a/htdocs/js/common/02-ajax.js b/htdocs/js/common/02-ajax.js index 0e5d356..7473a27 100644 --- a/htdocs/js/common/02-ajax.js +++ b/htdocs/js/common/02-ajax.js @@ -73,6 +73,8 @@ opts = Object.assign({}, defaultOpts, opts); var xhr = createXMLHttpRequest(); + var aborted = false; + xhr.open(method, url); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); @@ -81,7 +83,7 @@ var callbackFired = false; xhr.onreadystatechange = function() { - if (callbackFired) + if (callbackFired || aborted) return if (xhr.readyState === 4) { @@ -102,12 +104,22 @@ }; xhr.onerror = function(e) { + if (aborted) + return callback(e, null, 0); }; xhr.send(method === 'GET' ? null : data); - return xhr; + return { + xhr: xhr, + abort: function() { + aborted = true; + try { + xhr.abort(); + } catch (e) {} + } + }; } window.ajax = { @@ -116,3 +128,41 @@ } })(); + +// +// History API polyfill +// +(function() { + var supportsHistoryAPI = window.history && window.history.pushState && window.history.replaceState; + + function redirectToHashbangPath() { + if (!supportsHistoryAPI) { + var hash = window.location.hash; + if (hash.startsWith("#!")) { + var path = hash.substring(2); // Remove the '#!' to get the path + window.location.replace(path); + } + } + } + + if (!supportsHistoryAPI) { + var originalTitle = document.title; + window.history.pushState = function(state, title, url) { + var path = url.substring(url.indexOf("/", url.indexOf("//") + 2)); + sessionStorage.setItem('!' + path, JSON.stringify(state)); + window.location.hash = '!' + path; + document.title = title || originalTitle; + }; + window.history.replaceState = function(state, title, url) { + var path = url.substring(url.indexOf("/", url.indexOf("//") + 2)); + sessionStorage.setItem('!' + path, JSON.stringify(state)); + var currentUrlWithoutHash = window.location.href.split('#')[0]; + window.location.replace(currentUrlWithoutHash + '#!' + path); + document.title = title || originalTitle; + }; + window.addEventListener("hashchange", redirectToHashbangPath, false); + } + + // Initial check for redirect only if the browser does not support History API + redirectToHashbangPath(); +})(); \ No newline at end of file diff --git a/htdocs/js/common/03-dom.js b/htdocs/js/common/03-dom.js index 31a53b3..f929924 100644 --- a/htdocs/js/common/03-dom.js +++ b/htdocs/js/common/03-dom.js @@ -1,80 +1,96 @@ -// -// DOM helpers -// -function ge(id) { +(function(window) { +var logger = getLogger('03-dom.js') + +window.ge = function ge(id) { return document.getElementById(id) } -function hasClass(el, name) { +window.re = function re(el) { + if (typeof el === 'string') + el = ge(el); + if (el && el.parentNode) { + el.parentNode.removeChild(el) + } +} + +window.ce = function ce(tagName, attr, style) { + if (arguments.length === 1 && typeof tagName == 'string') { + var div = document.createElement('div'); + div.innerHTML = tagName; + return div.firstChild; + } + + var el = document.createElement(tagName); + if (attr) { + extend(el, attr); + } + if (style) { + setStyle(el, style); + } + return el +} + +window.insertAfter = function insertAfter(elem, refElem) { + return refElem.parentNode.insertBefore(elem, refElem.nextSibling); +} + +window.show = function show(el, dsp) { + if (typeof el === 'string') + el = ge(el); + if (!el) + return logger.local('show').error('invalid element:', el) + if (!dsp) + dsp = el.tagName === 'SPAN' || el.tagName === 'A' ? 'inline' : 'block'; + el.style.display = dsp; +} + +window.hide = function hide(el) { + if (typeof el === 'string') + el = ge(el); + if (!el) + return logger.local('hide').error('invalid element:', el) + el.style.display = 'none'; +} + +window.visible = function visible(el) { + return getStyle(el, 'display') !== 'none'; +} + +window.toggle = function toggle(el) { + visible(el) ? hide(el) : show(el) +} + +window.hasClass = function hasClass(el, name) { + if (typeof el === 'string') + el = ge(el); return el && el.nodeType === 1 && (" " + el.className + " ").replace(/[\t\r\n\f]/g, " ").indexOf(" " + name + " ") >= 0 } -function addClass(el, name) { - if (!el) { - return console.warn('addClass: el is', el) - } - if (!hasClass(el, name)) { +window.addClass = function addClass(el, name) { + if (typeof el === 'string') + el = ge(el); + if (!el) + return logger.local('addClass').warn('el is', el) + if (!hasClass(el, name)) el.className = (el.className ? el.className + ' ' : '') + name - } } -function removeClass(el, name) { - if (!el) { - return console.warn('removeClass: el is', el) - } +window.removeClass = function removeClass(el, name) { + if (typeof el === 'string') + el = ge(el); + if (!el) + return logger.local('removeClass').warn('el is', el) if (isArray(name)) { - for (var i = 0; i < name.length; i++) { + for (var i = 0; i < name.length; i++) removeClass(el, name[i]); - } return; } el.className = ((el.className || '').replace((new RegExp('(\\s|^)' + name + '(\\s|$)')), ' ')).trim() } -function addEvent(el, type, f, useCapture) { - if (!el) { - return console.warn('addEvent: el is', el, stackTrace()) - } - - if (isArray(type)) { - for (var i = 0; i < type.length; i++) { - addEvent(el, type[i], f, useCapture); - } - return; - } - - if (el.addEventListener) { - el.addEventListener(type, f, useCapture || false); - return true; - } else if (el.attachEvent) { - return el.attachEvent('on' + type, f); - } - - return false; -} - -function removeEvent(el, type, f, useCapture) { - if (isArray(type)) { - for (var i = 0; i < type.length; i++) { - var t = type[i]; - removeEvent(el, type[i], f, useCapture); - } - return; - } - - if (el.removeEventListener) { - el.removeEventListener(type, f, useCapture || false); - } else if (el.detachEvent) { - return el.detachEvent('on' + type, f); - } - - return false; -} - -function cancelEvent(evt) { - if (!evt) { - return console.warn('cancelEvent: event is', evt) - } +window.cancelEvent = function cancelEvent(evt) { + if (!evt) + return logger.local('cancelEvent').warn('cancelEvent: event is', evt) if (evt.preventDefault) evt.preventDefault(); if (evt.stopPropagation) evt.stopPropagation(); @@ -85,32 +101,55 @@ function cancelEvent(evt) { return false; } -// -// Cookies -// -function setCookie(name, value, days) { - var expires = ""; - if (days) { - var date = new Date(); - date.setTime(date.getTime() + (days*24*60*60*1000)); - expires = "; expires=" + date.toUTCString(); +window.setStyle = function setStyle(el, name, value) { + if (typeof el === 'string') + el = ge(el); + + if (Array.isArray(el)) { + return el.forEach(function(el, i) { + setStyle(el, name, value); + }); } - document.cookie = name + "=" + (value || "") + expires + "; domain=" + window.appConfig.cookieHost + "; path=/"; + + if (isObject(name)) { + for (var k in name) { + var v = name[k]; + setStyle(el, k, v); + } + return; + } + + if (typeof value === 'number' + && name !== 'z-index' + && ! /acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i.test(name)) + value += 'px'; + + el.style[toCamelCase(name)] = value; } -function unsetCookie(name) { - document.cookie = name + '=; Max-Age=-99999999; domain=' + window.appConfig.cookieHost + "; path=/"; -} +/** + * @param {HTMLElement} el + * @param {String|Array} css + * @return + */ +window.getStyle = function getStyle(el, css) { + if (typeof el === 'string') + el = ge(el); -function getCookie(name) { - var nameEQ = name + "="; - var ca = document.cookie.split(';'); - for (var i = 0; i < ca.length; i++) { - var c = ca[i]; - while (c.charAt(0) === ' ') - c = c.substring(1, c.length); - if (c.indexOf(nameEQ) === 0) - return c.substring(nameEQ.length, c.length); + if (Array.isArray(css)) { + var result = {} + for (var i = 0; i < css.length; i++) { + var key = css[i] + result[key] = getStyle(el, key) + } + return result + } + + if (window.getComputedStyle) { + var compStyle = window.getComputedStyle(el, '') + return compStyle.getPropertyValue(css) + } else if (el.currentStyle) { + return el.currentStyle[camelcase(css)] } - return null; } +})(window); diff --git a/htdocs/js/common/04-util.js b/htdocs/js/common/04-util.js index da95e39..91fce1c 100644 --- a/htdocs/js/common/04-util.js +++ b/htdocs/js/common/04-util.js @@ -1,14 +1,19 @@ -function bindEventHandlers(obj) { - for (var k in obj) { - if (obj.hasOwnProperty(k) - && typeof obj[k] == 'function' - && k.length > 2 - && k.startsWith('on') - && k[2].charCodeAt(0) >= 65 - && k[2].charCodeAt(0) <= 90) { - obj[k] = obj[k].bind(obj) +function bindEvents(obj) { + try { + for (var k in obj) { + if ((obj.hasOwnProperty(k) || obj.__proto__.hasOwnProperty(k)) + && typeof obj[k] == 'function' + && k.length > 2 + && k.startsWith('on') + && k[2].charCodeAt(0) >= 65 + && k[2].charCodeAt(0) <= 90) { + obj[k] = obj[k].bind(obj); + } } + } catch (e) { + console.error(e); } + return obj; } function isObject(o) { @@ -20,10 +25,10 @@ function isArray(a) { } function extend(dst, src) { - if (!isObject(dst)) { + if (typeof dst !== 'object') { return console.error('extend: dst is not an object'); } - if (!isObject(src)) { + if (typeof src !== 'object') { return console.error('extend: src is not an object'); } for (var key in src) { @@ -86,4 +91,49 @@ function once(fn, context) { } return result; }; +} + +function throttle(func, wait) { + var timeout = null; + var lastFunc; + var lastRan; + + return function() { + var context = this; + var args = arguments; + if (!lastRan) { + func.apply(context, args); + lastRan = Date.now(); + } else { + clearTimeout(lastFunc); + lastFunc = setTimeout(function() { + if ((Date.now() - lastRan) >= wait) { + func.apply(context, args); + lastRan = Date.now(); + } + }, wait - (Date.now() - lastRan)); + } + }; +} + +function toCamelCase(s) { + return s.split('-').map(function(word, index) { + return index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1); + }).join(''); +} + +function sprintf() { + var args = arguments, + format = args[0], + i = 1; + return format.replace(/%(\d+)?([%s])/g, function(match, number, type) { + if (type === "%") { + return "%"; + } + var index = number ? parseInt(number, 10) : i++; + if (index >= args.length) { + return match; + } + return args[index]; + }); } \ No newline at end of file diff --git a/htdocs/js/common/05-slideutils.js b/htdocs/js/common/05-slideutils.js new file mode 100644 index 0000000..374c012 --- /dev/null +++ b/htdocs/js/common/05-slideutils.js @@ -0,0 +1,125 @@ +(function(window) { +var SLIDE_DEFAULT_OPTS = { + duration: 350, + easing: 'ease-in-out' +}; + +window.slideutils = { + up: function slideUp(node, opts, callback) { + opts = Object.assign({}, SLIDE_DEFAULT_OPTS, opts || {}); + var wrap = null; + + if (node._slideActive) { + if (node._slideTimeout) { + clearTimeout(node._slideTimeout); + delete node._slideTimeout; + } + wrap = node.parentNode; + } else { + var height = node.offsetHeight; + wrap = ce('div', {}); + setStyle(wrap, { + height: height, + transition: 'height '+(opts.duration/1000)+'s '+opts.easing, + overflow: 'hidden' + }); + + insertAfter(wrap, node); + wrap.appendChild(node); + } + + node._slideActive = 1; + + setTimeout(function() { + setStyle(wrap, 'height', '0px'); + + node._slideTimeout = setTimeout(function() { + hide(node); + insertAfter(node, wrap); + re(wrap); + + delete node._slideActive; + delete node._slideTimeout; + + callback && callback(); + }, opts.duration); + }); + }, + + down: function slideDown(node, opts, callback) { + opts = Object.assign({}, SLIDE_DEFAULT_OPTS, opts || {}); + var wrap = null; + + if (node._slideActive) { + if (node._slideTimeout) { + clearTimeout(node._slideTimeout); + delete node._slideTimeout; + } + wrap = node.parentNode; + } else { + wrap = ce('div', {}); + setStyle(wrap, { + height: 0, + transition: 'height '+(opts.duration/1000)+'s '+opts.easing, + overflow: 'hidden' + }); + + insertAfter(wrap, node); + wrap.appendChild(node); + show(node); + }; + + node._slideActive = 2; + + setTimeout(function() { + setStyle(wrap, 'height', node.offsetHeight); + node._slideTimeout = setTimeout(function() { + insertAfter(node, wrap); + re(wrap); + + delete node._slideActive; + delete node._slideTimeout; + + callback && callback(); + }, opts.duration); + }) + }, + + toggle: function slideToggle(node, opts, callback) { + opts = opts || {}; + var isVisible = getStyle(node, 'display') !== 'none'; + if (isVisible && node._slideActive !== 1) { + this.up(node, opts, callback); + return false; + } else { + this.down(node, opts, callback); + return true; + } + }, + + custom: function(node, targetHeight, opts, callback) { + opts = Object.assign({}, SLIDE_DEFAULT_OPTS, opts || {}) + + if (node._slideActive) { + if (node._slideTimeout) { + clearTimeout(node._slideTimeout) + delete node._slideTimeout + } + } + + setStyle(node, { + transition: 'height '+(opts.duration/1000)+'s '+opts.easing + }); + + node._slideActive = 2; + setTimeout(function() { + setStyle(node, 'height', targetHeight); + node._slideTimeout = setTimeout(function() { + delete node._slideActive; + delete node._slideTimeout; + callback && callback(); + }, opts.duration) + }); + } +}; +})(window); \ No newline at end of file diff --git a/htdocs/js/common/06-cookies.js b/htdocs/js/common/06-cookies.js new file mode 100644 index 0000000..b442729 --- /dev/null +++ b/htdocs/js/common/06-cookies.js @@ -0,0 +1,27 @@ +function setCookie(name, value, days) { + var expires = ""; + if (days) { + var date = new Date(); + date.setTime(date.getTime() + (days*24*60*60*1000)); + expires = "; expires=" + date.toUTCString(); + } + document.cookie = name + "=" + (value || "") + expires + "; domain=" + window.appConfig.cookieHost + "; path=/"; +} + +function unsetCookie(name) { + document.cookie = name + '=; Max-Age=-99999999; domain=' + window.appConfig.cookieHost + "; path=/"; +} + +function getCookie(name) { + var nameEQ = name + "="; + var ca = document.cookie.split(';'); + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) === ' ') + c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) + return c.substring(nameEQ.length, c.length); + } + return null; +} + diff --git a/htdocs/js/common/10-lang.js b/htdocs/js/common/10-lang.js index bd0d8e7..2df1037 100644 --- a/htdocs/js/common/10-lang.js +++ b/htdocs/js/common/10-lang.js @@ -2,4 +2,53 @@ function lang(key) { return __lang[key] !== undefined ? __lang[key] : '{'+key+'}'; } +lang.num = function(s, num, opts) { + var defaultOpts = { + format: true, + formatDelim: ' ', + lang: 'ru' + }; + opts = Object.assign({}, defaultOpts, opts || {}); + + if (typeof s === 'string') + s = get(s); + + var word = 2; + switch (opts.lang) { + case 'ru': + var n = num % 100; + if (n > 19) + n %= 10; + + if (n === 1) { + word = 0; + } else if (n >= 2 && n <= 4) { + word = 1; + } else if (num === 0 && s.length === 4) { + word = 3; + } else { + word = 2; + } + break; + + default: + if (num === 0 && s.length === 4) { + word = 3; + } else { + word = num === 1 ? 0 : 1; + } + break; + } + + // if zero + if (word === 3) + return s[3]; + + if (typeof opts.format === 'function') { + num = opts.format(num) + } + + return sprintf(s[word], num) +} + window.__lang = {}; \ No newline at end of file diff --git a/htdocs/js/common/35-theme-switcher.js b/htdocs/js/common/35-theme-switcher.js index 01cf854..05479d3 100644 --- a/htdocs/js/common/35-theme-switcher.js +++ b/htdocs/js/common/35-theme-switcher.js @@ -1,4 +1,6 @@ var ThemeSwitcher = (function() { + var logger = getLogger('35-theme-switcher.js'); + /** * @type {string[]} */ @@ -60,7 +62,7 @@ var ThemeSwitcher = (function() { if (!val) return modes[0]; if (modes.indexOf(val) === -1) { - console.error('[ThemeSwitcher getSavedMode] invalid cookie value') + logger.local('getSavedMode').error('invalid cookie value') unsetCookie('theme') return modes[0] } @@ -80,7 +82,7 @@ var ThemeSwitcher = (function() { try { f(dark) } catch (e) { - console.error('[ThemeSwitcher->changeTheme->onDone] error while calling user callback:', e) + logger.local('changeTheme').error('error while calling user callback:', e) } }) }) @@ -135,7 +137,7 @@ var ThemeSwitcher = (function() { document.body.setAttribute('data-theme', selectedMode); for (var i = 0; i < modes.length; i++) { var mode = modes[i]; - document.getElementById('moon_'+mode).style.display = mode === selectedMode ? 'block': 'none'; + document.getElementById('svgicon_moon_'+mode+'_18').style.display = mode === selectedMode ? 'block': 'none'; } } @@ -179,7 +181,7 @@ var ThemeSwitcher = (function() { next: function(e) { if (hasClass(document.body, 'theme-changing')) { - console.log('next: theme changing is in progress, ignoring...') + logger.local('next').log('theme changing is in progress, ignoring...') return; } diff --git a/htdocs/js/common/40-index-page.js b/htdocs/js/common/40-index-page.js index d42fc2a..542f30f 100644 --- a/htdocs/js/common/40-index-page.js +++ b/htdocs/js/common/40-index-page.js @@ -1,4 +1,4 @@ -var IndexPage = { +window.IndexPage = { offsets: { en: 0, ru: 300 diff --git a/htdocs/js/common/41-files-search.js b/htdocs/js/common/41-files-search.js new file mode 100644 index 0000000..7f509bc --- /dev/null +++ b/htdocs/js/common/41-files-search.js @@ -0,0 +1,202 @@ +function FileSearch(opts) { + bindEvents(this); + + this.logger = getLogger('FileSearch'); + this.req = null; + this.initializedWithSearch = opts.inited_with_search; + this.collectionName = opts.collection_name; + this.baseUrl = opts.base_url; + this.inputQuery = opts.query || null; + this.resultsCount = opts.count || 0; + this.resultsOffset = opts.query ? opts.per_page : 0; + this.resultsPerPage = opts.per_page; + this.minQueryLength = opts.min_query_length; + + this.refs = { + container: ge(opts.container), + filesList: ge('files_list'), + clearIcon: ge('files_search_clear_icon'), + showMore: ge('files_show_more'), + input: ge('files_search_input'), + info: ge('files_search_info'), + infoResultsCount: ge('files_search_info_text') + }; + + if (this.inputQuery) + this.refs.input.value = this.inputQuery; + + this.init(); +} + +extend(FileSearch.prototype, { + init: function() { + this.refs.clearIcon.addEventListener('click', this.onClearIconClick); + this.refs.input.addEventListener('focus', this.onSearchInputFocus); + this.refs.input.addEventListener('blur', this.onSearchInputBlur); + this.refs.input.addEventListener('input', this.onSearchInput); + this.refs.input.addEventListener('keydown', this.onSearchInputKeyDown); + this.refs.showMore.addEventListener('click', this.onShowMoreClick); + + this.searchThrottled = throttle(this.search, 300); + + this.refs.input.focus(); + }, + + abortRequestIfNeeded: function() { + if (this.req) { + this.req.abort(); + this.req = null; + } + }, + + search: function(query) { + var queryChanged = this.inputQuery !== query; + if (queryChanged) { + this.resultsOffset = 0; + this.resultsCount = 0; + } + + this.abortRequestIfNeeded(); + this.inputQuery = query; + + this.showProgress(); + + this.req = ajax.get(this.baseUrl, { + q: query, + offset: this.resultsOffset + }, function(error, response) { + this.hideProgress(); + if (error) { + alert('error!'); + console.error(error); + } else { + if (!this.initializedWithSearch) { + var filesListEl = this.refs.filesList; + var filesListHiddenEl = ge('files_list_hidden'); + if (!filesListHiddenEl.hasAttribute('data-non-empty')) { + filesListHiddenEl.innerHTML = filesListEl.innerHTML; + filesListHiddenEl.setAttribute('data-non-empty', '1'); + filesListEl.innerHTML = ''; + } + } + + if (queryChanged) { + this.refs.filesList.innerHTML = response.html + } else { + this.refs.filesList.innerHTML += response.html; + } + + this.resultsCount = response.search_count; + this.resultsOffset = response.new_offset; + + this.updateLocation(); + this.showResultsCount(); + + if (this.canLoadMore()) { + show(this.refs.showMore) + } else { + hide(this.refs.showMore) + } + } + }.bind(this)); + }, + + canLoadMore: function() { + return this.resultsOffset < this.resultsCount; + }, + + showProgress: function () { + addClass(this.refs.showMore, 'is-loading'); + + if (!visible(this.refs.info)) + slideutils.down(this.refs.info); + + addClass(this.refs.info, 'is-loading'); + show(this.refs.clearIcon); + }, + + hideProgress: function() { + removeClass(this.refs.showMore, 'is-loading'); + removeClass(this.refs.info, 'is-loading'); + }, + + hideSearch: function() { + hide(this.refs.showMore); + + var filesListEl = ge('files_list'); + var filesListHiddenEl = ge('files_list_hidden'); + var nonEmpty = filesListHiddenEl.getAttribute('data-non-empty') === '1'; + if (!nonEmpty) + window.location = this.baseUrl; + else { + filesListEl.innerHTML = filesListHiddenEl.innerHTML + filesListHiddenEl.innerHTML = '' + filesListHiddenEl.removeAttribute('data-non-empty') + } + + this.abortRequestIfNeeded(); + + this.inputQuery = null; + this.resultsCount = 0; + this.resultsOffset = 0; + + this.updateLocation(); + + hide(this.refs.clearIcon); + this.hideProgress(); + if (visible(this.refs.info)) + slideutils.up(this.refs.info); + }, + + updateLocation: function() { + var title = lang('4in1') + ' - ' + lang('files_'+this.collectionName+'_collection'); + if (this.inputQuery !== null) + title += ' - ' + this.inputQuery; + var url = this.baseUrl; + if (this.inputQuery !== null) + url += '?q='+encodeURIComponent(this.inputQuery); + window.history.replaceState(null, title, url); + document.title = title; + }, + + showResultsCount: function() { + this.refs.infoResultsCount.innerText = lang.num(lang('files_search_results_count'), this.resultsCount); + }, + + onSearchInput: function(evt) { + var q = evt.target.value.trim(); + if (q.length >= this.minQueryLength) + this.searchThrottled(q); + else if (q === '') + this.hideSearch(); + }, + + onSearchInputKeyDown: function(evt) { + if (evt.keyCode === 10 || evt.keyCode === 13) { + var query = this.refs.input.value.trim(); + if (query === '') { + this.hideSearch(); + } else { + this.searchThrottled(query); + } + } + }, + + onSearchInputFocus: function(evt) { + addClass('files_search', 'is-focused'); + }, + + onSearchInputBlur: function(evt) { + removeClass('files_search', 'is-focused'); + }, + + onClearIconClick: function(evt) { + this.refs.input.value = ''; + this.refs.input.focus(); + this.hideSearch(); + }, + + onShowMoreClick: function(evt) { + this.search(this.inputQuery); + } +}); \ No newline at end of file diff --git a/htdocs/scss/app/blog.scss b/htdocs/scss/app/blog.scss index 23874d9..7e00e7b 100644 --- a/htdocs/scss/app/blog.scss +++ b/htdocs/scss/app/blog.scss @@ -66,13 +66,22 @@ } .blog-upload-form { + margin-top: $base-padding; + padding-top: $base-padding; padding-bottom: $base-padding; + background-color: $hover-hl; + @include radius(4px); } -.blog-upload-list {} +.blog-upload-list { + padding-top: $base-padding; +} .blog-upload-item { border-top: 1px $border-color solid; padding: 10px 0; + &:first-child { + border-top: 0; + } } .blog-upload-item-actions { float: right; @@ -171,7 +180,7 @@ body.wide .blog-post { font-size: 22px; font-weight: bold; padding: 0; - margin: 0; + margin: 5px 0 0 0; } .blog-post-date { @@ -383,7 +392,7 @@ body.wide .blog-post { td.blog-item-date-cell { width: 1px; white-space: nowrap; - text-align: right; + //text-align: right; padding-right: 10px; } .blog-item-date { @@ -406,9 +415,9 @@ td.blog-item-title-cell { .blog-item-row-year { td { padding-top: 10px; - text-align: right; - font-size: 20px; - letter-spacing: -0.5px; + //text-align: right; + font-size: $fs + 2px; + //letter-spacing: -0.5px; } &:first-child td { padding-top: 0; diff --git a/htdocs/scss/app/common.scss b/htdocs/scss/app/common.scss index 3754c36..d721ed5 100644 --- a/htdocs/scss/app/common.scss +++ b/htdocs/scss/app/common.scss @@ -23,6 +23,16 @@ body { font-size: $fs; } +.no-select { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome, Edge, Opera and Firefox */ +} + .base-width { max-width: $base-width; margin: 0 auto; @@ -43,6 +53,13 @@ body.full-width .base-width { } } +.matched { + background: $hl_matched_bg; + color: $hl_matched_fg; + padding: 0 1px; + border-radius: 3px; +} + input[type="text"], input[type="password"], textarea { @@ -358,15 +375,20 @@ a.index-dl-line { } .bc { - padding-bottom: 15px; + &.mt { + margin-top: $base-padding; + } > a.bc-item, > span.bc-item { font-size: $fs + 2px; + line-height: $fs + 6px; display: inline-block; - padding: 5px 10px; + padding: 4px 9px; + vertical-align: middle; - &:not(:first-child) { - margin-left: 10px; + margin-bottom: 8px; + &:not(:last-child) { + margin-right: 8px; } } @@ -375,8 +397,10 @@ a.index-dl-line { border-radius: 4px; } > span.bc-item { - padding: 5px; - font-weight: 600; + padding: 4px; + &:first-child { + padding-left: 0; + } } > a.bc-item:hover { @@ -387,6 +411,15 @@ a.index-dl-line { // arrow span.bc-arrow { - color: $grey !important; + color: $light-grey; + vertical-align: middle; + opacity: .7; + > svg { + position: relative; + top: 3px; + margin-left: -6px; + margin-right: -4px; + margin-top: -1px; + } } } \ No newline at end of file diff --git a/htdocs/scss/app/files.scss b/htdocs/scss/app/files.scss new file mode 100644 index 0000000..b38b492 --- /dev/null +++ b/htdocs/scss/app/files.scss @@ -0,0 +1,196 @@ +.files-title { + padding: 5px 0; + font-size: 20px; + letter-spacing: -0.5px; +} +.files-list {} +.files-list-item { + display: block; + padding: 5px 8px; + margin-left: -8px; + margin-right: -8px; + + &.is-disabled { + color: $dark-grey; + cursor: default; + } + + &-icon { + width: 20px; + height: 20px; + float: left; + position: relative; + top: -1px; + } + + &-info { + margin-left: 30px; + } + + &-title { + //&-label { + // margin-right: 4px; + //} + } + + &-subtitle { + color: $grey; + } +} +.files-list-item-title-label-external-icon { + position: relative; + top: 1px; + color: $grey; +} + +.files-list-item-title-label + .files-list-item-meta-item { + margin-left: 4px; +} + +.files-list-item-meta { + padding: 2px 0; + margin-left: -2px; +} +.files-list-item-meta-item { + display: inline-block; + margin-right: 5px; + font-size: $fs - 5px; + background-color: $hover-hl; + color: transparentize($fg, 0.4); + padding: 3px 4px; + @include radius(4px); + &:last-child { margin-right: 0px; } +} + +.files-list-item:hover { + text-decoration: none; + background-color: $hover-hl; + @include radius(3px); + + .files-list-item-meta-item { + background-color: darken($hover-hl, 7%); + } +} + +.files-list-item-text-excerpt { + font-size: $fs - 4px; + font-family: monospace; + margin-top: 4px; + margin-bottom: 2px; + color: $dark-grey; + .matched { + background-color: darken($hover-hl, 4%); + @include radius(2px); + color: $dark-grey; + } +} + +.files-search { + position: relative; + border: 1px $input-border solid; + background-color: $input-bg; + @include radius(4px); + + &.is-focused { + border-color: $input-border-focused; + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.1); + } + + > input[type="text"] { + width: 100%; + background: none; + border: 0; + position: relative; + z-index: 2; + padding: 8px 8px 8px 34px; + } + + &-wrap { + padding-top: 6px; + padding-bottom: $base-padding; + } + + &-icon { + color: $grey; + position: absolute; + left: 8px; + top: 6px; + z-index: 1; + } + + &-clear-icon { + color: $light-grey; + position: absolute; + width: 32px; + top: 0; + right: 0; + bottom: 0; + z-index: 3; + cursor: pointer; + opacity: 0.8; + > svg { + position: absolute; + top: 50%; + left: 50%; + // since we know that icon is 16x16, we can just use -8px here + margin-left: -8px; + margin-top: -8px; + } + &:hover { + opacity: 1; + color: $link-color; + } + } + + &-results-info { + padding-top: 8px; + + &-inner { + position: relative; + background-color: $hover-hl; + color: $fg; + font-size: $fs - 2px; + box-sizing: border-box; + padding: 4px 8px; + @include radius(4px); + } + + &-spinner { + display: none; + position: absolute; + left: 50%; + top: 1px; + margin-left: -20px; + } + + &.is-loading &-spinner { display: block; } + } +} + +.files-list-show-more { + text-align: center; + padding: 10px; + @include radius(4px); + background-color: $hover-hl; + margin-top: 8px; + cursor: pointer; + position: relative; + + &:hover { + background-color: $hover-hl-darker; + } + &:active { + background-color: darken($hover-hl-darker, 4%); + } + + > .spinner { + display: none; + position: absolute; + top: 7px; + left: 50%; + margin-left: -20px; + } + + &.is-loading > .spinner { display: block; } + &.is-loading > &-label { visibility: hidden; } +} \ No newline at end of file diff --git a/htdocs/scss/app/head.scss b/htdocs/scss/app/head.scss index 15e8e6f..240a54d 100644 --- a/htdocs/scss/app/head.scss +++ b/htdocs/scss/app/head.scss @@ -117,3 +117,10 @@ body a.head-item.is-theme-switcher svg path, body a.head-item.is-settings svg path { fill: $fg; } + + +#svgicon_moon_light_18, +#svgicon_moon_dark_18, +#svgicon_moon_auto_18 { + display: none; +} \ No newline at end of file diff --git a/htdocs/scss/app/widgets.scss b/htdocs/scss/app/widgets.scss new file mode 100644 index 0000000..1783674 --- /dev/null +++ b/htdocs/scss/app/widgets.scss @@ -0,0 +1,30 @@ +.spinner { + text-align: center; + + > span { + display: inline-block; + width: 12px; + height: 4px; + margin-right: 2px; + animation: highlight .6s infinite linear; + + &:last-child { margin-right: 0 } + } +} + +@keyframes highlight { + 0%, 100% { + background-color: desaturate(lighten($link-color, 40%), 80%); + } + 33% { + background-color: lighten($link-color, 15%); + } +} + +.spinner > span:nth-child(1) { + animation-delay: -0.4s; +} + +.spinner > span:nth-child(2) { + animation-delay: -0.2s; +} \ No newline at end of file diff --git a/htdocs/scss/bundle_common.scss b/htdocs/scss/bundle_common.scss index 24beefe..d964461 100644 --- a/htdocs/scss/bundle_common.scss +++ b/htdocs/scss/bundle_common.scss @@ -4,7 +4,9 @@ @import "./app/blog"; @import "./app/form"; @import "./app/pages"; +@import "./app/files"; @import "./hljs/github.scss"; +@import "./app/widgets"; @media screen and (max-width: 880px) { @import "./app/mobile"; diff --git a/htdocs/scss/colors/dark.scss b/htdocs/scss/colors/dark.scss index 9c416dc..20e7f9b 100644 --- a/htdocs/scss/colors/dark.scss +++ b/htdocs/scss/colors/dark.scss @@ -13,6 +13,9 @@ $light-grey: $grey; $fg: #eee; $bg: #333; +$hl_matched_bg: $link-color; +$hl_matched_fg: #fff; + $code-block-bg: #394146; $inline-code-block-bg: #394146; diff --git a/htdocs/scss/colors/light.scss b/htdocs/scss/colors/light.scss index 33cf07d..46a57d8 100644 --- a/htdocs/scss/colors/light.scss +++ b/htdocs/scss/colors/light.scss @@ -13,6 +13,9 @@ $light-grey: #999; $fg: #222; $bg: #fff; +$hl_matched_bg: #207cdf; +$hl_matched_fg: #fff; + $code-block-bg: #f3f3f3; $inline-code-block-bg: #f1f1f1; diff --git a/htdocs/scss/vars.scss b/htdocs/scss/vars.scss index 896f998..b590264 100644 --- a/htdocs/scss/vars.scss +++ b/htdocs/scss/vars.scss @@ -1,6 +1,6 @@ $fs: 16px; $fsMono: 85%; -$ff: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; +$ff: -apple-system, BlinkMacSystemFont, Segoe UI, 'Liberation Sans', Helvetica, Arial, sans-serif; $ffMono: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, monospace; $base-width: 800px; diff --git a/lib/AdminActions/Util/Logger.php b/lib/AdminActions/Util/Logger.php index 1de086c..fa6b9a2 100644 --- a/lib/AdminActions/Util/Logger.php +++ b/lib/AdminActions/Util/Logger.php @@ -251,7 +251,7 @@ class Logger { continue; $class_name = substr($f, 0, strpos($f, '.')); - $class = '\\knigavuhe\\AdminActions\\'.$class_name; + $class = '\\AdminActions\\'.$class_name; if (interface_exists($class) || !class_exists($class)) { // logError(__METHOD__.': class '.$class.' not found'); diff --git a/lib/ext/MyParsedown.php b/lib/ext/MyParsedown.php index a06e861..b0815e3 100644 --- a/lib/ext/MyParsedown.php +++ b/lib/ext/MyParsedown.php @@ -214,7 +214,7 @@ class MyParsedown extends ParsedownExtended { } protected static function getSkinContext(): SkinContext { - return new SkinContext('\\skin\\markdown'); + return skin('markdown'); } } diff --git a/lib/files.php b/lib/files.php new file mode 100644 index 0000000..543fd24 --- /dev/null +++ b/lib/files.php @@ -0,0 +1,525 @@ +type == FilesItemType::FOLDER; } + public function isFile(): bool { return $this->type == FilesItemType::FILE; } +} + +trait FilesItemSizeTrait { + public int $size; + public function getSize(): ?int { return $this->isFile() ? $this->size : null; } +} + +class CollectionItem implements FilesItemInterface { + + public function __construct( + protected FilesCollection $collection + ) {} + + public function getTitleHtml(): ?string { return null; } + public function getId(): string { return $this->collection->value; } + public function isFolder(): bool { return true; } + public function isFile(): bool { return false; } + public function isAvailable(): bool { return true; } + public function getUrl(): string { + global $config; + switch ($this->collection) { + case FilesCollection::MercureDeFrance: + case FilesCollection::WilliamFriedman: + return '/files/'.$this->collection->value.'/'; + case FilesCollection::Baconiana: + return 'https://'.$config['files_domain'].'/Baconiana/'; + } + } + public function getSize(): ?int { return null; } + public function getTitle(): string { return lang("files_{$this->collection->value}_collection"); } + public function getMeta(?string $hl_matched = null): array { return []; } + public function isTargetBlank(): bool { return $this->collection === FilesCollection::Baconiana; } + public function getSubtitle(): ?string { return null; } +} + +class WFFCollectionItem extends model implements FilesItemInterface { + + const DB_TABLE = 'wff_collection'; + + use FilesItemTypeTrait; + use FilesItemSizeTrait; + + public int $id; + public int $parentId; + public string $title; + public string $documentId; + public string $path; + public int $filesCount; + + public function getTitleHtml(): ?string { return null; } + public function getId(): string { return (string)$this->id; } + public function isAvailable(): bool { return true; } + public function getTitle(): string { return $this->title; } + public function getDocumentId(): string { return $this->isFolder() ? str_replace('_', ' ', basename($this->path)) : $this->documentId; } + public function isTargetBlank(): bool { return $this->isFile(); } + public function getSubtitle(): ?string { return null; } + + public function getUrl(): string { + global $config; + return $this->isFolder() + ? "/files/wff/{$this->id}/" + : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}"; + } + + public function getMeta(?string $hl_matched = null): array { + if ($this->isFolder()) { + if (!$this->parentId) + return []; + return [ + 'items' => [ + hl_matched($this->getDocumentId(), $hl_matched), + lang_num('files_count', $this->filesCount) + ] + ]; + } + return [ + 'inline' => false, + 'items' => [ + hl_matched('Document '.$this->documentId), + sizeString($this->size), + 'PDF' + ] + ]; + } + +} + +class MDFCollectionItem extends model implements FilesItemInterface { + + const DB_TABLE = 'mdf_collection'; + + use FilesItemTypeTrait; + use FilesItemSizeTrait; + + public int $id; + public int $issue; + public string $path; + public string $date; + public int $volume; + public int $pageFrom; + public int $pageTo; + public int $pdfPages; + public int $size; + + public function isAvailable(): bool { return true; } + + public function getTitleHtml(): ?string { return null; } + + public function getTitle(): string { + return "№{$this->issue}, {$this->getHumanFriendlyDate()}"; + } + + protected function getHumanFriendlyDate(): string { + $dt = new DateTime($this->date); + return $dt->format('j M Y'); + } + + public function isTargetBlank(): bool { return true; } + public function getId(): string { return (string)$this->issue; } + public function getUrl(): string { + global $config; + return 'https://'.$config['files_domain'].'/Mercure-de-France-OCR/'.$this->path; + } + + public function getMeta(?string $hl_matched = null): array { + return [ + 'inline' => true, + 'items' => [ + 'Vol. '.$this->getRomanVolume(), + 'pp. '.$this->pageFrom.'-'.$this->pageTo, + sizeString($this->size), + 'PDF' + ] + ]; + } + + public function getRomanVolume(): string { + return _arabic_to_roman($this->volume); + } + + public function getSubtitle(): ?string { + return null; + //return 'Vol. '.$this->getRomanVolume().', pp. '.$this->pageFrom.'-'.$this->pageTo; + } +} + +class BookItem extends model implements FilesItemInterface { + + const DB_TABLE = 'books'; + + public int $id; + public int $parentId; + public string $author; + public string $title; + public int $year; + public int $size; + public FilesItemType $type; + public BookFileType $fileType; + public string $path; + public bool $external; + + use FilesItemSizeTrait; + use FilesItemTypeTrait; + + public function getId(): string { + return $this->id; + } + + public function getUrl(): string { + if ($this->isFolder() && !$this->external) + return '/files/'.$this->id.'/'; + global $config; + $buf = 'https://'.$config['files_domain']; + if (!str_starts_with($this->path, '/')) + $buf .= '/'; + $buf .= $this->path; + return $buf; + } + + public function getTitleHtml(): ?string { + if ($this->isFolder() || !$this->author) + return null; + $buf = ''.htmlescape($this->author).''; + if (!str_ends_with($this->author, '.')) + $buf .= '.'; + $buf .= ' '.htmlescape($this->title); + return $buf; + } + + public function getTitle(): string { + return $this->title; + } + + public function getMeta(?string $hl_matched = null): array { + if ($this->isFolder()) + return []; + + $items = [ + sizeString($this->size), + strtoupper($this->getExtension()) + ]; + + return [ + 'inline' => false, + 'items' => $items + ]; + } + + protected function getExtension(): string { + return extension(basename($this->path)); + } + + public function isAvailable(): bool { + return true; + } + + public function isTargetBlank(): bool { + return $this->isFile() || $this->external; + } + + public function getSubtitle(): ?string { + if (!$this->year) + return null; + return '('.$this->year.')'; + } +} + +/** + * @param int $folder_id + * @param bool $with_parents + * @return WFFCollectionItem|WFFCollectionItem[]|null + */ +function wff_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null { + $db = DB(); + $q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id); + if (!$db->numRows($q)) + return null; + $item = new WFFCollectionItem($db->fetch($q)); + if (!$item->isFolder()) + return null; + if ($with_parents) { + $items = [$item]; + if ($item->parentId) { + $parents = wff_get_folder($item->parentId, true); + if ($parents !== null) + $items = array_merge($items, $parents); + } + return $items; + } + return $item; +} + +/** + * @param int|int[]|null $parent_id + * @return array + */ +function wff_get(int|array|null $parent_id = null) { + $db = DB(); + + $where = []; + $args = []; + + if (!is_null($parent_id)) { + if (is_int($parent_id)) { + $where[] = "parent_id=?"; + $args[] = $parent_id; + } else { + $where[] = "parent_id IN (".implode(", ", $parent_id).")"; + } + } + $sql = "SELECT * FROM wff_collection"; + if (!empty($where)) + $sql .= " WHERE ".implode(" AND ", $where); + $sql .= " ORDER BY title"; + $q = $db->query($sql, ...$args); + + return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q)); +} + +/** + * @param int[] $ids + * @return WFFCollectionItem[] + */ +function wff_get_by_id(array $ids): array { + $db = DB(); + $q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")"); + return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q)); +} + +function wff_search(string $q, int $offset = 0, int $count = 0): array { + $query_filtered = sphinx_mkquery($q, [ + 'star' => false, + ]); + + $cl = sphinx_client(); + $cl->setLimits($offset, $count); + $cl->setMatchMode(Sphinx\SphinxClient::SPH_MATCH_EXTENDED); + $cl->setFieldWeights([ + 'title' => 50, + 'document_id' => 60, + ]); + + $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); + $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC'); + + // run search + $final_query = "$query_filtered"; + $result = $cl->query($final_query, WFF_ARCHIVE_SPHINX_RTINDEX); + $error = $cl->getLastError(); + $warning = $cl->getLastWarning(); + if ($error) + logError(__FUNCTION__, $error); + if ($warning) + logWarning(__FUNCTION__, $warning); + if ($result === false) + return ['count' => 0, 'items' => []]; + + $total_found = (int)$result['total_found']; + + $items = []; + if (!empty($result['matches'])) + $items = wff_get_by_id(array_keys($result['matches'])); + + return ['count' => $total_found, 'items' => $items]; +} + +function wff_reindex(): void { + sphinx_execute("TRUNCATE RTINDEX ".WFF_ARCHIVE_SPHINX_RTINDEX); + $db = DB(); + $q = $db->query("SELECT * FROM wff_collection"); + while ($row = $db->fetch($q)) { + $item = new WFFCollectionItem($row); + if ($item->isFile()) { + $txt = file_get_contents('/home/user/nsa/txt/'.str_replace('.pdf', '.txt', basename($item->path))); + } else { + $txt = ''; + } + sphinx_execute("INSERT INTO ".WFF_ARCHIVE_SPHINX_RTINDEX." (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)", + $item->id, $item->getDocumentId(), $item->title, $txt, (int)$item->isFolder(), $item->parentId); + } +} + +/** + * @param int[] $ids + * @param string[] $keywords Must already be lower-cased + * @param int $before + * @param int $after + * @return array + */ +function wff_get_text_excerpts(array $ids, + array $keywords, + int $before = 50, + int $after = 40): array { + $results = []; + foreach ($ids as $id) + $results[$id] = null; + + $db = DB(); + + $dynamic_sql_parts = []; + $combined_parts = []; + foreach ($keywords as $keyword) { + $part = "LOCATE('".$db->escape($keyword)."', LOWER(text))"; + $dynamic_sql_parts[] = $part; + } + if (count($dynamic_sql_parts) > 1) { + foreach ($dynamic_sql_parts as $part) + $combined_parts[] = "IF({$part} > 0, {$part}, CHAR_LENGTH(text) + 1)"; + $combined_parts = implode(', ', $combined_parts); + $combined_parts = 'LEAST('.$combined_parts.')'; + } else { + $combined_parts = "IF({$dynamic_sql_parts[0]} > 0, {$dynamic_sql_parts[0]}, CHAR_LENGTH(text) + 1)"; + } + + $total = $before + $after; + $sql = "SELECT + wff_id AS id, + GREATEST( + 1, + {$combined_parts} - {$before} + ) AS excerpt_start_index, + SUBSTRING( + text, + GREATEST( + 1, + {$combined_parts} - {$before} + ), + LEAST( + CHAR_LENGTH(text), + {$total} + {$combined_parts} - GREATEST(1, {$combined_parts} - {$before}) + ) + ) AS excerpt + FROM + wff_texts + WHERE + wff_id IN (".implode(',', $ids).")"; + + $q = $db->query($sql); + while ($row = $db->fetch($q)) { + $results[$row['id']] = [ + 'excerpt' => preg_replace('/\s+/', ' ', $row['excerpt']), + 'index' => (int)$row['excerpt_start_index'] + ]; + } + + return $results; +} + +/** + * @return MDFCollectionItem[] + */ +function mdf_get(): array { + $db = DB(); + $q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`"); + return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q)); +} + +/** + * @return BookItem[] + */ +function books_get(int $parent_id = 0, + BookCategory $category = BookCategory::BOOKS): array { + $db = DB(); + + if ($category == BookCategory::BOOKS) { + $order_by = "type, ".($parent_id != 0 ? 'year, ': '')."author, title"; + } + else + $order_by = "type, title"; + + $q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by", + $category->value, $parent_id); + return array_map('BookItem::create_instance', $db->fetchAll($q)); +} + +function books_get_folder(int $id): ?BookItem { + $db = DB(); + $q = $db->query("SELECT * FROM books WHERE id=?", $id); + if (!$db->numRows($q)) + return null; + $item = new BookItem($db->fetch($q)); + if (!$item->isFolder()) + return null; + return $item; +} + +function _arabic_to_roman($number) { + $map = [ + 1000 => 'M', + 900 => 'CM', + 500 => 'D', + 400 => 'CD', + 100 => 'C', + 90 => 'XC', + 50 => 'L', + 40 => 'XL', + 10 => 'X', + 9 => 'IX', + 5 => 'V', + 4 => 'IV', + 1 => 'I', + ]; + $result = ''; + + foreach ($map as $arabic => $roman) { + while ($number >= $arabic) { + $result .= $roman; + $number -= $arabic; + } + } + + return $result; +} diff --git a/routes.php b/routes.php index 24019e6..b1ce388 100644 --- a/routes.php +++ b/routes.php @@ -1,6 +1,11 @@ $fn->value, FilesCollection::cases()); + + $wff = FilesCollection::WilliamFriedman->value; + $routes = [ 'Main' => [ '/' => 'index', @@ -10,6 +15,12 @@ return (function() { 'articles/' => 'articles', 'articles/([a-z0-9-]+)/' => 'post name=$(1)', ], + 'Files' => [ + 'files/' => 'files', + 'files/(\d+)/' => 'folder folder_id=$(1)', + 'files/{'.implode(',', $files_collections).'}/' => 'collection collection=${1}', + 'files/'.$wff.'/(\d+)/' => 'collection collection='.$wff.' folder_id=$(1)', + ], 'Admin' => [ 'admin/' => 'index', 'admin/{login,logout,log}/' => '${1}', @@ -18,7 +29,7 @@ return (function() { 'articles/write/' => 'post_add', 'articles/([a-z0-9-]+)/{delete,edit}/' => 'post_${1} short_name=$(1)', 'admin/markdown-preview.ajax' => 'ajax_md_preview', - 'admin/uploads/' => 'uploads', + 'admin/{uploads}/' => '${1}', 'admin/uploads/{edit_note,delete}/(\d+)/' => 'upload_${1} id=$(1)' ] ]; diff --git a/skin/admin.phps b/skin/admin.phps index 911f67f..54652e5 100644 --- a/skin/admin.phps +++ b/skin/admin.phps @@ -52,7 +52,6 @@ function index($ctx, $admin_login) { Authorized as {$admin_login} | Sign out
Uploads
- HTML; } @@ -166,7 +165,7 @@ if ($is_edit) { $html = << {$ctx->if_true($saved, fn() => '
'.$ctx->lang('info_saved').'
')} -{$ctx->bc($bc_tree, 'padding-bottom: 20px')} +{$ctx->bc($bc_tree, 'padding-bottom: 12px')}
@@ -280,7 +279,7 @@ $form_url = '/'.$short_name.'/'.($is_edit ? 'edit' : 'create').'/'; if ($is_edit) { $bc_html = $ctx->bc([ ['url' => '/'.$short_name.'/', 'text' => $ctx->lang('view_page')] - ], 'padding-bottom: 20px'); + ], 'padding-bottom: 12px'); } else { $bc_html = ''; } @@ -408,5 +407,15 @@ return <<{$unsafe_html} HTML; +} + +function books($ctx) { +return <<bc([ + ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'], + ['text' => $ctx->lang('admin_books')], +])} + +HTML; } \ No newline at end of file diff --git a/skin/base.phps b/skin/base.phps index 217e032..03f68b5 100644 --- a/skin/base.phps +++ b/skin/base.phps @@ -5,7 +5,7 @@ namespace skin\base; use SkinContext; use Stringable; -function layout($ctx, $title, $unsafe_body, $static, $meta, $js, $opts, $unsafe_lang, $theme, $exec_time, $admin_email) { +function layout($ctx, $title, $unsafe_body, $static, $meta, $js, $opts, $unsafe_lang, $theme, $exec_time, $admin_email, $svg_defs) { global $config; $app_config = jsonEncode([ 'domain' => $config['domain'], @@ -33,6 +33,7 @@ return <<renderStatic($static, $theme)} if_true($body_class, ' class="'.implode(' ', $body_class).'"')}> + {$ctx->if_true($svg_defs, fn() => $ctx->renderSVGIcons($svg_defs))}
{$ctx->renderHeader($theme, $opts['head_section'], $opts['articles_lang'], $opts['is_index'])}
{$unsafe_body}
@@ -61,6 +62,19 @@ return << $icon) { + $buf .= << + {$icon['svg']} + +SVG; + } + $buf .= ''; + return $buf; +} + function renderScript($ctx, $unsafe_js, $unsafe_lang) { global $config; @@ -190,16 +204,23 @@ function renderHeader(SkinContext $ctx, ?string $section, ?string $articles_lang, bool $show_subtitle): string { +$icons = svg(); $items = []; -if (is_admin()) +if (is_admin()) { $items[] = ['url' => '/articles/'.($articles_lang ? '?lang='.$articles_lang : ''), 'label' => 'articles', 'selected' => $section === 'articles']; -array_push($items, - ['url' => 'https://files.4in1.ws', 'label' => 'files', 'selected' => $section === 'files'], - ['url' => '/info/', 'label' => 'about', 'selected' => $section === 'about'] -); +} +$items[] = ['url' => '/files/', 'label' => 'files', 'selected' => $section === 'files']; +$items[] = ['url' => '/info/', 'label' => 'about', 'selected' => $section === 'about']; if (is_admin()) - $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' => '/admin/', 'label' => $icons->settings_28(in_place: true), 'type' => 'settings', 'selected' => $section === 'admin']; +$items[] = [ + 'url' => 'javascript:void(0)', + 'label' => $icons->moon_auto_18(in_place: true) + .$icons->moon_light_18(in_place: true) + .$icons->moon_dark_18(in_place: true), + 'type' => 'theme-switcher', + 'type_opts' => $theme +]; // here, items are rendered using for_each, so that there are no gaps (whitespaces) between tags @@ -257,32 +278,6 @@ return << - - - - - - - - - -SVG; - -} - -function renderSettingsIcon(SkinContext $ctx): string { -return << - - -SVG; - -} - function renderFooter($ctx, $admin_email): string { return << diff --git a/skin/files.phps b/skin/files.phps new file mode 100644 index 0000000..0c0b618 --- /dev/null +++ b/skin/files.phps @@ -0,0 +1,249 @@ +bc([ + ['text' => $ctx->lang('files_archives')] +])} + +
+ {$ctx->for_each($collections, + fn(FilesItemInterface $file) => $ctx->file( + file: $file, + disabled: !$file->isAvailable()))} +
+ +{$ctx->bc([ + ['text' => $ctx->lang('files_books')] +], mt: true)} + +
+ {$ctx->for_each($books, fn(FilesItemInterface $file) => $ctx->file(file: $file))} +
+ +{$ctx->bc([ + ['text' => $ctx->lang('files_misc')] +], mt: true)} + +
+ {$ctx->for_each($misc, fn(FilesItemInterface $file) => $ctx->file(file: $file))} +
+HTML; +} + +function folder($ctx, BookItem $folder, array $files) { +$svg = svg(); +$svg->folder_20(preload_symbol: true); +$svg->file_20(preload_symbol: true); + +return <<bc([ + ['text' => $ctx->lang('files'), 'url' => '/files/'], + ['text' => $folder->title] +])} +
+
+ {$ctx->collection_files($files)} +
+
+HTML; + +} + +function collection(SkinContext $ctx, + FilesCollection $collection, + array $files, + ?array $parents, + int $search_results_per_page, + int $search_min_query_length, + ?string $search_query = null, + ?int $search_count = null, + ?array $text_excerpts = null) { + $widgets = skin('widgets'); + + $svg = svg(); + $svg->folder_20(preload_symbol: true); + $svg->file_20(preload_symbol: true); + + $bc = [ + ['text' => $ctx->lang('files'), 'url' => '/files/'], + ]; + if ($parents) { + $bc[] = ['text' => $ctx->lang('files_'.$collection->value.'_collection_short'), 'url' => "/files/{$collection->value}/"]; + for ($i = 0; $i < count($parents); $i++) { + $parent = $parents[$i]; + $bc_item = ['text' => $parent->getTitle()]; + if ($i < count($parents)-1) + $bc_item['url'] = $parent->getUrl(); + $bc[] = $bc_item; + } + } else { + $bc[] = ['text' => $ctx->lang('files_'.$collection->value.'_collection')]; + } + + $do_show_search = empty($parents) && $collection->isSearchSupported(); + $do_show_more = $search_count > 0 && count($files) < $search_count; + + $html = <<bc($bc)} +{$ctx->if_true($do_show_search, fn() => $ctx->collection_search($search_count, $search_query))} + +
+
+ {$ctx->collection_files($files, $search_query, $text_excerpts)} +
+
if_not($do_show_more, ' style="display: none"')}> + {$ctx->lang('files_show_more')} + {$widgets->spinner('files_show_more_spinner')} +
+
+ +HTML; + + if ($do_show_search) { + $opts = [ + 'container' => 'files_list', + 'per_page' => $search_results_per_page, + 'min_query_length' => $search_min_query_length, + 'base_url' => "/files/{$collection->value}/", + 'query' => $search_query, + 'count' => $search_count, + 'collection_name' => $collection->value, + 'inited_with_search' => !!$search_query + ]; + $opts = jsonEncode($opts); + + $js = <<for_each($files, fn(FilesItemInterface $f) => $ctx->file( + file: $f, + unsafe_query: $search_query, + text_excerpts: $text_excerpts)); +} + +function collection_search(SkinContext $ctx, $count, $query) { + $icons = svg(); + $widgets = skin('widgets'); + $clear_dsp = $query ? 'block' : 'none'; + + return << + + +
+
+
{$widgets->spinner()}
+ {$ctx->if_then_else($query, fn() => $ctx->lang_num('files_search_results_count', $count), ' ')} +
+
+
+HTML; +} + + +function file(SkinContext $ctx, + FilesItemInterface $file, + ?SkinString $unsafe_query = null, + bool $disabled = false, + ?array $text_excerpts = null,) { +$icons = svg(); +if ($file instanceof BookItem && $file->fileType == BookFileType::BOOK) + $icon = $icons->book_20(); +else + $icon = $file->isFile() ? $icons->file_20() : $icons->folder_20(); + +$class = 'files-list-item clearfix'; +if ($disabled) + $class .= ' is-disabled'; + +$mapper = function($s) use ($unsafe_query) { + if ($unsafe_query !== null) { + return hl_matched($s, [$unsafe_query]); + } else { + return htmlescape($s); + } +}; + +$title = $file->getTitleHtml(); +if ($title === null) { + // we don't apply $mapper to already htmlescaped string + $title = $mapper($file->getTitle()); +} + +$meta = $file->getMeta($unsafe_query); +$meta_is_inline = $meta['inline'] ?? false; +$meta_items = $meta['items'] ?? []; +$url = htmlescape($file->getUrl()); + +$subtitle = $file->getSubtitle(); + +return <<if_true($file->isTargetBlank(), ' target="_blank"')}> +
{$icon}
+
+
+ {$title} + {$ctx->if_true($file->isFolder() && $file->isTargetBlank(), fn() => ''.$icons->arrow_up_right_out_square_outline_12().'')} + {$ctx->if_true($subtitle, fn() => ''.htmlescape($subtitle).'')} + {$ctx->if_true($meta_is_inline, $ctx->for_each($meta_items, fn($s) => '
'.$s.'
'))} +
+ {$ctx->if_true($meta_items && !$meta_is_inline, $ctx->meta($meta_items))} + {$ctx->if_true(is_array($text_excerpts) && isset($text_excerpts[$file->getId()]), + fn() => $ctx->text_excerpt($text_excerpts[$file->getId()]['excerpt'], $text_excerpts[$file->getId()]['index'], $unsafe_query))} +
+ +HTML; +} + +/** + * @param SkinContext $ctx + * @param string[] $meta strings are already html-safe + * @return string + */ +function meta($ctx, array $meta) { +return << + {$ctx->for_each($meta, fn($s) => '
'.$s.'
')} + +HTML; +} + + +function text_excerpt($ctx, $unsafe_excerpt, $index, $unsafe_query) { + if ($index > 0) + $unsafe_excerpt = '...'.$unsafe_excerpt; + $unsafe_excerpt .= '...'; + $text = hl_matched($unsafe_excerpt, $unsafe_query); + return <<{$text} +HTML; +} \ No newline at end of file diff --git a/skin/icons.phps b/skin/icons.phps new file mode 100644 index 0000000..abc2e48 --- /dev/null +++ b/skin/icons.phps @@ -0,0 +1,71 @@ + +SVG; +} + +function settings_28($ctx) { +return << +SVG; +} + +function moon_auto_18($ctx) { +return << + +SVG; +} + +function moon_light_18($ctx) { +return << +SVG; +} + +function moon_dark_18($ctx) { +return << +SVG; +} + +function search_20($ctx) { +return << +SVG; +} + +function file_20($ctx) { +return << +SVG; +} + +function book_20($ctx) { +return << +SVG; +} + +function clear_20($ctx) { +return << +SVG; +} + +function clear_16($ctx) { +return << +SVG; +} + +function arrow_up_right_out_square_outline_12($ctx) { +return << +SVG; + +} \ No newline at end of file diff --git a/skin/widgets.phps b/skin/widgets.phps new file mode 100644 index 0000000..3acb6ae --- /dev/null +++ b/skin/widgets.phps @@ -0,0 +1,14 @@ +if_true($id, ' id="'.$id.'"')}> + + + + +HTML; +} \ No newline at end of file diff --git a/strings/main.yaml b/strings/main.yaml index d0fb348..21d5952 100644 --- a/strings/main.yaml +++ b/strings/main.yaml @@ -88,7 +88,36 @@ err_pages_no_id: 'ID not specified' err_pages_no_short_name: 'Short name not specified' err_pages_db_err: 'Database error' -# admin-switch -admin_title: "Admin" -admin_password: 'Password' -admin_login: "Login" +# /admin +admin_title: Admin +admin_password: Password +admin_login: Login +admin_books: Books + +# /files +files: Files +files_collections: Collections +files_archives: Archives +files_books: Books +files_misc: Miscellaneous +files_count: + - "%s file" + - "%s files" + - "%s files" + - "No files" + +files_wff_collection: William F. Friedman NSA Archive +files_mdf_collection: Mercure de France (1920-1940) +files_baconiana_collection: Baconiana + +files_wff_collection_short: W.F.F. Archive +files_mdf_collection_short: MdF +files_baconiana_collection_short: Baconiana + +files_search_ph: Document number, title or text +files_search_results_count: + - "%s result" + - "%s results" + - "%s results" + - "No results" +files_show_more: Show more \ No newline at end of file