articles and books interface, search in wff archive

This commit is contained in:
E. S. 2024-03-09 13:26:31 +00:00
parent dca4a9f9fc
commit fed70b8045
46 changed files with 2621 additions and 403 deletions

View File

@ -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": "*",

60
composer.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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 {

View File

@ -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('/(?<!^)([A-Z])/', ' $1', $http_code->name);
$html = $ctx->http_error($http_code->value, $http_message, $message);
http_response_code($http_code->value);

View File

@ -1,6 +1,6 @@
<?php
const ROUTER_VERSION = 2;
const ROUTER_VERSION = 3;
const ROUTER_MC_KEY = '4in1/routes';
$RouterInput = [];

View File

@ -9,7 +9,6 @@ $SkinState = new class {
public array $options = [
'full_width' => 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 = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.47 4.217a.75.75 0 0 0 0 1.06L12.185 10 7.469 14.72a.75.75 0 1 0 1.062 1.06l5.245-5.25a.75.75 0 0 0 0-1.061L8.531 4.218a.75.75 0 0 0-1.061-.001z" fill="currentColor"/></svg>';
$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 .= ' <span class="bc-arrow">&rsaquo;</span></a>';
$buf .= ' <span class="bc-arrow">'.$chevron.'</span></a>';
else
$buf .= '</span>';
return $buf;
}, $items));
return '<div class="bc"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
$class = 'bc';
if ($mt)
$class .= ' mt';
return '<div class="'.$class.'"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
}
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 <<<SVG
<svg id="svgicon_{$name}" width="{$width}" height="{$height}" fill="currentColor" viewBox="0 0 {$width} {$height}">{$content}</svg>
SVG;
} else {
return <<<SVG
<svg width="{$width}" height="{$height}"><use xlink:href="#svgicon_{$name}"></use></svg>
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;

104
engine/sphinx.php Normal file
View File

@ -0,0 +1,104 @@
<?php
function sphinx_execute(string $sql) {
$link = _sphinxql_link();
if (func_num_args() > 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;
}

View File

@ -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 .= '<span class="matched">'.htmlescape(mb_substr($s, $start_pos, $len, $charset)).'</span>';
$last_index = $start_pos + $len;
}
if ($last_index < $s_len) {
$buf .= htmlescape(mb_substr($s, $last_index, $s_len - $last_index, $charset));
}
return $buf;
}

View File

@ -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) {

133
handler/FilesHandler.php Normal file
View File

@ -0,0 +1,133 @@
<?php
require_once 'lib/files.php';
class FilesHandler extends request_handler {
const SEARCH_RESULTS_PER_PAGE = 50;
const SEARCH_MIN_QUERY_LENGTH = 3;
function GET_files() {
set_title('$files');
set_skin_opts(['head_section' => '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);
}
}

View File

@ -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'],

View File

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

View File

@ -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) }
});

View File

@ -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);
})

View File

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

View File

@ -45,3 +45,28 @@ if (!Object.assign) {
}
});
}
(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));

View File

@ -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;
}

View File

@ -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();
})();

View File

@ -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);

View File

@ -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) {
@ -87,3 +92,48 @@ 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];
});
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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 = {};

View File

@ -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;
}

View File

@ -1,4 +1,4 @@
var IndexPage = {
window.IndexPage = {
offsets: {
en: 0,
ru: 300

View File

@ -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);
}
});

View File

@ -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;

View File

@ -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;
}
}
}

196
htdocs/scss/app/files.scss Normal file
View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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";

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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');

View File

@ -214,7 +214,7 @@ class MyParsedown extends ParsedownExtended {
}
protected static function getSkinContext(): SkinContext {
return new SkinContext('\\skin\\markdown');
return skin('markdown');
}
}

525
lib/files.php Normal file
View File

@ -0,0 +1,525 @@
<?php
require_once 'engine/sphinx.php';
const WFF_ARCHIVE_SPHINX_RTINDEX = 'wff_collection';
//const MDF_ARCHIVE_SPHINX/**/_RTINDEX = 'mdf_archive';
//const BACONIANA_ARCHIVE_SPHINX_RTINDEX = 'baconiana_archive';
enum FilesCollection: string {
case WilliamFriedman = 'wff';
case MercureDeFrance = 'mdf';
case Baconiana = 'baconiana';
public function isSearchSupported(): bool {
return $this == FilesCollection::WilliamFriedman;
}
}
enum FilesItemType: string {
case FILE = 'file';
case FOLDER = 'folder';
}
enum BookFileType: string {
case NONE = 'none';
case BOOK = 'book';
case ARTICLE = 'article';
}
enum BookCategory: string {
case BOOKS = 'books';
case MISC = 'misc';
}
interface FilesItemInterface {
public function getId(): string;
public function isFolder(): bool;
public function isFile(): bool;
public function getUrl(): string;
public function getSize(): ?int;
public function getTitle(): string;
public function getTitleHtml(): ?string;
public function getMeta(?string $hl_matched = null): array;
public function isAvailable(): bool;
public function isTargetBlank(): bool;
public function getSubtitle(): ?string;
}
trait FilesItemTypeTrait {
public FilesItemType $type;
public function isFolder(): bool { return $this->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 = '<b>'.htmlescape($this->author).'</b>';
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;
}

View File

@ -1,6 +1,11 @@
<?php
return (function() {
require_once 'lib/files.php';
$files_collections = array_map(fn(FilesCollection $fn) => $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)'
]
];

View File

@ -52,7 +52,6 @@ function index($ctx, $admin_login) {
Authorized as <b>{$admin_login}</b> | <a href="/admin/logout/?token={$ctx->csrf('logout')}">Sign out</a><br>
<!--<a href="/admin/log/">Log</a><br/>-->
<a href="/admin/uploads/">Uploads</a><br>
</div>
HTML;
}
@ -166,7 +165,7 @@ if ($is_edit) {
$html = <<<HTML
<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->bc($bc_tree, 'padding-bottom: 20px')}
{$ctx->bc($bc_tree, 'padding-bottom: 12px')}
<table cellpadding="0" cellspacing="0" class="blog-write-table">
<tr>
<td id="form_first_cell">
@ -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 <<<HTML
<div class="blog-post-text">{$unsafe_html}</div>
</div>
HTML;
}
function books($ctx) {
return <<<HTML
{$ctx->bc([
['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
['text' => $ctx->lang('admin_books')],
])}
HTML;
}

View File

@ -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 <<<HTML
{$ctx->renderStatic($static, $theme)}
</head>
<body{$ctx->if_true($body_class, ' class="'.implode(' ', $body_class).'"')}>
{$ctx->if_true($svg_defs, fn() => $ctx->renderSVGIcons($svg_defs))}
<div class="page-content base-width">
{$ctx->renderHeader($theme, $opts['head_section'], $opts['articles_lang'], $opts['is_index'])}
<div class="page-content-inner">{$unsafe_body}</div>
@ -61,6 +62,19 @@ return <<<HTML
HTML;
}
function renderSVGIcons($ctx, $svg_defs) {
$buf = '<svg style="display: none">';
foreach ($svg_defs as $name => $icon) {
$buf .= <<<SVG
<symbol id="svgicon_{$name}" viewBox="0 0 {$icon['width']} {$icon['height']}" fill="currentColor">
{$icon['svg']}
</symbol>
SVG;
}
$buf .= '</svg>';
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 <<<HTML
HTML;
}
function renderMoonIcons(SkinContext $ctx): string {
return <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" id="moon_auto" display="none">
<path fill-rule="evenodd" d="M14.54 10.37c-4.3 1.548-8.458-2.61-6.91-6.91a.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54c-3.028 2.985-2.485 8.012 1.111 10.282 3.596 2.269 8.368.596 9.759-3.422a.59.59 0 0 0-.75-.74Z" />
<path d="M13.502 6.513V5.194h-1.389q-.802 0-1.195.346-.392.346-.392 1.06 0 .651.398 1.032.398.38 1.078.38.674 0 1.084-.415.416-.416.416-1.084zm1.078-1.934v3.27h.961v.62h-2.039v-.673q-.357.433-.826.639-.469.205-1.096.205-1.037 0-1.646-.551-.61-.55-.61-1.488 0-.967.698-1.5.697-.534 1.968-.534h1.512V4.14q0-.71-.433-1.096-.428-.393-1.207-.393-.645 0-1.026.293-.38.293-.474.868h-.557v-1.26q.562-.24 1.09-.358.533-.123 1.037-.123 1.295 0 1.969.645.68.638.68 1.863z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" id="moon_light" display="none">
<path fill-rule="evenodd" d="M14.54 10.37a5.4 5.4 0 0 1-6.91-6.91.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54 6.6 6.6 0 1 0 10.87 6.86.59.59 0 0 0-.75-.74zm-1.61 2.39a5.44 5.44 0 0 1-7.69-7.69 5.58 5.58 0 0 1 1-.76 6.55 6.55 0 0 0 7.47 7.47 5.15 5.15 0 0 1-.78.98z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" id="moon_dark" display="none">
<path fill-rule="evenodd" d="M14.54 10.37c-4.3 1.548-8.458-2.61-6.91-6.91a.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54c-3.028 2.985-2.485 8.012 1.111 10.282 3.596 2.269 8.368.596 9.759-3.422a.59.59 0 0 0-.75-.74Z"/>
</svg>
SVG;
}
function renderSettingsIcon(SkinContext $ctx): string {
return <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
<path d="M10.648 5.894c1.465-.84 1.719-1.714 1.894-2.588.194-.973.486-.973.972-.973h.972c.486 0 .68 0 .972.973.263.876.447 1.752 1.903 2.592 1.63.443 2.428.003 3.17-.491.825-.55 1.031-.344 1.375 0l.687.687c.344.344.482.481 0 1.375-.433.805-.923 1.555-.487 3.179.84 1.465 1.714 1.719 2.588 1.894.973.194.973.486.973.972v.972c0 .486 0 .68-.973.972-.876.263-1.752.447-2.592 1.903-.443 1.63-.003 2.428.491 3.17.55.825.344 1.031 0 1.375l-.687.687c-.344.344-.481.482-1.375 0-.805-.433-1.555-.923-3.179-.487-1.465.84-1.719 1.714-1.894 2.588-.194.973-.486.973-.972.973h-.972c-.486 0-.68 0-.972-.973-.263-.876-.447-1.752-1.903-2.592-1.63-.443-2.428-.003-3.17.491-.825.55-1.031.344-1.375 0l-.687-.687c-.344-.344-.482-.481 0-1.375.433-.805.923-1.555.487-3.179-.84-1.465-1.714-1.719-2.588-1.894-.973-.194-.973-.486-.973-.972v-.972c0-.486 0-.68.973-.972.876-.263 1.752-.447 2.592-1.903.443-1.63.003-2.428-.491-3.17-.55-.825-.344-1.031 0-1.375l.687-.687c.344-.344.481-.482 1.375 0 .805.433 1.555.923 3.179.487ZM14 19.502a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Z"/>
</svg>
SVG;
}
function renderFooter($ctx, $admin_email): string {
return <<<HTML
<div class="footer">

249
skin/files.phps Normal file
View File

@ -0,0 +1,249 @@
<?php
namespace skin\files;
use BookFileType;
use BookItem;
use FilesCollection;
use FilesItemInterface;
use SkinContext;
use SkinString;
use function svg;
function index($ctx,
array $collections,
array $books,
array $misc) {
return <<<HTML
{$ctx->bc([
['text' => $ctx->lang('files_archives')]
])}
<div class="files-list">
{$ctx->for_each($collections,
fn(FilesItemInterface $file) => $ctx->file(
file: $file,
disabled: !$file->isAvailable()))}
</div>
{$ctx->bc([
['text' => $ctx->lang('files_books')]
], mt: true)}
<div class="files-list">
{$ctx->for_each($books, fn(FilesItemInterface $file) => $ctx->file(file: $file))}
</div>
{$ctx->bc([
['text' => $ctx->lang('files_misc')]
], mt: true)}
<div class="files-list">
{$ctx->for_each($misc, fn(FilesItemInterface $file) => $ctx->file(file: $file))}
</div>
HTML;
}
function folder($ctx, BookItem $folder, array $files) {
$svg = svg();
$svg->folder_20(preload_symbol: true);
$svg->file_20(preload_symbol: true);
return <<<HTML
{$ctx->bc([
['text' => $ctx->lang('files'), 'url' => '/files/'],
['text' => $folder->title]
])}
<div class="files-list">
<div id="files_list">
{$ctx->collection_files($files)}
</div>
</div>
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 = <<<HTML
{$ctx->bc($bc)}
{$ctx->if_true($do_show_search, fn() => $ctx->collection_search($search_count, $search_query))}
<div class="files-list">
<div id="files_list">
{$ctx->collection_files($files, $search_query, $text_excerpts)}
</div>
<div class="files-list-show-more no-select" id="files_show_more"{$ctx->if_not($do_show_more, ' style="display: none"')}>
<span class="files-list-show-more-label">{$ctx->lang('files_show_more')}</span>
{$widgets->spinner('files_show_more_spinner')}
</div>
</div>
<div id="files_list_hidden" style="display: none"></div>
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 = <<<JAVASCRIPT
cur.search = new FileSearch({$opts});
JAVASCRIPT;
return [$html, $js];
} else {
return $html;
}
}
function collection_files($ctx,
array $files,
?string $search_query = null,
?array $text_excerpts = null) {
return $ctx->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 <<<HTML
<div class="files-search-wrap">
<div class="files-search" id="files_search">
<div class="files-search-icon">{$icons->search_20()}</div>
<input type="text" value="{$query}" placeholder="{$ctx->lang('files_search_ph')}" id="files_search_input">
<div class="files-search-clear-icon" id="files_search_clear_icon" style="display: {$clear_dsp}">{$icons->clear_16()}</div>
</div>
<div class="files-search-results-info" id="files_search_info" style="display: {$clear_dsp}">
<div class="files-search-results-info-inner">
<div class="files-search-results-info-spinner">{$widgets->spinner()}</div>
<span id="files_search_info_text">{$ctx->if_then_else($query, fn() => $ctx->lang_num('files_search_results_count', $count), '&nbsp;')}
</div>
</div>
</div>
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 <<<HTML
<a href="{$url}" class="{$class}" data-id="{$file->getId()}"{$ctx->if_true($file->isTargetBlank(), ' target="_blank"')}>
<div class="files-list-item-icon">{$icon}</div>
<div class="files-list-item-info">
<div class="files-list-item-title">
<span class="files-list-item-title-label">{$title}</span>
{$ctx->if_true($file->isFolder() && $file->isTargetBlank(), fn() => '<span class="files-list-item-title-label-external-icon">'.$icons->arrow_up_right_out_square_outline_12().'</span>')}
{$ctx->if_true($subtitle, fn() => '<span class="files-list-item-subtitle">'.htmlescape($subtitle).'</span>')}
{$ctx->if_true($meta_is_inline, $ctx->for_each($meta_items, fn($s) => '<div class="files-list-item-meta-item">'.$s.'</div>'))}
</div>
{$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))}
</div>
</a>
HTML;
}
/**
* @param SkinContext $ctx
* @param string[] $meta strings are already html-safe
* @return string
*/
function meta($ctx, array $meta) {
return <<<HTML
<div class="files-list-item-meta">
{$ctx->for_each($meta, fn($s) => '<div class="files-list-item-meta-item">'.$s.'</div>')}
</div>
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 <<<HTML
<div class="files-list-item-text-excerpt">{$text}</div>
HTML;
}

71
skin/icons.phps Normal file
View File

@ -0,0 +1,71 @@
<?php
namespace skin\icons;
function folder_20($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M7.066 2.058C6.812 2 6.552 2 6.256 2h-.09c-.65 0-1.18 0-1.612.034-.446.035-.85.11-1.23.295A3.25 3.25 0 0 0 1.83 3.824c-.185.38-.26.784-.295 1.23-.034.432-.034.962-.034 1.611v5.616c0 .674 0 1.224.037 1.672.037.463.118.882.317 1.272a3.25 3.25 0 0 0 1.42 1.42c.391.2.81.28 1.273.318.447.037.998.037 1.671.037h7.563c.674 0 1.224 0 1.672-.037.463-.038.882-.118 1.272-.317a3.25 3.25 0 0 0 1.42-1.42c.2-.391.28-.81.318-1.273.037-.448.037-.998.037-1.672V8.85c0-.673 0-1.224-.037-1.672-.037-.463-.118-.881-.317-1.272a3.25 3.25 0 0 0-1.42-1.42c-.391-.2-.81-.28-1.273-.318-.447-.037-.998-.037-1.672-.037h-3.486c-.404 0-.484-.004-.55-.02a.75.75 0 0 1-.218-.091c-.059-.036-.118-.09-.403-.377l-.758-.764c-.208-.21-.391-.395-.611-.534a2.25 2.25 0 0 0-.69-.287ZM6.196 3.5c.39 0 .466.004.53.02a.75.75 0 0 1 .23.095c.056.035.113.086.388.363l.759.766c.216.218.406.41.636.552.202.125.423.217.653.273.263.063.534.063.84.063h3.518c.712 0 1.202 0 1.58.031.371.03.57.086.714.16.33.167.598.435.765.764.074.144.13.343.16.714.005.063.009.129.012.199H3v-.805c0-.686 0-1.157.03-1.523.027-.357.08-.55.147-.69a1.75 1.75 0 0 1 .805-.805c.14-.068.333-.12.69-.148.366-.029.837-.03 1.523-.03ZM3 9v3.25c0 .712 0 1.202.032 1.58.03.371.085.57.159.714.167.33.435.597.764.765.145.074.344.129.714.16.38.03.869.03 1.581.03h7.5c.712 0 1.202 0 1.58-.03.371-.031.57-.086.714-.16a1.75 1.75 0 0 0 .765-.765c.074-.144.13-.343.16-.713.03-.38.031-.869.031-1.581V9H3Z" clip-rule="evenodd"/>
SVG;
}
function settings_28($ctx) {
return <<<SVG
<path d="M10.648 5.894c1.465-.84 1.719-1.714 1.894-2.588.194-.973.486-.973.972-.973h.972c.486 0 .68 0 .972.973.263.876.447 1.752 1.903 2.592 1.63.443 2.428.003 3.17-.491.825-.55 1.031-.344 1.375 0l.687.687c.344.344.482.481 0 1.375-.433.805-.923 1.555-.487 3.179.84 1.465 1.714 1.719 2.588 1.894.973.194.973.486.973.972v.972c0 .486 0 .68-.973.972-.876.263-1.752.447-2.592 1.903-.443 1.63-.003 2.428.491 3.17.55.825.344 1.031 0 1.375l-.687.687c-.344.344-.481.482-1.375 0-.805-.433-1.555-.923-3.179-.487-1.465.84-1.719 1.714-1.894 2.588-.194.973-.486.973-.972.973h-.972c-.486 0-.68 0-.972-.973-.263-.876-.447-1.752-1.903-2.592-1.63-.443-2.428-.003-3.17.491-.825.55-1.031.344-1.375 0l-.687-.687c-.344-.344-.482-.481 0-1.375.433-.805.923-1.555.487-3.179-.84-1.465-1.714-1.719-2.588-1.894-.973-.194-.973-.486-.973-.972v-.972c0-.486 0-.68.973-.972.876-.263 1.752-.447 2.592-1.903.443-1.63.003-2.428-.491-3.17-.55-.825-.344-1.031 0-1.375l.687-.687c.344-.344.481-.482 1.375 0 .805.433 1.555.923 3.179.487ZM14 19.502a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Z"/>
SVG;
}
function moon_auto_18($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M14.54 10.37c-4.3 1.548-8.458-2.61-6.91-6.91a.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54c-3.028 2.985-2.485 8.012 1.111 10.282 3.596 2.269 8.368.596 9.759-3.422a.59.59 0 0 0-.75-.74Z" />
<path d="M13.502 6.513V5.194h-1.389q-.802 0-1.195.346-.392.346-.392 1.06 0 .651.398 1.032.398.38 1.078.38.674 0 1.084-.415.416-.416.416-1.084zm1.078-1.934v3.27h.961v.62h-2.039v-.673q-.357.433-.826.639-.469.205-1.096.205-1.037 0-1.646-.551-.61-.55-.61-1.488 0-.967.698-1.5.697-.534 1.968-.534h1.512V4.14q0-.71-.433-1.096-.428-.393-1.207-.393-.645 0-1.026.293-.38.293-.474.868h-.557v-1.26q.562-.24 1.09-.358.533-.123 1.037-.123 1.295 0 1.969.645.68.638.68 1.863z" />
SVG;
}
function moon_light_18($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M14.54 10.37a5.4 5.4 0 0 1-6.91-6.91.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54 6.6 6.6 0 1 0 10.87 6.86.59.59 0 0 0-.75-.74zm-1.61 2.39a5.44 5.44 0 0 1-7.69-7.69 5.58 5.58 0 0 1 1-.76 6.55 6.55 0 0 0 7.47 7.47 5.15 5.15 0 0 1-.78.98z"/>
SVG;
}
function moon_dark_18($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M14.54 10.37c-4.3 1.548-8.458-2.61-6.91-6.91a.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54c-3.028 2.985-2.485 8.012 1.111 10.282 3.596 2.269 8.368.596 9.759-3.422a.59.59 0 0 0-.75-.74Z"/>
SVG;
}
function search_20($ctx) {
return <<<SVG
<path clip-rule="evenodd" d="m9.5 4.5c-2.76142 0-5 2.23858-5 5 0 2.7614 2.23858 5 5 5 2.7614 0 5-2.2386 5-5 0-2.76142-2.2386-5-5-5zm-6.5 5c0-3.58985 2.91015-6.5 6.5-6.5 3.5899 0 6.5 2.91015 6.5 6.5 0 1.5247-.525 2.9268-1.404 4.0353l3.1843 3.1844c.2929.2929.2929.7677 0 1.0606s-.7677.2929-1.0606 0l-3.1844-3.1843c-1.1085.879-2.5106 1.404-4.0353 1.404-3.58985 0-6.5-2.9101-6.5-6.5z" fill="currentColor" fill-rule="evenodd"/>
SVG;
}
function file_20($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M10.175 1.5H9c-.806 0-1.465.006-2.01.05-.63.052-1.172.16-1.67.413a4.25 4.25 0 0 0-1.857 1.858c-.253.497-.361 1.04-.413 1.67C3 6.103 3 6.864 3 7.816v4.366c0 .952 0 1.713.05 2.327.052.63.16 1.172.413 1.67a4.25 4.25 0 0 0 1.858 1.857c.497.253 1.04.361 1.67.413.613.05 1.374.05 2.326.05h1.366c.952 0 1.713 0 2.327-.05.63-.052 1.172-.16 1.67-.413a4.251 4.251 0 0 0 1.857-1.857c.253-.498.361-1.04.413-1.67.05-.614.05-1.375.05-2.327V8.325c0-.489 0-.733-.055-.963-.05-.205-.13-.4-.24-.579-.123-.201-.296-.374-.642-.72l-3.626-3.626c-.346-.346-.519-.519-.72-.642a2.001 2.001 0 0 0-.579-.24c-.23-.055-.474-.055-.963-.055ZM15.5 12.15c0 .992 0 1.692-.045 2.238-.044.537-.127.86-.255 1.11A2.751 2.751 0 0 1 14 16.7c-.252.128-.574.21-1.111.255-.546.044-1.245.045-2.238.045h-1.3c-.992 0-1.692 0-2.238-.045-.537-.044-.86-.127-1.11-.255A2.75 2.75 0 0 1 4.8 15.5c-.128-.252-.21-.574-.255-1.111-.044-.546-.045-1.245-.045-2.238v-4.3c0-.992 0-1.692.045-2.238.044-.537.127-.86.255-1.11A2.75 2.75 0 0 1 6.002 3.3c.25-.128.573-.21 1.11-.255C7.658 3.001 8.358 3 9.35 3H10v2.35c0 .409 0 .761.024 1.051.026.306.083.61.238.902.21.398.537.724.935.935.291.155.596.212.902.238.29.024.642.024 1.051.024h2.35v3.65ZM14.879 7 11.5 3.621V5.32c0 .447 0 .736.02.955.017.21.047.288.067.326a.75.75 0 0 0 .312.312c.038.02.116.05.326.068.22.018.508.019.955.019h1.699Zm-8.876 7.248a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Zm.75-3.749a.75.75 0 1 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-4.5Z" clip-rule="evenodd"/>
SVG;
}
function book_20($ctx) {
return <<<SVG
<path clip-rule="evenodd" d="m6.91957 1.5h.03043 6.1.0304c.5342-.00001.98-.00002 1.3443.02974.3799.03104.7365.09815 1.0738.26999.5174.26366.9381.68435 1.2018 1.2018.1718.33726.2389.69392.27 1.0738.0297.36422.0297.81005.0297 1.34418v.03049 9.3c0 .4142-.3358.75-.75.75h-.1885c-.0335.1873-.0615.4371-.0615.75s.028.5627.0615.75h.1885c.4142 0 .75.3358.75.75s-.3358.75-.75.75h-10.99999c-1.24265 0-2.25001-1.0073-2.25001-2.25v-10.8-.03043c-.00001-.53415-.00002-.98001.02974-1.34424.03104-.37988.09815-.73654.26999-1.0738.26366-.51745.68435-.93814 1.2018-1.2018.33726-.17184.69392-.23895 1.0738-.26999.36423-.02976.81009-.02975 1.34424-.02974zm7.62533 15.5c-.0272-.218-.0449-.4681-.0449-.75s.0177-.532.0449-.75h-9.29489c-.41422 0-.75001.3358-.75001.75s.33579.75.75001.75zm.9551-11.55v8.55h-10.24999c-.26298 0-.51542.0451-.75001.128v-8.678c0-.57243.00058-.95664.02476-1.25252.02346-.28712.06534-.42441.11148-.51497.11984-.2352.31107-.42642.54627-.54627.09056-.04614.22785-.08802.51497-.11148.29588-.02417.68009-.02476 1.25252-.02476h6.1c.5724 0 .9566.00059 1.2525.02476.2871.02346.4244.06534.515.11148.2352.11985.4264.31107.5463.54627.0461.09056.088.22785.1114.51497.0242.29588.0248.68009.0248 1.25252z" fill="currentColor" fill-rule="evenodd"/>
SVG;
}
function clear_20($ctx) {
return <<<SVG
<path clip-rule="evenodd" d="m20 10c0-5.52285-4.4772-10-10-10-5.52285 0-10 4.47715-10 10 0 5.5228 4.47715 10 10 10 5.5228 0 10-4.4772 10-10zm-12.29289-3.70711c-.39053-.39052-1.02369-.39052-1.41422 0-.39052.39053-.39052 1.02369 0 1.41422l2.2929 2.29289-2.2929 2.2929c-.39052.3905-.39052 1.0237 0 1.4142.39053.3905 1.02369.3905 1.41422 0l2.29289-2.2929 2.2929 2.2929c.3905.3905 1.0237.3905 1.4142 0s.3905-1.0237 0-1.4142l-2.2929-2.2929 2.2929-2.29289c.3905-.39053.3905-1.02369 0-1.41422-.3905-.39052-1.0237-.39052-1.4142 0l-2.2929 2.2929z" fill="currentColor" fill-rule="evenodd"/>
SVG;
}
function clear_16($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0ZM4.563 4.564a.9.9 0 0 0 0 1.272L6.727 8l-2.164 2.164a.9.9 0 1 0 1.273 1.272L8 9.273l2.164 2.163a.9.9 0 0 0 1.272-1.272L9.273 8l2.163-2.164a.9.9 0 1 0-1.272-1.272L8 6.727 5.836 4.564a.9.9 0 0 0-1.273 0Z" clip-rule="evenodd"/>
SVG;
}
function arrow_up_right_out_square_outline_12($ctx) {
return <<<SVG
<path fill="currentColor" d="M4.25 1a.75.75 0 1 1 0 1.5c-.5006 0-.6414.0036-.7439.024a1.25 1.25 0 0 0-.982.9821c-.0205.1025-.0241.2433-.0241.7439v2.8c0 .5724.0006.9566.0248 1.2525.0234.2871.0653.4244.1114.515a1.25 1.25 0 0 0 .5463.5463c.0906.0461.2279.088.515.1114.2959.0242.68.0248 1.2525.0248h2.8c.5006 0 .6414-.0036.7439-.024a1.25 1.25 0 0 0 .982-.9821c.0205-.1025.0241-.2433.0241-.7439a.75.75 0 0 1 1.5 0c0 .4287.0036.7526-.0528 1.0365a2.7501 2.7501 0 0 1-2.1607 2.1607c-.2675.0532-.5705.053-.9632.0528-.968-.0005-1.9358 0-2.9037 0-.5342 0-.98 0-1.3443-.0297-.3798-.0311-.7365-.0982-1.0738-.27a2.7504 2.7504 0 0 1-1.2018-1.2018c-.1718-.3373-.239-.694-.27-1.0738C1 8.0604 1 7.6146 1 7.0804c0-.9679.0005-1.9358 0-2.9037-.0002-.3927-.0004-.6957.0528-.9632a2.75 2.75 0 0 1 2.1607-2.1607C3.4975.9964 3.8213 1 4.25 1Z M7 .75A.75.75 0 0 1 7.75 0h3.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V2.5607L7.2803 5.7803a.75.75 0 0 1-1.0606-1.0606L9.4393 1.5H7.75A.75.75 0 0 1 7 .75Z"/>
SVG;
}

14
skin/widgets.phps Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace skin\widgets;
function spinner($ctx,
$id = null) {
return <<<HTML
<div class="spinner"{$ctx->if_true($id, ' id="'.$id.'"')}>
<span></span>
<span></span>
<span></span>
</div>
HTML;
}

View File

@ -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