├── .dockerignore ├── .gitignore ├── .gitlab-ci.yml ├── .htaccess ├── DOCUMENTATION.md ├── Dockerfile ├── LICENSE.lgpl ├── Makefile ├── README.md ├── api ├── backlog │ └── index.php ├── search │ └── index.php └── searchbuffer │ └── index.php ├── database ├── Config.php ├── Database.php ├── User.php ├── backends │ ├── Backend.php │ ├── BackendFactory.php │ ├── PostgresSmartBackend.php │ └── SQLiteSmartBackend.php └── helper │ ├── AuthHelper.php │ ├── RendererHelper.php │ ├── SessionHelper.php │ ├── TranslationHelper.php │ └── ViewHelper.php ├── favicon.ico ├── favicon.png ├── favicon.svg ├── index.php ├── login.php ├── package-lock.json ├── package.json ├── qrs_config.default.php ├── res ├── css │ ├── _animations.sass │ ├── _content.sass │ ├── _font.sass │ ├── _icons.sass │ ├── _loading.sass │ ├── _mirccolor.sass │ ├── _nav.sass │ ├── _searchoptions.sass │ ├── _sendercolor.sass │ ├── _textfield.sass │ ├── _util.sass │ ├── login.css │ ├── login.css.map │ ├── login.sass │ ├── search.css │ ├── search.css.map │ └── search.sass ├── fonts │ ├── roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff │ ├── roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff2 │ ├── roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff │ └── roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff2 ├── icons │ ├── MaterialIcons-Regular.woff │ ├── MaterialIcons-Regular.woff2 │ ├── chevron-down.svg │ ├── chevron-up.svg │ ├── dots-horizontal.svg │ └── error.png └── js │ ├── component │ ├── app.js │ ├── app.jsx │ ├── buffer.js │ ├── buffer.jsx │ ├── context.js │ ├── context.jsx │ ├── error.js │ ├── error.jsx │ ├── history.js │ ├── history.jsx │ ├── historyelement.js │ ├── historyelement.jsx │ ├── loading.js │ ├── loading.jsx │ ├── loadmore.js │ ├── loadmore.jsx │ ├── message.js │ ├── message.jsx │ ├── nav.js │ ├── nav.jsx │ ├── nohistoryelement.js │ └── nohistoryelement.jsx │ └── util │ ├── component.js │ ├── highlighthandler.js │ ├── loader.js │ ├── mirccolorhandler.js │ ├── moment-with-locales.js │ ├── nativejsx-prototypes.js │ ├── sendercolorhandler.js │ ├── statehandler.js │ └── storage.js ├── templates ├── login.phtml └── search.phtml └── translations ├── de.json └── en.json /.dockerignore: -------------------------------------------------------------------------------- 1 | /LICENSE.* 2 | /package.json 3 | /package-lock.json 4 | /README.md 5 | /qrs_config.php 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /*.iml 3 | /node_modules 4 | /qrs_config.php 5 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | build: 2 | stage: build 3 | image: 4 | name: gcr.io/kaniko-project/executor:debug 5 | entrypoint: [ "" ] 6 | script: 7 | - mkdir -p /kaniko/.docker 8 | - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json 9 | - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE:${CI_COMMIT_TAG:-$CI_COMMIT_SHORT_SHA} --destination $CI_REGISTRY_IMAGE:latest 10 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Order allow,deny 3 | deny from all 4 | Require all denied 5 | 6 | 7 | 8 | RedirectMatch 403 ^.*/\.git.*$ 9 | 10 | -------------------------------------------------------------------------------- /DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## API Endpoints 4 | 5 | Authentication happens via [HTTP basic auth](https://tools.ietf.org/html/rfc7617) 6 | 7 | ### api/backlog/ 8 | 9 | This endpoint returns full backlog for a single quassel buffer, centered around a certain message. 10 | 11 | **Parameters**: 12 | 13 | | Name | Type | Required | Description | 14 | | -------- | ------ | -------- | ----------------------------------------------------------------- | 15 | | `anchor` | Number | `true` | Index message for which to retrieve surrounding backlog | 16 | | `buffer` | Number | `true` | Chat/Buffer from which messages should be retrieved | 17 | | `before` | Number | `true` | Number of messages to load chronologically before the anchorpoint | 18 | | `after` | Number | `true` | Number of messages to load chronologically after the anchorpoint | 19 | 20 | ### api/search/ 21 | 22 | This endpoint returns search results across all buffers. 23 | 24 | **Parameters**: 25 | 26 | | Name | Type | Required | Description | 27 | | --------- | --------- | -------- | ------------------------------------------------------------------------------ | 28 | | `query` | String | `true` | Query to filter messages | 29 | | `since` | Timestamp | `false` | If set, only show messages received after this timestamp. Format: '1970-01-01' | 30 | | `before` | Timestamp | `false` | If set, only show messages received beforethis timestamp. Format: '1970-01-01' | 31 | | `buffer` | String | `false` | If set, only show messages if the name of the chat/buffer they are in matches | 32 | | `network` | String | `false` | If set, only show messages if the name of the network they are in matches | 33 | | `sender` | String | `false` | If set, only show messages if the nick!user@host of the sender matches | 34 | | `limit` | Number | `true` | Number of messages to return per buffer | 35 | 36 | ### api/searchbuffer/ 37 | 38 | This endpoint returns search results for a single quassel buffer. 39 | 40 | **Parameters**: 41 | 42 | | Name | Type | Required | Description | 43 | | --------- | --------- | -------- | ------------------------------------------------------------------------------ | 44 | | `query` | String | `true` | Query to filter messages | 45 | | `since` | Timestamp | `false` | If set, only show messages received after this timestamp. Format: '1970-01-01' | 46 | | `before` | Timestamp | `false` | If set, only show messages received beforethis timestamp. Format: '1970-01-01' | 47 | | `buffer` | Number | `true` | Id of the buffer to search in | 48 | | `sender` | String | `false` | If set, only show messages if the nick!user@host of the sender matches | 49 | | `offset` | Number | `true` | Number of results to skip (for pagination) | 50 | | `limit` | Number | `true` | Number of messages to return | 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM trafex/php-nginx:latest 2 | 3 | USER root 4 | 5 | RUN apk add --no-cache --update \ 6 | php82-json \ 7 | php82-pdo_sqlite \ 8 | php82-pdo_pgsql 9 | 10 | USER nobody 11 | 12 | ADD . /var/www/html/ 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE := k8r.eu/justjanne/$(shell basename $(shell git remote get-url origin) .git) 2 | TAGS := $(shell git describe --always --tags HEAD) 3 | 4 | .PHONY: build 5 | build: 6 | docker build --pull -t $(IMAGE):$(TAGS) . 7 | docker tag $(IMAGE):$(TAGS) $(IMAGE):latest 8 | @echo Successfully tagged $(IMAGE):$(TAGS) as latest 9 | 10 | .PHONY: push 11 | push: build 12 | docker push $(IMAGE):$(TAGS) 13 | docker push $(IMAGE):latest 14 | @echo Successfully pushed $(IMAGE):$(TAGS) as latest 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quassel RESTSearch 2 | 3 | This is a websearch frontend for a quassel database. 4 | 5 | It offers both a simple HTTP API for search, and a normal website for the same purpose. 6 | 7 | Setting up search backends 8 | -------------------------- 9 | 10 | #### pgsql-smart 11 | 12 | First, add a new column to the backlog table: 13 | 14 | ```sql 15 | ALTER TABLE backlog ADD COLUMN tsv tsvector; 16 | ``` 17 | 18 | Second, add the two new indices: 19 | 20 | ```sql 21 | CREATE INDEX backlog_tsv_idx 22 | ON backlog 23 | USING gin(tsv); 24 | ``` 25 | 26 | ```sql 27 | CREATE INDEX backlog_tsv_filtered_idx 28 | ON backlog 29 | USING gin(tsv) 30 | WHERE (type & 23559) > 0; 31 | ``` 32 | 33 | Third, set up a trigger to populate the `tsv` column: 34 | 35 | ```sql 36 | CREATE TRIGGER tsvectorupdate 37 | BEFORE INSERT OR UPDATE 38 | ON backlog 39 | FOR EACH ROW 40 | EXECUTE PROCEDURE tsvector_update_trigger('tsv', 'pg_catalog.english', 'message'); 41 | ``` 42 | 43 | Fourth, populate the `tsv` column: 44 | ```sql 45 | UPDATE backlog 46 | SET messageid = messageid; 47 | ``` 48 | 49 | Setting up the search 50 | --------------------- 51 | 52 | First, copy the file `qrs_config.default.php` to `qrs_config.php`. 53 | 54 | Then configure the database access, backend (currently only `pgsql-smart` is available), and the prefix of the path. 55 | 56 | Your qrs_config.php should look something like this 57 | 58 | ```injectablephp 59 | authenticate( 21 | $session->username ?: $_SERVER['PHP_AUTH_USER'] ?: '', 22 | $session->password ?: $_SERVER['PHP_AUTH_PW'] ?: '' 23 | )) { 24 | $session->destroy(); 25 | $renderer->renderJsonError(false); 26 | } else { 27 | $renderer->renderJson($backend->context( 28 | param('anchor', 0), 29 | param('buffer', 0), 30 | param('before', 4), 31 | param('after', 4) 32 | )); 33 | } 34 | -------------------------------------------------------------------------------- /api/search/index.php: -------------------------------------------------------------------------------- 1 | authenticate( 21 | $session->username ?: $_SERVER['PHP_AUTH_USER'] ?: '', 22 | $session->password ?: $_SERVER['PHP_AUTH_PW'] ?: '' 23 | )) { 24 | $session->destroy(); 25 | $renderer->renderJsonError(false); 26 | } else { 27 | $renderer->renderJson($backend->find( 28 | param('query', ""), 29 | param('since'), 30 | param('before'), 31 | param('buffer'), 32 | param('network'), 33 | param('sender'), 34 | param('limit', 4) 35 | )); 36 | } 37 | -------------------------------------------------------------------------------- /api/searchbuffer/index.php: -------------------------------------------------------------------------------- 1 | authenticate( 21 | $session->username ?: $_SERVER['PHP_AUTH_USER'] ?: '', 22 | $session->password ?: $_SERVER['PHP_AUTH_PW'] ?: '' 23 | )) { 24 | $session->destroy(); 25 | $renderer->renderJsonError(false); 26 | } else { 27 | $renderer->renderJson($backend->findInBuffer( 28 | param('query', ""), 29 | param('since'), 30 | param('before'), 31 | param('sender'), 32 | param('buffer', 0), 33 | param('offset', 0), 34 | param('limit', 20) 35 | )); 36 | } 37 | -------------------------------------------------------------------------------- /database/Config.php: -------------------------------------------------------------------------------- 1 | database_connector = $database_connector; 21 | $this->username = $username; 22 | $this->password = $password; 23 | $this->path_prefix = rtrim($path_prefix, "/"); 24 | $this->backend = $backend; 25 | $this->database_options = $options; 26 | $this->enable_ranking = $enable_ranking; 27 | } 28 | 29 | public static function createFromGlobals() 30 | { 31 | $options = []; 32 | if (defined('qrs_db_option_tsqueryfunction') && (null !== qrs_db_option_tsqueryfunction)) { 33 | $options['tsqueryfunction'] = qrs_db_option_tsqueryfunction; 34 | } 35 | 36 | $options['timeout'] = (defined('qrs_db_option_timeout') && (null !== qrs_db_option_timeout)) ? qrs_db_option_timeout : 5000; 37 | 38 | if (defined('qrs_db_connector') && null !== qrs_db_connector) 39 | return new Config(qrs_path_prefix, qrs_db_connector, qrs_db_user, qrs_db_pass, qrs_backend, $options, qrs_enable_ranking); 40 | else 41 | return new Config(qrs_path_prefix, 'pgsql:host=' . qrs_db_host . ';port=' . qrs_db_port . ';dbname=' . qrs_db_name . '', qrs_db_user, qrs_db_pass, qrs_backend, $options, qrs_enable_ranking); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /database/Database.php: -------------------------------------------------------------------------------- 1 | backend = BackendFactory::create($type, new \PDO($database_connector, $username, $password), $options, $enable_ranking); 22 | $this->enable_ranking = $enable_ranking; 23 | } 24 | 25 | public static function createFromConfig(Config $config): Database 26 | { 27 | return Database::createFromOptions($config->database_connector, $config->username, $config->password, $config->backend, $config->database_options, $config->enable_ranking); 28 | } 29 | 30 | public static function createFromOptions(string $database_connector, string $username, string $password, string $type, array $options, bool $enable_ranking): Database 31 | { 32 | return new Database($database_connector, $username, $password, $type, $options, $enable_ranking); 33 | } 34 | 35 | public function authenticateFromHeader(string $header): bool 36 | { 37 | $parsedHeader = AuthHelper::parseAuthHeader($header); 38 | return $this->authenticate($parsedHeader['username'], $parsedHeader['password']); 39 | } 40 | 41 | public function authenticate(string $username, string $password): bool 42 | { 43 | if (!isset($username) || !isset($password)) { 44 | syslog(LOG_ERR, "Username or password not set"); 45 | return false; 46 | } 47 | 48 | $stmt = $this->backend->findUser(); 49 | 50 | $stmt->bindParam(":username", $username); 51 | $stmt->execute(); 52 | $result = $stmt->fetch(\PDO::FETCH_ASSOC); 53 | 54 | if ($result === FALSE) { 55 | syslog(LOG_ERR, "Couldn’t find user " . $username); 56 | return false; 57 | } 58 | 59 | $user = new User($result); 60 | 61 | if (!AuthHelper::initialAuthenticateUser($password, $user->password, $user->hashversion)) { 62 | syslog(LOG_ERR, "Password does not match for user " . $username); 63 | return false; 64 | } 65 | 66 | $this->user = $user; 67 | return true; 68 | } 69 | 70 | private function apply_config(\PDOStatement $stmt) 71 | { 72 | $values = [ 73 | ":config_normalization" => 4, 74 | ":weight_content" => 4, 75 | ":weight_type" => 5, 76 | ":weight_time" => 1, 77 | ]; 78 | foreach ($this->backend->rankingParameters() as $parameter) { 79 | $stmt->bindValue($parameter, $values[$parameter], PDO::PARAM_INT); 80 | } 81 | } 82 | 83 | public function find(string $query, string $since = null, string $before = null, string $buffer = null, string $network = null, string $sender = null, int $limitPerBuffer = 4): array 84 | { 85 | $truncatedLimit = max(min($limitPerBuffer, 10), 0); 86 | 87 | $messages = $this->findInBufferMultiple($query, $since, $before, $buffer, $network, $sender, $truncatedLimit); 88 | $hasMore = $this->findInBufferMultipleCount($query, $since, $before, $buffer, $network, $sender, 0, $truncatedLimit); 89 | 90 | $buffermap = []; 91 | 92 | foreach ($messages as $message) { 93 | if (!array_key_exists($message['bufferid'], $buffermap)) { 94 | $buffermap[$message['bufferid']] = [ 95 | "bufferid" => $message['bufferid'], 96 | "buffername" => $message['buffername'], 97 | "networkname" => $message['networkname'], 98 | "messages" => [] 99 | ]; 100 | } 101 | 102 | array_push($buffermap[$message['bufferid']]['messages'], $message); 103 | } 104 | 105 | foreach ($hasMore as $hasMoreResult) { 106 | if (array_key_exists($hasMoreResult['bufferid'], $buffermap)) 107 | $buffermap[$hasMoreResult['bufferid']]['hasmore'] = $hasMoreResult['hasmore']; 108 | } 109 | 110 | return array_values($buffermap); 111 | } 112 | 113 | public function findInBufferMultiple(string $query, string $since = null, string $before = null, string $buffer = null, string $network = null, string $sender = null, int $limit = 4): array 114 | { 115 | $ignore_since = $since === null; 116 | $ignore_before = $before === null; 117 | $ignore_network = $network === null; 118 | $ignore_buffer = $buffer === null; 119 | $ignore_sender = $sender === null; 120 | 121 | $stmt = $this->backend->findInBuffers(); 122 | $this->apply_config($stmt); 123 | 124 | $stmt->bindParam(':userid', $this->user->userid, PDO::PARAM_INT); 125 | $stmt->bindParam(':query', $query, PDO::PARAM_STR); 126 | $stmt->bindParam(':limit', $limit, PDO::PARAM_INT); 127 | 128 | $stmt->bindValue(':since', !$ignore_since ? (string)$since : "1970-01-01", PDO::PARAM_STR); 129 | $stmt->bindValue(':before', !$ignore_before ? (string)$before : "1970-01-01", PDO::PARAM_STR); 130 | $stmt->bindValue(':buffer', !$ignore_buffer ? (string)$buffer : "", PDO::PARAM_STR); 131 | $stmt->bindValue(':network', !$ignore_network ? (string)$network : "", PDO::PARAM_STR); 132 | $stmt->bindValue(':sender', !$ignore_sender ? (string)$sender : "", PDO::PARAM_STR); 133 | $stmt->bindParam(':ignore_since', $ignore_since, PDO::PARAM_INT); 134 | $stmt->bindParam(':ignore_before', $ignore_before, PDO::PARAM_INT); 135 | $stmt->bindParam(':ignore_buffer', $ignore_buffer, PDO::PARAM_INT); 136 | $stmt->bindParam(':ignore_network', $ignore_network, PDO::PARAM_INT); 137 | $stmt->bindParam(':ignore_sender', $ignore_sender, PDO::PARAM_INT); 138 | 139 | $success = $stmt->execute(); 140 | $result = $stmt->fetchAll(\PDO::FETCH_ASSOC); 141 | return $result; 142 | } 143 | 144 | public function findInBufferMultipleCount(string $query, string $since = null, string $before = null, string $buffer = null, string $network = null, string $sender = null, int $offset = 0, int $limit = 4): array 145 | { 146 | $truncatedLimit = max(min($limit, 50), 0); 147 | $ignore_since = $since === null; 148 | $ignore_before = $before === null; 149 | $ignore_network = $network === null; 150 | $ignore_buffer = $buffer === null; 151 | $ignore_sender = $sender === null; 152 | 153 | $stmt = $this->backend->findInBuffersCount(); 154 | 155 | $stmt->bindParam(':userid', $this->user->userid, PDO::PARAM_INT); 156 | $stmt->bindParam(':query', $query, PDO::PARAM_STR); 157 | 158 | $stmt->bindValue(':since', !$ignore_since ? (string)$since : "1970-01-01", PDO::PARAM_STR); 159 | $stmt->bindValue(':before', !$ignore_before ? (string)$before : "1970-01-01", PDO::PARAM_STR); 160 | $stmt->bindValue(':buffer', !$ignore_buffer ? (string)$buffer : "", PDO::PARAM_STR); 161 | $stmt->bindValue(':network', !$ignore_network ? (string)$network : "", PDO::PARAM_STR); 162 | $stmt->bindValue(':sender', !$ignore_sender ? (string)$sender : "", PDO::PARAM_STR); 163 | $stmt->bindParam(':ignore_since', $ignore_since, PDO::PARAM_INT); 164 | $stmt->bindParam(':ignore_before', $ignore_before, PDO::PARAM_INT); 165 | $stmt->bindParam(':ignore_buffer', $ignore_buffer, PDO::PARAM_INT); 166 | $stmt->bindParam(':ignore_network', $ignore_network, PDO::PARAM_INT); 167 | $stmt->bindParam(':ignore_sender', $ignore_sender, PDO::PARAM_INT); 168 | 169 | $stmt->bindParam(':limit', $truncatedLimit, PDO::PARAM_INT); 170 | $stmt->bindParam(':offset', $offset, PDO::PARAM_INT); 171 | 172 | $success = $stmt->execute(); 173 | $result = $stmt->fetchAll(\PDO::FETCH_ASSOC); 174 | return $result; 175 | } 176 | 177 | public function findInBuffer(string $query, string $since = null, string $before = null, string $sender = null, int $bufferid, int $offset = 0, int $limit = 20): array 178 | { 179 | $truncatedLimit = max(min($limit, 50), 0); 180 | $ignore_since = $since === null; 181 | $ignore_before = $before === null; 182 | $ignore_sender = $sender === null; 183 | 184 | $stmt = $this->backend->findInBuffer(); 185 | $this->apply_config($stmt); 186 | 187 | $stmt->bindParam(':userid', $this->user->userid, PDO::PARAM_INT); 188 | $stmt->bindParam(':bufferid', $bufferid, PDO::PARAM_INT); 189 | $stmt->bindParam(':query', $query, PDO::PARAM_STR); 190 | 191 | $stmt->bindValue(':since', !$ignore_since ? (string)$since : "1970-01-01", PDO::PARAM_STR); 192 | $stmt->bindValue(':before', !$ignore_before ? (string)$before : "1970-01-01", PDO::PARAM_STR); 193 | $stmt->bindValue(':sender', !$ignore_sender ? (string)$sender : "", PDO::PARAM_STR); 194 | $stmt->bindParam(':ignore_since', $ignore_since, PDO::PARAM_INT); 195 | $stmt->bindParam(':ignore_before', $ignore_before, PDO::PARAM_INT); 196 | $stmt->bindParam(':ignore_sender', $ignore_sender, PDO::PARAM_INT); 197 | 198 | $stmt->bindParam(':limit', $truncatedLimit, PDO::PARAM_INT); 199 | $stmt->bindParam(':offset', $offset, PDO::PARAM_INT); 200 | 201 | $stmt->execute(); 202 | return [ 203 | 'results' => $stmt->fetchAll(\PDO::FETCH_ASSOC), 204 | 'hasmore' => $this->findInBufferCount($query, $since, $before, $sender, $bufferid, $offset, $limit) 205 | ]; 206 | } 207 | 208 | public function findInBufferCount(string $query, string $since = null, string $before = null, string $sender = null, int $bufferid, int $offset = 0, int $limit = 4): bool 209 | { 210 | $truncatedLimit = max(min($limit, 50), 0); 211 | $ignore_since = $since === null; 212 | $ignore_before = $before === null; 213 | $ignore_sender = $sender === null; 214 | 215 | $stmt = $this->backend->findInBufferCount(); 216 | 217 | $stmt->bindParam(':userid', $this->user->userid, PDO::PARAM_INT); 218 | $stmt->bindParam(':bufferid', $bufferid, PDO::PARAM_INT); 219 | $stmt->bindParam(':query', $query, PDO::PARAM_STR); 220 | 221 | $stmt->bindValue(':since', !$ignore_since ? (string)$since : "1970-01-01", PDO::PARAM_STR); 222 | $stmt->bindValue(':before', !$ignore_before ? (string)$before : "1970-01-01", PDO::PARAM_STR); 223 | $stmt->bindValue(':sender', !$ignore_sender ? (string)$sender : "", PDO::PARAM_STR); 224 | $stmt->bindParam(':ignore_since', $ignore_since, PDO::PARAM_INT); 225 | $stmt->bindParam(':ignore_before', $ignore_before, PDO::PARAM_INT); 226 | $stmt->bindParam(':ignore_sender', $ignore_sender, PDO::PARAM_INT); 227 | 228 | $stmt->bindParam(':limit', $truncatedLimit, PDO::PARAM_INT); 229 | $stmt->bindParam(':offset', $offset, PDO::PARAM_INT); 230 | 231 | $stmt->execute(); 232 | return $stmt->fetchColumn(); 233 | } 234 | 235 | public function context(int $anchor, int $buffer, int $loadBefore, int $loadAfter): array 236 | { 237 | return array_merge(array_reverse($this->before($anchor, $buffer, $loadBefore)), $this->after($anchor, $buffer, $loadAfter)); 238 | } 239 | 240 | public function before(int $anchor, int $buffer, int $limit): array 241 | { 242 | $truncatedLimit = max(min($limit, 50), 0); 243 | 244 | $stmt = $this->backend->loadBefore(); 245 | 246 | $stmt->bindParam(":userid", $this->user->userid, PDO::PARAM_INT); 247 | $stmt->bindParam(":bufferid", $buffer, PDO::PARAM_INT); 248 | $stmt->bindParam(":anchor", $anchor, PDO::PARAM_INT); 249 | 250 | $stmt->bindParam(":limit", $truncatedLimit, PDO::PARAM_INT); 251 | 252 | $stmt->execute(); 253 | return $stmt->fetchAll(\PDO::FETCH_ASSOC); 254 | } 255 | 256 | public function after(int $anchor, int $buffer, int $limit): array 257 | { 258 | $truncatedLimit = max(min($limit, 50), 0); 259 | 260 | $stmt = $this->backend->loadAfter(); 261 | 262 | $stmt->bindParam(":userid", $this->user->userid, PDO::PARAM_INT); 263 | $stmt->bindParam(":bufferid", $buffer, PDO::PARAM_INT); 264 | $stmt->bindParam(":anchor", $anchor, PDO::PARAM_INT); 265 | 266 | $stmt->bindParam(":limit", $truncatedLimit, PDO::PARAM_INT); 267 | 268 | $stmt->execute(); 269 | return $stmt->fetchAll(\PDO::FETCH_ASSOC); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /database/User.php: -------------------------------------------------------------------------------- 1 | userid = $userArray['userid']; 16 | $this->username = $userArray['username']; 17 | $this->password = $userArray['password']; 18 | $this->hashversion = $userArray['hashversion']; 19 | } 20 | } -------------------------------------------------------------------------------- /database/backends/Backend.php: -------------------------------------------------------------------------------- 1 | db = $db; 16 | $timeout = $options["timeout"]; 17 | $this->db->exec("SET statement_timeout = $timeout;"); 18 | $this->options = $options; 19 | $this->enable_ranking = $enable_ranking; 20 | } 21 | 22 | public function findUser(): \PDOStatement 23 | { 24 | return $this->db->prepare(" 25 | SELECT * 26 | FROM quasseluser 27 | WHERE quasseluser.username = :username 28 | "); 29 | } 30 | 31 | private function tsQueryFunction(): string 32 | { 33 | return array_key_exists('tsqueryfunction', $this->options) ? $this->options['tsqueryfunction'] : "plainto_tsquery('english', :query)"; 34 | } 35 | 36 | function rankingParameters(): array 37 | { 38 | if ($this->enable_ranking) { 39 | return [ 40 | ":config_normalization", 41 | ":weight_content", 42 | ":weight_type", 43 | ":weight_time" 44 | ]; 45 | } else { 46 | return [ 47 | ":weight_type", 48 | ":weight_time" 49 | ]; 50 | } 51 | } 52 | 53 | private function rankingFunction(): string 54 | { 55 | if ($this->enable_ranking) { 56 | return "( 57 | (ts_rank_cd(tsv, query, :config_normalization) ^ (2 ^ :weight_content)) * 58 | ((CASE 59 | WHEN TYPE IN (1, 4) THEN 1.0 60 | WHEN TYPE IN (2, 1024, 2048, 4096, 16384) THEN 0.8 61 | WHEN TYPE IN (32, 64, 128, 256, 512, 32768, 65536) THEN 0.6 62 | WHEN TYPE IN (8, 16, 8192, 131072) THEN 0.4 63 | ELSE 0.2 END) ^ (2 ^ :weight_type)) * 64 | ((1 / (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) - EXTRACT(EPOCH FROM time))) ^ (2 ^ :weight_time)) 65 | )"; 66 | } else { 67 | return "( 68 | ((CASE 69 | WHEN TYPE IN (1, 4) THEN 1.0 70 | WHEN TYPE IN (2, 1024, 2048, 4096, 16384) THEN 0.8 71 | WHEN TYPE IN (32, 64, 128, 256, 512, 32768, 65536) THEN 0.6 72 | WHEN TYPE IN (8, 16, 8192, 131072) THEN 0.4 73 | ELSE 0.2 END) ^ (2 ^ :weight_type)) * 74 | ((1 / (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) - EXTRACT(EPOCH FROM time))) ^ (2 ^ :weight_time)) 75 | )"; 76 | } 77 | } 78 | 79 | public function findInBuffers(): \PDOStatement 80 | { 81 | $tsQueryFunction = $this->tsQueryFunction(); 82 | $rankingFunction = $this->rankingFunction(); 83 | return $this->db->prepare(" 84 | WITH matching_backlog AS ( 85 | SELECT * FROM backlog WHERE tsv @@ $tsQueryFunction 86 | ) 87 | SELECT 88 | ranked_messages.bufferid, 89 | ranked_messages.buffername, 90 | ranked_messages.networkname, 91 | ranked_messages.messageid, 92 | ranked_messages.type, 93 | ranked_messages.time, 94 | ranked_messages.sender, 95 | ts_headline(replace(replace(ranked_messages.message, '<', '<'), '>', '>'), query, 'HighlightAll=TRUE') AS message 96 | FROM 97 | (SELECT 98 | matching_messages.*, 99 | network.networkname, 100 | sender.sender, 101 | rank() 102 | OVER ( 103 | PARTITION BY matching_messages.bufferid 104 | ORDER BY matching_messages.rank_value DESC 105 | ) AS rank, 106 | first_value(rank_value) 107 | OVER ( 108 | PARTITION BY matching_messages.bufferid 109 | ORDER BY matching_messages.rank_value DESC 110 | ) AS max_rank_value 111 | FROM 112 | (SELECT 113 | backlog.messageid, 114 | backlog.bufferid, 115 | buffer.buffername, 116 | buffer.networkid, 117 | backlog.senderid, 118 | backlog.type, 119 | backlog.time, 120 | backlog.message, 121 | query, 122 | $rankingFunction AS rank_value 123 | FROM 124 | matching_backlog AS backlog 125 | JOIN buffer ON backlog.bufferid = buffer.bufferid 126 | , $tsQueryFunction query 127 | WHERE buffer.userid = :userid 128 | AND (:ignore_since::BOOLEAN OR backlog.time > :since::TIMESTAMP) 129 | AND (:ignore_before::BOOLEAN OR backlog.time < :before::TIMESTAMP) 130 | AND (:ignore_buffer::BOOLEAN OR buffer.buffername ~* :buffer) 131 | AND backlog.type & 23559 > 0 132 | ) matching_messages 133 | JOIN sender ON matching_messages.senderid = sender.senderid 134 | JOIN network ON matching_messages.networkid = network.networkid 135 | WHERE (:ignore_network::BOOLEAN OR network.networkname ~* :network) 136 | AND (:ignore_sender::BOOLEAN OR sender.sender ~* :sender) 137 | ) ranked_messages 138 | WHERE ranked_messages.rank <= :limit 139 | ORDER BY ranked_messages.max_rank_value DESC, ranked_messages.rank_value DESC 140 | "); 141 | } 142 | 143 | public function findInBuffersCount(): \PDOStatement 144 | { 145 | $tsQueryFunction = $this->tsQueryFunction(); 146 | return $this->db->prepare(" 147 | SELECT 148 | backlog.bufferid, 149 | COUNT(*) > (:limit::INT + :offset::INT) AS hasmore 150 | FROM 151 | backlog 152 | JOIN buffer ON backlog.bufferid = buffer.bufferid 153 | JOIN sender ON backlog.senderid = sender.senderid 154 | JOIN network ON buffer.networkid = network.networkid 155 | , $tsQueryFunction query 156 | WHERE buffer.userid = :userid 157 | AND (:ignore_since::BOOLEAN OR backlog.time > :since::TIMESTAMP) 158 | AND (:ignore_before::BOOLEAN OR backlog.time < :before::TIMESTAMP) 159 | AND (:ignore_buffer::BOOLEAN OR buffer.buffername ~* :buffer) 160 | AND (:ignore_network::BOOLEAN OR network.networkname ~* :network) 161 | AND (:ignore_sender::BOOLEAN OR sender.sender ~* :sender) 162 | AND backlog.tsv @@ query AND backlog.type & 23559 > 0 163 | GROUP BY backlog.bufferid; 164 | "); 165 | } 166 | 167 | public function findInBuffer(): \PDOStatement 168 | { 169 | $tsQueryFunction = $this->tsQueryFunction(); 170 | $rankingFunction = $this->rankingFunction(); 171 | return $this->db->prepare(" 172 | WITH matching_backlog AS ( 173 | SELECT * FROM backlog WHERE tsv @@ $tsQueryFunction 174 | ) 175 | SELECT 176 | matching_messages.messageid, 177 | matching_messages.time, 178 | sender.sender, 179 | ts_headline(replace(replace(matching_messages.message, '<', '<'), '>', '>'), query, 'HighlightAll=TRUE') AS message 180 | FROM 181 | (SELECT 182 | backlog.messageid, 183 | backlog.bufferid, 184 | buffer.buffername, 185 | buffer.networkid, 186 | backlog.senderid, 187 | backlog.type, 188 | backlog.time, 189 | backlog.message, 190 | query, 191 | $rankingFunction AS rank_value 192 | FROM 193 | matching_backlog AS backlog 194 | JOIN buffer ON backlog.bufferid = buffer.bufferid 195 | , $tsQueryFunction query 196 | WHERE buffer.userid = :userid 197 | AND buffer.bufferid = :bufferid 198 | AND (:ignore_since::BOOLEAN OR backlog.time > :since::TIMESTAMP) 199 | AND (:ignore_before::BOOLEAN OR backlog.time < :before::TIMESTAMP) 200 | AND backlog.type & 23559 > 0 201 | ) matching_messages 202 | JOIN sender ON matching_messages.senderid = sender.senderid 203 | JOIN network ON matching_messages.networkid = network.networkid 204 | WHERE (:ignore_sender::BOOLEAN OR sender.sender ~* :sender) 205 | ORDER BY matching_messages.rank_value DESC 206 | LIMIT :limit 207 | OFFSET :offset 208 | "); 209 | } 210 | 211 | public function findInBufferCount(): \PDOStatement 212 | { 213 | $tsQueryFunction = $this->tsQueryFunction(); 214 | return $this->db->prepare(" 215 | SELECT 216 | COUNT(*) > (:limit::INT + :offset::INT) AS hasmore 217 | FROM 218 | backlog 219 | JOIN buffer ON backlog.bufferid = buffer.bufferid 220 | JOIN sender ON backlog.senderid = sender.senderid 221 | , $tsQueryFunction query 222 | WHERE buffer.userid = :userid 223 | AND backlog.bufferid = :bufferid 224 | AND (:ignore_since::BOOLEAN OR backlog.time > :since::TIMESTAMP) 225 | AND (:ignore_before::BOOLEAN OR backlog.time < :before::TIMESTAMP) 226 | AND (:ignore_sender::BOOLEAN OR sender.sender ~* :sender) 227 | AND backlog.tsv @@ query AND backlog.type & 23559 > 0 228 | GROUP BY backlog.bufferid; 229 | "); 230 | } 231 | 232 | public function loadAfter(): \PDOStatement 233 | { 234 | return $this->db->prepare(" 235 | SELECT backlog.messageid, 236 | backlog.bufferid, 237 | buffer.buffername, 238 | sender.sender, 239 | backlog.type, 240 | backlog.time, 241 | network.networkname, 242 | backlog.message 243 | FROM 244 | backlog 245 | JOIN sender ON backlog.senderid = sender.senderid 246 | JOIN buffer ON backlog.bufferid = buffer.bufferid 247 | JOIN network ON buffer.networkid = network.networkid 248 | WHERE buffer.userid = :userid 249 | AND backlog.bufferid = :bufferid 250 | AND backlog.messageid > :anchor 251 | ORDER BY backlog.messageid ASC 252 | LIMIT :limit; 253 | "); 254 | } 255 | 256 | public function loadBefore(): \PDOStatement 257 | { 258 | return $this->db->prepare(" 259 | SELECT * FROM (SELECT backlog.messageid, 260 | backlog.bufferid, 261 | buffer.buffername, 262 | sender.sender, 263 | backlog.type, 264 | backlog.time, 265 | network.networkname, 266 | backlog.message 267 | FROM 268 | backlog 269 | JOIN sender ON backlog.senderid = sender.senderid 270 | JOIN buffer ON backlog.bufferid = buffer.bufferid 271 | JOIN network ON buffer.networkid = network.networkid 272 | WHERE buffer.userid = :userid 273 | AND backlog.bufferid = :bufferid 274 | AND backlog.messageid < :anchor 275 | ORDER BY backlog.messageid DESC 276 | LIMIT :limit) t 277 | ORDER BY messageid ASC; 278 | "); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /database/backends/SQLiteSmartBackend.php: -------------------------------------------------------------------------------- 1 | ', '') FROM backlog_fts WHERE backlog_fts MATCH ('message: ' || 'jeanmuch*') 24 | */ 25 | 26 | class SQLiteSmartBackend implements Backend 27 | { 28 | private $db; 29 | private $options; 30 | private $enable_ranking; 31 | private $fts5_enabled; 32 | private $opening_tag_marker; 33 | private $closing_tag_marker; 34 | private $opening_tag; 35 | private $closing_tag; 36 | 37 | function __construct(\PDO $db, array $options, bool $enable_ranking) 38 | { 39 | $this->db = $db; 40 | $timeout = $options["timeout"]; 41 | $this->db->exec("PRAGMA busy_timeout = $timeout;"); 42 | $this->options = $options; 43 | $this->enable_ranking = $enable_ranking; 44 | $this->fts5_enabled = SQLiteSmartBackend::ensureFullTextSearchableDatabase($db); 45 | $this->opening_tag_marker = chr(2); 46 | $this->closing_tag_marker = chr(3); 47 | $this->opening_tag = $this->opening_tag_marker . 'b' . $this->closing_tag_marker; 48 | $this->closing_tag = $this->opening_tag_marker . '/b' . $this->closing_tag_marker; 49 | } 50 | 51 | private static function ensureFullTextSearchableDatabase(\PDO $db): bool 52 | { 53 | // Check fts5 is supported 54 | $stmt = $db->prepare("SELECT sqlite_compileoption_used('ENABLE_FTS5');"); 55 | 56 | if (!$stmt->execute()) { 57 | return false; 58 | } 59 | 60 | $record = $stmt->fetch(\PDO::FETCH_NUM); 61 | 62 | if (!$record || (int)$record[0] !== 1) { 63 | return false; 64 | } 65 | 66 | // Check if the fts5 table already exists 67 | $stmt = $db->prepare("SELECT rowid FROM sqlite_master WHERE type='table' and name = 'backlog_fts'"); 68 | 69 | if (!$stmt->execute()) { 70 | return false; 71 | } 72 | 73 | $record = $stmt->fetch(\PDO::FETCH_NUM); 74 | 75 | if ($record && $record[0] > 0) { 76 | return true; 77 | } 78 | 79 | // Create the fts5 table, its update triggers and populate the initial indexes 80 | try { 81 | $oldMode = $db->getAttribute(\PDO::ATTR_ERRMODE); 82 | 83 | $db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); 84 | $db->beginTransaction(); 85 | 86 | $db->exec("CREATE VIRTUAL TABLE backlog_fts USING fts5(message, content='backlog', content_rowid='messageid');"); 87 | 88 | $db->exec(" 89 | CREATE TRIGGER tsvectorupdate_ai AFTER INSERT ON backlog BEGIN 90 | INSERT INTO backlog_fts(rowid, message) VALUES (new.messageid, new.message); 91 | END; 92 | "); 93 | 94 | $db->exec(" 95 | CREATE TRIGGER tsvectorupdate_ad AFTER DELETE ON backlog BEGIN 96 | INSERT INTO backlog_fts(backlog_fts, rowid, message) VALUES('delete', old.messageid, old.message); 97 | END; 98 | "); 99 | 100 | $db->exec(" 101 | CREATE TRIGGER tsvectorupdate_au AFTER UPDATE ON backlog BEGIN 102 | INSERT INTO backlog_fts(backlog_fts, rowid, message) VALUES('delete', old.messageid, old.message); 103 | INSERT INTO backlog_fts(rowid, message) VALUES (new.messageid, new.message); 104 | END; 105 | "); 106 | 107 | $db->exec("INSERT INTO backlog_fts(backlog_fts) VALUES('rebuild');"); 108 | 109 | $db->setAttribute(\PDO::ATTR_ERRMODE, $oldMode); 110 | $db->commit(); 111 | return true; 112 | } catch (\PDOException $e) { 113 | $db->rollBack(); 114 | return false; 115 | } 116 | } 117 | 118 | public function findUser(): \PDOStatement 119 | { 120 | return $this->db->prepare(" 121 | SELECT * 122 | FROM quasseluser 123 | WHERE quasseluser.username = :username 124 | "); 125 | } 126 | 127 | function rankingParameters(): array 128 | { 129 | return [ 130 | ":weight_content", 131 | ":weight_type", 132 | ":weight_time" 133 | ]; 134 | } 135 | 136 | private function rankingFunction(): string 137 | { 138 | // TODO: Properly port missing support of Pow in sqlite 139 | 140 | if ($this->enable_ranking) { 141 | return "( 142 | (matching_backlog.rank << :weight_content) * 143 | ((CASE 144 | WHEN type IN (1, 4) THEN 1.0 145 | WHEN type IN (2, 1024, 2048, 4096, 16384) THEN 0.8 146 | WHEN type IN (32, 64, 128, 256, 512, 32768, 65536) THEN 0.6 147 | WHEN type IN (8, 16, 8192, 131072) THEN 0.4 148 | ELSE 0.2 END) << :weight_type) * 149 | ((1 / (CAST(strftime('%s', 'now') AS INT) - (time / 1000))) << :weight_time) 150 | )"; 151 | } else { 152 | return "( 153 | ((CASE 154 | WHEN type IN (1, 4) THEN 1.0 155 | WHEN type IN (2, 1024, 2048, 4096, 16384) THEN 0.8 156 | WHEN type IN (32, 64, 128, 256, 512, 32768, 65536) THEN 0.6 157 | WHEN type IN (8, 16, 8192, 131072) THEN 0.4 158 | ELSE 0.2 END) << :weight_type) * 159 | ((1 / (CAST(strftime('%s', 'now') AS INT) - (time / 1000))) << :weight_time) 160 | )"; 161 | } 162 | } 163 | 164 | public function findInBuffers(): \PDOStatement 165 | { 166 | $rankingFunction = $this->rankingFunction(); 167 | return $this->db->prepare(" 168 | WITH matching_backlog AS ( 169 | SELECT rowid, snippet(backlog_fts, 0, '$this->opening_tag', '$this->closing_tag', '...', 64) AS message, rank FROM backlog_fts WHERE backlog_fts MATCH ('message: ' || :query) 170 | ) 171 | SELECT 172 | ranked_messages.bufferid, 173 | ranked_messages.buffername, 174 | ranked_messages.networkname, 175 | ranked_messages.messageid, 176 | ranked_messages.type, 177 | datetime(ranked_messages.time / 1000, 'unixepoch') AS time, 178 | ranked_messages.sender, 179 | replace(replace(replace(replace(ranked_messages.message, '<', '<'), '>', '>'), '$this->opening_tag_marker', '<'), '$this->closing_tag_marker', '>') AS message 180 | FROM 181 | (SELECT 182 | matching_messages.*, 183 | network.networkname, 184 | sender.sender, 185 | rank() 186 | OVER ( 187 | PARTITION BY matching_messages.bufferid 188 | ORDER BY matching_messages.rank_value DESC 189 | ) AS rank, 190 | first_value(rank_value) 191 | OVER ( 192 | PARTITION BY matching_messages.bufferid 193 | ORDER BY matching_messages.rank_value DESC 194 | ) AS max_rank_value 195 | FROM 196 | (SELECT 197 | backlog.messageid, 198 | backlog.bufferid, 199 | buffer.buffername, 200 | buffer.networkid, 201 | backlog.senderid, 202 | backlog.type, 203 | backlog.time, 204 | matching_backlog.message, 205 | $rankingFunction AS rank_value 206 | FROM 207 | backlog 208 | JOIN buffer ON backlog.bufferid = buffer.bufferid 209 | JOIN matching_backlog ON backlog.messageid = matching_backlog.rowid 210 | WHERE buffer.userid = :userid 211 | AND (:ignore_since OR backlog.time > CAST(strftime('%s', strftime('%Y-%m-%d', :since)) AS INT) * 1000) 212 | AND (:ignore_before OR backlog.time < CAST(strftime('%s', strftime('%Y-%m-%d', :before)) AS INT) * 1000) 213 | AND (:ignore_buffer OR buffer.buffername LIKE '%' || :buffer || '%') 214 | AND backlog.type & 23559 > 0 215 | ) matching_messages 216 | JOIN sender ON matching_messages.senderid = sender.senderid 217 | JOIN network ON matching_messages.networkid = network.networkid 218 | WHERE (:ignore_network OR network.networkname LIKE '%' || :network || '%') 219 | AND (:ignore_sender OR sender.sender LIKE '%' || :sender || '%') 220 | ) ranked_messages 221 | WHERE ranked_messages.rank <= :limit 222 | ORDER BY ranked_messages.max_rank_value DESC, ranked_messages.rank_value DESC; 223 | "); 224 | } 225 | 226 | public function findInBuffersCount(): \PDOStatement 227 | { 228 | return $this->db->prepare(" 229 | WITH matching_backlog AS ( 230 | SELECT rowid FROM backlog_fts WHERE backlog_fts MATCH ('message: ' || :query) 231 | ) 232 | SELECT 233 | backlog.bufferid, 234 | COUNT(*) > (:limit + :offset) AS hasmore 235 | FROM 236 | backlog 237 | JOIN buffer ON backlog.bufferid = buffer.bufferid 238 | JOIN sender ON backlog.senderid = sender.senderid 239 | JOIN network ON buffer.networkid = network.networkid 240 | JOIN matching_backlog ON backlog.messageid = matching_backlog.rowid 241 | WHERE buffer.userid = :userid 242 | AND (:ignore_since OR backlog.time > CAST(strftime('%s', strftime('%Y-%m-%d', :since)) AS INT) * 1000) 243 | AND (:ignore_before OR backlog.time < CAST(strftime('%s', strftime('%Y-%m-%d', :before)) AS INT) * 1000) 244 | AND (:ignore_buffer OR buffer.buffername LIKE '%' || :buffer || '%') 245 | AND (:ignore_network OR network.networkname LIKE '%' || :network || '%') 246 | AND (:ignore_sender OR sender.sender LIKE '%' || :sender || '%') 247 | AND backlog.type & 23559 > 0 248 | GROUP BY backlog.bufferid; 249 | "); 250 | } 251 | 252 | public function findInBuffer(): \PDOStatement 253 | { 254 | $rankingFunction = $this->rankingFunction(); 255 | return $this->db->prepare(" 256 | WITH matching_backlog AS ( 257 | SELECT rowid, snippet(backlog_fts, 0, '$this->opening_tag', '$this->closing_tag', '...', 64) AS message, rank FROM backlog_fts WHERE backlog_fts MATCH ('message: ' || :query) 258 | ) 259 | SELECT 260 | matching_messages.messageid, 261 | matching_messages.time, 262 | datetime(matching_messages.time / 1000, 'unixepoch') AS time, 263 | sender.sender, 264 | replace(replace(replace(replace(matching_messages.message, '<', '<'), '>', '>'), '$this->opening_tag_marker', '<'), '$this->closing_tag_marker', '>') AS message 265 | FROM 266 | (SELECT 267 | backlog.messageid, 268 | backlog.bufferid, 269 | buffer.buffername, 270 | buffer.networkid, 271 | backlog.senderid, 272 | backlog.type, 273 | backlog.time, 274 | matching_backlog.message, 275 | $rankingFunction AS rank_value 276 | FROM 277 | backlog 278 | JOIN buffer ON backlog.bufferid = buffer.bufferid 279 | JOIN matching_backlog ON backlog.messageid = matching_backlog.rowid 280 | WHERE buffer.userid = :userid 281 | AND buffer.bufferid = :bufferid 282 | AND (:ignore_since OR backlog.time > CAST(strftime('%s', strftime('%Y-%m-%d', :since)) AS INT) * 1000) 283 | AND (:ignore_before OR backlog.time < CAST(strftime('%s', strftime('%Y-%m-%d', :before)) AS INT) * 1000) 284 | AND backlog.type & 23559 > 0 285 | ) matching_messages 286 | JOIN sender ON matching_messages.senderid = sender.senderid 287 | JOIN network ON matching_messages.networkid = network.networkid 288 | WHERE (:ignore_sender OR sender.sender LIKE '%' || :sender || '%') 289 | ORDER BY matching_messages.rank_value DESC 290 | LIMIT :limit 291 | OFFSET :offset 292 | "); 293 | } 294 | 295 | public function findInBufferCount(): \PDOStatement 296 | { 297 | return $this->db->prepare(" 298 | WITH matching_backlog AS ( 299 | SELECT rowid FROM backlog_fts WHERE backlog_fts MATCH ('message: ' || :query) 300 | ) 301 | SELECT 302 | COUNT(*) > (:limit + :offset) AS hasmore 303 | FROM 304 | backlog 305 | JOIN buffer ON backlog.bufferid = buffer.bufferid 306 | JOIN sender ON backlog.senderid = sender.senderid 307 | JOIN matching_backlog ON backlog.messageid = matching_backlog.rowid 308 | WHERE buffer.userid = :userid 309 | AND backlog.bufferid = :bufferid 310 | AND (:ignore_since OR backlog.time > CAST(strftime('%s', strftime('%Y-%m-%d', :since)) AS INT) * 1000) 311 | AND (:ignore_before OR backlog.time < CAST(strftime('%s', strftime('%Y-%m-%d', :before)) AS INT) * 1000) 312 | AND (:ignore_sender OR sender.sender LIKE '%' || :sender || '%') 313 | AND backlog.type & 23559 > 0 314 | GROUP BY backlog.bufferid; 315 | "); 316 | } 317 | 318 | public function loadAfter(): \PDOStatement 319 | { 320 | return $this->db->prepare(" 321 | SELECT backlog.messageid, 322 | backlog.bufferid, 323 | buffer.buffername, 324 | sender.sender, 325 | datetime(backlog.time / 1000, 'unixepoch') AS time, 326 | network.networkname, 327 | replace(replace(replace(backlog.message, '&', '&'), '<', '<'), '>', '>') AS message 328 | FROM 329 | backlog 330 | JOIN sender ON backlog.senderid = sender.senderid 331 | JOIN buffer ON backlog.bufferid = buffer.bufferid 332 | JOIN network ON buffer.networkid = network.networkid 333 | WHERE buffer.userid = :userid 334 | AND backlog.bufferid = :bufferid 335 | AND backlog.messageid > :anchor 336 | ORDER BY backlog.messageid ASC 337 | LIMIT :limit; 338 | "); 339 | } 340 | 341 | public function loadBefore(): \PDOStatement 342 | { 343 | return $this->db->prepare(" 344 | SELECT * FROM (SELECT backlog.messageid, 345 | backlog.bufferid, 346 | buffer.buffername, 347 | sender.sender, 348 | datetime(backlog.time / 1000, 'unixepoch') AS time, 349 | network.networkname, 350 | replace(replace(replace(backlog.message, '&', '&'), '<', '<'), '>', '>') AS message 351 | FROM 352 | backlog 353 | JOIN sender ON backlog.senderid = sender.senderid 354 | JOIN buffer ON backlog.bufferid = buffer.bufferid 355 | JOIN network ON buffer.networkid = network.networkid 356 | WHERE buffer.userid = :userid 357 | AND backlog.bufferid = :bufferid 358 | AND backlog.messageid < :anchor 359 | ORDER BY backlog.messageid DESC 360 | LIMIT :limit) t 361 | ORDER BY messageid ASC; 362 | "); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /database/helper/AuthHelper.php: -------------------------------------------------------------------------------- 1 | base64_decode($arr[0]), 57 | 'password' => base64_decode($arr[1]) 58 | ]; 59 | } 60 | } 61 | 62 | public static function generateAuthHeader(string $password, string $username): string 63 | { 64 | return base64_encode(base64_encode($username) . ":" . base64_encode($password)); 65 | } 66 | } -------------------------------------------------------------------------------- /database/helper/RendererHelper.php: -------------------------------------------------------------------------------- 1 | config = $config; 17 | $this->translator = new TranslationHelper($config); 18 | $this->sessionHelper = $sessionHelper; 19 | } 20 | 21 | public function renderError($e) 22 | { 23 | header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden'); 24 | header('Status: 403 Forbidden'); 25 | echo 'Error 403: Forbidden' . "\n"; 26 | echo $e . "\n"; 27 | } 28 | 29 | public function renderJsonError($json) 30 | { 31 | header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden'); 32 | header('Status: 403 Forbidden'); 33 | header('Content-Type: application/json'); 34 | echo json_encode($json) . "\n"; 35 | } 36 | 37 | public function renderJson($json) 38 | { 39 | header('Content-Type: application/json'); 40 | echo json_encode($json) . "\n"; 41 | } 42 | 43 | public function renderPage(string $template, array $vars = []) 44 | { 45 | $translation = $this->translator->loadTranslation($this->translator->findMatchingLanguage($_SERVER['HTTP_ACCEPT_LANGUAGE'])); 46 | $viewHelper = new ViewHelper($translation, array_merge($this->sessionHelper->vars, $vars)); 47 | $viewHelper->render($template); 48 | $this->sessionHelper->vars = []; 49 | } 50 | 51 | public function redirect(string $page, array $vars = []) 52 | { 53 | header('Location: ' . $this->config->path_prefix . $page); 54 | $this->sessionHelper->startSession(); 55 | $this->sessionHelper->vars = $vars; 56 | } 57 | } -------------------------------------------------------------------------------- /database/helper/SessionHelper.php: -------------------------------------------------------------------------------- 1 | startSession(); 26 | 27 | return self::$instance; 28 | } 29 | 30 | 31 | public function startSession(): bool 32 | { 33 | if ($this->sessionState == self::SESSION_NOT_STARTED) { 34 | $this->sessionState = session_start(); 35 | } 36 | 37 | return $this->sessionState; 38 | } 39 | 40 | public function __get(string $name) 41 | { 42 | if (isset($_SESSION[$name])) { 43 | return $_SESSION[$name]; 44 | } else { 45 | return null; 46 | } 47 | } 48 | 49 | public function __set(string $name, $value) 50 | { 51 | $_SESSION[$name] = $value; 52 | } 53 | 54 | public function __isset(string $name): bool 55 | { 56 | return isset($_SESSION[$name]); 57 | } 58 | 59 | 60 | public function __unset(string $name) 61 | { 62 | unset($_SESSION[$name]); 63 | } 64 | 65 | 66 | public function destroy(): bool 67 | { 68 | if ($this->sessionState == self::SESSION_STARTED) { 69 | $this->sessionState = !session_destroy(); 70 | unset($_SESSION); 71 | 72 | return !$this->sessionState; 73 | } 74 | 75 | return !$this->sessionState; 76 | } 77 | } -------------------------------------------------------------------------------- /database/helper/TranslationHelper.php: -------------------------------------------------------------------------------- 1 | setPath('../../translations/'); 12 | } 13 | 14 | public function setPath(string $path) 15 | { 16 | $this->template_dir = realpath(dirname(__FILE__) . '/' . $path); 17 | } 18 | 19 | public function findMatchingLanguage(string $language_str): string 20 | { 21 | $languages = explode(",", $language_str); 22 | foreach ($languages as $language) { 23 | $language = explode(";", $language)[0]; 24 | if ($this->exists($language)) { 25 | return $language; 26 | } 27 | } 28 | return "en"; 29 | } 30 | 31 | public function exists($language): bool 32 | { 33 | return file_exists($this->path($language)); 34 | } 35 | 36 | private function path($language): string 37 | { 38 | return $this->template_dir . '/' . $language . '.json'; 39 | } 40 | 41 | public function loadTranslation($language): array 42 | { 43 | return json_decode(file_get_contents($this->path($language)), true); 44 | } 45 | } -------------------------------------------------------------------------------- /database/helper/ViewHelper.php: -------------------------------------------------------------------------------- 1 | translation = $translation; 14 | $this->setPath('../../templates/'); 15 | if ($vars !== null) { 16 | $this->vars = $vars; 17 | } 18 | } 19 | 20 | public function setPath(string $path) 21 | { 22 | $this->template_dir = realpath(dirname(__FILE__) . '/' . $path); 23 | } 24 | 25 | public function render($template_file) 26 | { 27 | $translation = $this->translation; 28 | $t = function ($path) use ($translation) { 29 | $arr = explode(".", $path); 30 | $var = $translation; 31 | foreach ($arr as $key) 32 | $var = $var[$key]; 33 | echo $var; 34 | }; 35 | $vars = $this->vars; 36 | 37 | $path = $this->template_dir . '/' . $template_file . '.phtml'; 38 | if (file_exists($path)) { 39 | include $path; 40 | } else { 41 | throw new \Exception('Template ' . $path . ' not found '); 42 | } 43 | } 44 | 45 | public function __get($name) 46 | { 47 | return $this->vars[$name]; 48 | } 49 | 50 | public function __set($name, $value) 51 | { 52 | $this->vars[$name] = $value; 53 | } 54 | } -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/favicon.ico -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/favicon.png -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | authenticate($session->username ?: '', $session->password ?: '')) { 16 | $session->destroy(); 17 | $renderer->redirect('/login.php', ['message' => 'login.message.error_unauthed', 'type' => 'error']); 18 | } else { 19 | $renderer->renderPage('search', ['username' => $session->username]); 20 | } -------------------------------------------------------------------------------- /login.php: -------------------------------------------------------------------------------- 1 | authenticate($username, $password)) { 19 | $session->username = $username; 20 | $session->password = $password; 21 | $renderer->redirect('/'); 22 | } else { 23 | syslog(LOG_ERR, "Could not authenticate user " . $username); 24 | $renderer->redirect('/login.php', ['message' => 'login.message.error_invalid', 'type' => 'error']); 25 | } 26 | } elseif (isset($_GET['action']) && $_GET['action'] === 'logout') { 27 | $session->destroy(); 28 | $renderer->redirect('/login.php', ['message' => 'login.message.success_logout', 'type' => 'info']); 29 | } else if ($backend->authenticate($session->username ?: '', $session->password ?: '')) { 30 | $renderer->redirect('/'); 31 | } else { 32 | $renderer->renderPage('login'); 33 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quassel-rest-search", 3 | "version": "3.0.0", 4 | "description": "This is a websearch frontend for a quassel database.", 5 | "scripts": { 6 | "jsx": "node_modules/nativejsx/bin/nativejsx res/js/**/*.jsx", 7 | "dep": "cp node_modules/nativejsx/dist/nativejsx-prototypes.js res/js/util/", 8 | "sass": "node_modules/node-sass/bin/node-sass --style compressed res/css/ -o res/css/" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/justjanne/quassel-rest-search.git" 13 | }, 14 | "author": "Janne Koschinski", 15 | "license": "GPL", 16 | "bugs": { 17 | "url": "https://github.com/justjanne/quassel-rest-search/issues" 18 | }, 19 | "homepage": "https://github.com/justjanne/quassel-rest-search#readme", 20 | "devDependencies": { 21 | "acorn": ">=5.7.4", 22 | "nativejsx": "https://github.com/j3l11234/nativejsx/archive/4.2.0.2.tar.gz", 23 | "node-sass": "^7.0.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /qrs_config.default.php: -------------------------------------------------------------------------------- 1 | .container 42 | > .inline-button 43 | display: none 44 | 45 | .title 46 | border-bottom: 1px solid transparent 47 | border-top: 1px solid transparent 48 | color: #616161 49 | margin: 0 50 | padding: 20px 6px 10px 22px 51 | position: sticky 52 | top: 55px 53 | z-index: 1 54 | transition: all 400ms 55 | display: flex 56 | 57 | &:before 58 | content: "" 59 | display: block 60 | left: -16px 61 | right: -16px 62 | top: 0 63 | bottom: 0 64 | background-color: #f2f2f2 65 | position: absolute 66 | z-index: -2 67 | 68 | @media(max-width: 800px) 69 | left: 0 70 | right: 0 71 | 72 | &:after 73 | content: "" 74 | display: block 75 | left: 0 76 | right: 0 77 | top: 0 78 | bottom: 0 79 | background-color: transparent 80 | transition: margin-left 400ms, margin-right 400ms, background-color 400ms 81 | will-change: margin-left, margin-right, background-color 82 | position: absolute 83 | z-index: -1 84 | 85 | h2 86 | flex: 1 87 | line-height: 36px 88 | 89 | button 90 | border-radius: 3px 91 | box-shadow: none 92 | color: #444444 93 | text-transform: uppercase 94 | border: none 95 | position: relative 96 | font-weight: normal 97 | margin-bottom: 0 98 | text-align: center 99 | vertical-align: middle 100 | touch-action: manipulation 101 | background: transparent none 102 | white-space: nowrap 103 | padding: 6px 16px 104 | font-size: 13px 105 | line-height: 1.846 106 | @include vendor-prefix('user-select', 'none') 107 | text-decoration: none 108 | box-sizing: border-box 109 | font-family: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif 110 | will-change: background 111 | opacity: 0 112 | pointer-events: none 113 | cursor: none 114 | transition: opacity 0.4s 0.2s, background 0.4s 115 | 116 | &:hover 117 | background-color: rgba(0, 0, 0, 0.06) 118 | 119 | &:after 120 | content: "" 121 | display: block 122 | position: absolute 123 | width: 100% 124 | height: 100% 125 | top: 0 126 | left: 0 127 | background: radial-gradient(circle, #444444 10%, transparent 10.01%) no-repeat 50% 128 | background-size: 1000% 1000% 129 | opacity: 0 130 | pointer-events: none 131 | transition: background .5s, opacity 1s 132 | 133 | &:active 134 | &:after 135 | background-size: 0 0 136 | opacity: .2 137 | transition: 0s 138 | 139 | &:not(.focus) .secondary 140 | display: none 141 | 142 | &:before 143 | content: "" 144 | display: block 145 | left: 0 146 | right: 0 147 | top: 0 148 | bottom: 0 149 | background-color: transparent 150 | transition: margin-left 400ms, margin-right 400ms, margin-bottom 400ms, background-color 400ms 151 | will-change: margin-left, margin-right, margin-bottom, background-color 152 | position: absolute 153 | z-index: -1 154 | 155 | &.focus 156 | &:before 157 | margin-bottom: -16px 158 | background-color: #ddd 159 | 160 | @media(min-width: 800px) 161 | margin-left: -16px 162 | margin-right: -16px 163 | 164 | .title 165 | &:after 166 | background-color: #ddd 167 | 168 | @media(min-width: 800px) 169 | margin-left: -16px 170 | margin-right: -16px 171 | 172 | button 173 | opacity: 1 174 | display: inline-block 175 | cursor: pointer 176 | pointer-events: initial 177 | 178 | .container 179 | font-size: 13px 180 | 181 | > .inline-button 182 | overflow: hidden 183 | font-size: 14px 184 | line-height: 24px 185 | padding: 7px 24px 186 | border-bottom: 1px solid #e5e5e5 187 | color: #555 188 | background: #f0f0f0 189 | cursor: pointer 190 | @include vendor-prefix('user-select', 'none') 191 | transition: height 400ms 192 | position: relative 193 | box-shadow: 0 -1px 0 #e5e5e5, 0 0 2px rgba(0, 0, 0, .12), 0 2px 4px rgba(0, 0, 0, .24) 194 | 195 | &:before 196 | content: "" 197 | display: inline-block 198 | height: 24px 199 | width: 24px 200 | background-image: url(../icons/dots-horizontal.svg) 201 | background-size: cover 202 | vertical-align: top 203 | margin-right: 16px 204 | 205 | &.before> .inline-button:before 206 | background-image: url(../icons/chevron-up.svg) 207 | 208 | &.after > .inline-button:before 209 | background-image: url(../icons/chevron-down.svg) 210 | 211 | .context 212 | display: block 213 | position: relative 214 | animation-duration: 1s 215 | animation-name: slidein 216 | 217 | .container .inline-button:first-child:last-child 218 | display: none 219 | 220 | &.focus 221 | padding: 0 222 | margin: 0.5rem -0.5rem 223 | 224 | @media(max-width: 800px) 225 | margin: 1rem 0 226 | 227 | .container.before 228 | .message, .inline-button 229 | animation-name: slidein_msg_before 230 | animation-duration: 400ms 231 | 232 | .container.after 233 | .message, .inline-button 234 | animation-name: slidein_msg_after 235 | animation-duration: 400ms 236 | 237 | &:not(.focus) 238 | > .before, > .after 239 | display: none 240 | 241 | > .inline-button 242 | display: none 243 | 244 | .message 245 | display: flex 246 | line-height: 20px 247 | padding: 12px 24px 248 | border-bottom: 1px solid #e5e5e5 249 | color: #212121 250 | background: #fff 251 | position: relative 252 | 253 | .invisible 254 | font-size: 0 255 | opacity: 0 256 | 257 | &:before 258 | bottom: 0 259 | box-shadow: 0 -1px 0 #e5e5e5, 0 0 2px rgba(0, 0, 0, .12), 0 2px 4px rgba(0, 0, 0, .24) 260 | content: '' 261 | display: block 262 | left: 0 263 | pointer-events: none 264 | position: absolute 265 | right: 0 266 | top: 0 267 | 268 | > .more 269 | height: 56px 270 | width: 56px 271 | position: relative 272 | padding: 16px 13px 273 | cursor: pointer 274 | color: #555 275 | transition: all .15s 276 | text-decoration: none 277 | text-align: center 278 | margin-right: -24px 279 | margin-top: -12px 280 | @include vendor-prefix('user-select', 'none') 281 | 282 | &:before 283 | background-color: rgba(255, 255, 255, .12) 284 | bottom: 9px 285 | content: ' ' 286 | left: 9px 287 | margin: auto 288 | padding: 4px 289 | position: absolute 290 | right: 9px 291 | top: 9px 292 | z-index: -1 293 | border-radius: 50% 294 | opacity: 0 295 | transition: opacity 100ms 296 | 297 | &:hover:before 298 | opacity: 1 299 | 300 | time 301 | width: 136px 302 | display: inline-block 303 | text-align: right 304 | flex-shrink: 0 305 | font-family: monospace 306 | font-size: 85% 307 | color: #444 308 | 309 | @media(max-width: 800px) 310 | position: absolute 311 | right: 8px 312 | bottom: 8px 313 | width: initial 314 | font-style: italic 315 | color: #777 316 | 317 | .container 318 | display: flex 319 | flex-grow: 1 320 | flex-shrink: 1 321 | overflow: hidden 322 | 323 | @media(max-width: 800px) 324 | display: block 325 | padding-bottom: 16px 326 | 327 | .sender 328 | width: 148px 329 | display: inline-block 330 | padding: 0 24px 331 | font-weight: bold 332 | flex-shrink: 0 333 | overflow: hidden 334 | text-overflow: ellipsis 335 | 336 | @media(max-width: 800px) 337 | width: initial 338 | padding: 0 339 | display: inline 340 | line-height: 1 341 | 342 | &:after 343 | @media(max-width: 800px) 344 | content: ': ' 345 | 346 | .content 347 | flex-grow: 1 348 | overflow: hidden 349 | 350 | .irc_highlight 351 | background-color: rgba(251, 246, 167, 0.5) 352 | color: #212121 !important 353 | 354 | @media(max-width: 800px) 355 | vertical-align: top 356 | display: inline 357 | 358 | span 359 | vertical-align: top 360 | &.action 361 | .sender, .content 362 | font-style: italic 363 | 364 | .sender 365 | &:before 366 | content: ' -*- ' 367 | color: #1a237e 368 | 369 | .content 370 | color: #1a237e 371 | 372 | &.notice 373 | .content, .sender 374 | color: #916409 375 | 376 | .sender 377 | &:before 378 | content: '[' 379 | &:after 380 | content: ']' -------------------------------------------------------------------------------- /res/css/_font.sass: -------------------------------------------------------------------------------- 1 | @font-face 2 | font-family: 'Roboto' 3 | font-style: normal 4 | font-weight: 400 5 | src: local('Roboto'), local('Roboto-Regular'), url('../fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff2') format('woff2'), url('../fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff') format('woff') 6 | 7 | @font-face 8 | font-family: 'Roboto' 9 | font-style: normal 10 | font-weight: 700 11 | src: local('Roboto Bold'), local('Roboto-Bold'), url('../fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff2') format('woff2'), url('../fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff') format('woff') -------------------------------------------------------------------------------- /res/css/_icons.sass: -------------------------------------------------------------------------------- 1 | @font-face 2 | font-family: 'Material Icons' 3 | font-style: normal 4 | font-weight: 400 5 | src: local('Material Icons'), local('MaterialIcons-Regular'), url(../icons/MaterialIcons-Regular.woff2) format('woff2'), url(../icons/MaterialIcons-Regular.woff) format('woff') 6 | 7 | .icon 8 | font-family: 'Material Icons', sans-serif 9 | font-weight: normal 10 | font-style: normal 11 | font-size: 24px 12 | display: inline-block 13 | width: 1em 14 | height: 1em 15 | line-height: 1 16 | text-transform: none 17 | letter-spacing: normal 18 | word-wrap: normal 19 | white-space: nowrap 20 | direction: ltr 21 | 22 | /* Support for all WebKit browsers. */ 23 | -webkit-font-smoothing: antialiased 24 | /* Support for Safari and Chrome. */ 25 | text-rendering: optimizeLegibility 26 | 27 | /* Support for Firefox. */ 28 | -moz-osx-font-smoothing: grayscale 29 | 30 | /* Support for IE. */ 31 | font-feature-settings: 'liga' -------------------------------------------------------------------------------- /res/css/_loading.sass: -------------------------------------------------------------------------------- 1 | .progress 2 | position: absolute 3 | height: 4px 4 | display: block 5 | background-color: rgba(255,255,255,0.2) 6 | overflow: hidden 7 | transition: opacity 400ms 8 | bottom: 0 9 | left: 0 10 | right: 0 11 | 12 | &:not(.visible) 13 | opacity: 0 14 | 15 | .indeterminate 16 | background-color: rgba(255,255,255,0.8) 17 | 18 | &:before 19 | content: '' 20 | position: absolute 21 | background-color: inherit 22 | top: 0 23 | left: 0 24 | bottom: 0 25 | will-change: left, right 26 | animation: indeterminate 4.2s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite 27 | 28 | &:after 29 | content: '' 30 | position: absolute 31 | background-color: inherit 32 | top: 0 33 | left: 0 34 | bottom: 0 35 | will-change: left, right 36 | animation: indeterminate-short 4.2s cubic-bezier(0.165, 0.84, 0.44, 1) infinite 37 | animation-delay: 2.25s 38 | 39 | -------------------------------------------------------------------------------- /res/css/_mirccolor.sass: -------------------------------------------------------------------------------- 1 | .irc_bold 2 | font-weight: bold 3 | 4 | .irc_italic 5 | font-style: italic 6 | 7 | .irc_underline 8 | text-decoration: underline 9 | 10 | .irc_strikethrough 11 | text-decoration: line-through 12 | 13 | .irc_strikethrough.irc_underline 14 | text-decoration: line-through underline 15 | 16 | .irc_monospace 17 | font-family: 'Roboto Mono', 'Source Code Pro', monospace 18 | 19 | $mircColors: ("0": #ffffff, "1": #000000, "2": #000080, "3": #008000, "4": #ff0000, "5": #800000, "6": #800080, "7": #ffa500, "8": #ffff00, "9": #00ff00, "10": #008080, "11": #00ffff, "12": #4169e1, "13": #ff00ff, "14": #808080, "15": #c0c0c0, "16": #470000, "17": #472100, "18": #474700, "19": #324700, "20": #004700, "21": #00472c, "22": #004747, "23": #002747, "24": #000047, "25": #2e0047, "26": #470047, "27": #47002a, "28": #740000, "29": #743a00, "30": #747400, "31": #517400, "32": #007400, "33": #007449, "34": #007474, "35": #004074, "36": #000074, "37": #4b0074, "38": #740074, "39": #740045, "40": #b50000, "41": #b56300, "42": #b5b500, "43": #7db500, "44": #00b500, "45": #00b571, "46": #00b5b5, "47": #0063b5, "48": #0000b5, "49": #7500b5, "50": #b500b5, "51": #b5006b, "52": #ff0000, "53": #ff8c00, "54": #ffff00, "55": #b2ff00, "56": #00ff00, "57": #00ffa0, "58": #00ffff, "59": #008cff, "60": #0000ff, "61": #a500ff, "62": #ff00ff, "63": #ff0098, "64": #ff5959, "65": #ffb459, "66": #ffff71, "67": #cfff60, "68": #6fff6f, "69": #65ffc9, "70": #6dffff, "71": #59b4ff, "72": #5959ff, "73": #c459ff, "74": #ff66ff, "75": #ff59bc, "76": #ff9c9c, "77": #ffd39c, "78": #ffff9c, "79": #e2ff9c, "80": #9cff9c, "81": #9cffdb, "82": #9cffff, "83": #9cd3ff, "84": #9c9cff, "85": #dc9cff, "86": #ff9cff, "87": #ff94d3, "88": #000000, "89": #131313, "90": #282828, "91": #363636, "92": #4d4d4d, "93": #656565, "94": #818181, "95": #9f9f9f, "96": #bcbcbc, "97": #e2e2e2, "98": #ffffff) 20 | 21 | @each $i, $color in $mircColors 22 | [data-irc_foreground="#{$i}"] 23 | color: $color 24 | 25 | [data-irc_background="#{$i}"] 26 | background-color: $color -------------------------------------------------------------------------------- /res/css/_nav.sass: -------------------------------------------------------------------------------- 1 | @import "util" 2 | 3 | .nav 4 | position: fixed 5 | left: 0 6 | right: 0 7 | top: 0 8 | height: 56px 9 | z-index: 2 10 | 11 | .bar 12 | background-color: #0a70c0 13 | z-index: 1 14 | position: absolute 15 | top: 0 16 | left: 0 17 | right: 0 18 | box-shadow: 0 -1px 0 #e0e0e0, 0 0 2px rgba(0, 0, 0, .12), 0 2px 4px rgba(0, 0, 0, .24) 19 | transition: background-color .15s 20 | will-change: background-color 21 | 22 | .container 23 | max-width: 1136px 24 | margin: 0 auto 25 | padding: 0 2rem 26 | display: flex 27 | 28 | @media(max-width: 800px) 29 | padding: 0 0 0 8px 30 | 31 | .searchBar 32 | margin: 8px auto 33 | height: 40px 34 | display: flex 35 | position: relative 36 | border: none 37 | background: #348bcf 38 | border-radius: 2px 39 | padding: 0 0 0 72px 40 | color: #ffffff 41 | flex-direction: row 42 | flex: 1 43 | box-shadow: 0 1px 1px rgba(0, 0, 0, .06) 44 | transition: background-color .15s 45 | 46 | &:hover 47 | background-color: rgba(255, 255, 255, .3) 48 | 49 | .icon 50 | display: inline-block 51 | width: 72px 52 | height: 40px 53 | position: absolute 54 | text-align: center 55 | line-height: 40px 56 | left: 0 57 | top: 0 58 | cursor: pointer 59 | 60 | .search 61 | display: inline-block 62 | flex-grow: 1 63 | flex-shrink: 1 64 | background: none 65 | border: none 66 | line-height: 100% 67 | color: #ffffff 68 | font-size: 1rem 69 | margin-right: 8px 70 | -webkit-appearance: textfield 71 | -webkit-box-sizing: content-box 72 | 73 | &::-moz-placeholder 74 | color: #ffffff 75 | opacity: 1 76 | -moz-osx-font-smoothing: grayscale 77 | 78 | &::-webkit-input-placeholder 79 | color: #ffffff 80 | opacity: 1 81 | -webkit-font-smoothing: antialiased 82 | 83 | &::-webkit-search-decoration, &::-webkit-search-cancel-button 84 | display: none 85 | 86 | .actions 87 | display: flex 88 | align-items: center 89 | justify-content: flex-end 90 | 91 | a 92 | height: 56px 93 | width: 56px 94 | position: relative 95 | padding: 16px 13px 96 | cursor: pointer 97 | color: #fff 98 | transition: all .15s 99 | text-decoration: none 100 | text-align: center 101 | @include vendor-prefix('user-select', 'none') 102 | 103 | &:before 104 | background-color: rgba(255, 255, 255, .12) 105 | bottom: 9px 106 | content: ' ' 107 | left: 9px 108 | margin: auto 109 | padding: 4px 110 | position: absolute 111 | right: 9px 112 | top: 9px 113 | z-index: -1 114 | border-radius: 50% 115 | opacity: 0 116 | transition: opacity 100ms 117 | 118 | &:hover:before 119 | opacity: 1 120 | 121 | .history 122 | top: 100% 123 | max-width: 1136px 124 | margin: auto 125 | padding: 0 2rem 126 | transform: translateY(-200%) 127 | transition: transform 400ms 128 | position: relative 129 | 130 | @media(max-width: 800px) 131 | padding: 0 132 | 133 | ul 134 | list-style-type: none 135 | margin: 0 136 | padding: 6px 0 137 | background: #fff 138 | box-shadow: 0 -1px 0 #e0e0e0, 0 0 2px rgba(0, 0, 0, .12), 0 2px 4px rgba(0, 0, 0, .24) 139 | 140 | li 141 | cursor: pointer 142 | line-height: 36px 143 | overflow: hidden 144 | padding: 0 24px 145 | text-overflow: ellipsis 146 | white-space: nowrap 147 | 148 | &:hover, &:focus, &.focus 149 | background: rgba(0, 0, 0, 0.03) 150 | 151 | .icon 152 | border-radius: 12px 153 | height: 24px 154 | margin-right: 24px 155 | vertical-align: middle 156 | width: 24px 157 | display: inline-block 158 | background-size: cover 159 | opacity: 0.6 160 | 161 | p 162 | cursor: default 163 | line-height: 36px 164 | overflow: hidden 165 | padding: 0 24px 166 | text-overflow: ellipsis 167 | white-space: nowrap 168 | font-style: italic 169 | color: #646464 170 | 171 | &.focus 172 | .bar 173 | background: #f2f2f2 174 | 175 | .container 176 | 177 | .searchBar 178 | background: #ffffff 179 | 180 | .search 181 | color: #333333 182 | 183 | &::-moz-placeholder 184 | color: #757575 185 | 186 | &::-webkit-input-placeholder 187 | color: #757575 188 | 189 | .icon 190 | color: #757575 191 | 192 | .progress 193 | background-color: rgba(117, 117, 117, 0.2) 194 | 195 | .indeterminate 196 | background-color: rgba(117, 117, 117, 0.8) 197 | 198 | .history 199 | transform: translateY(0) 200 | 201 | & + .results 202 | opacity: 0 -------------------------------------------------------------------------------- /res/css/_searchoptions.sass: -------------------------------------------------------------------------------- 1 | .search-options 2 | margin: 0 auto 3 | padding: 6px 0 4 | background: #fff 5 | box-shadow: 0 -1px 0 #e0e0e0, 0 0 2px rgba(0, 0, 0, .12), 0 2px 4px rgba(0, 0, 0, .24) 6 | max-width: 1072px 7 | overflow-x: scroll 8 | white-space: nowrap -------------------------------------------------------------------------------- /res/css/_sendercolor.sass: -------------------------------------------------------------------------------- 1 | [data-sendercolor="0"] 2 | color: #e90d7f 3 | 4 | [data-sendercolor="1"] 5 | color: #8e55e9 6 | 7 | [data-sendercolor="2"] 8 | color: #b30e0e 9 | 10 | [data-sendercolor="3"] 11 | color: #17b339 12 | 13 | [data-sendercolor="4"] 14 | color: #58afb3 15 | 16 | [data-sendercolor="5"] 17 | color: #9d54b3 18 | 19 | [data-sendercolor="6"] 20 | color: #b39775 21 | 22 | [data-sendercolor="7"] 23 | color: #3176b3 24 | 25 | [data-sendercolor="8"] 26 | color: #e90d7f 27 | 28 | [data-sendercolor="9"] 29 | color: #8e55e9 30 | 31 | [data-sendercolor="a"] 32 | color: #b30e0e 33 | 34 | [data-sendercolor="b"] 35 | color: #17b339 36 | 37 | [data-sendercolor="c"] 38 | color: #58afb3 39 | 40 | [data-sendercolor="d"] 41 | color: #9d54b3 42 | 43 | [data-sendercolor="e"] 44 | color: #b39775 45 | 46 | [data-sendercolor="f"] 47 | color: #3176b3 -------------------------------------------------------------------------------- /res/css/_textfield.sass: -------------------------------------------------------------------------------- 1 | .textfield-floating-label -------------------------------------------------------------------------------- /res/css/_util.sass: -------------------------------------------------------------------------------- 1 | @mixin vendor-prefix($name, $value) 2 | @each $vendor in ('-webkit-', '-moz-', '-ms-', '-o-', '') 3 | #{$vendor}#{$name}: #{$value} -------------------------------------------------------------------------------- /res/css/login.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Material Icons'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local("Material Icons"), local("MaterialIcons-Regular"), url(../icons/MaterialIcons-Regular.woff2) format("woff2"), url(../icons/MaterialIcons-Regular.woff) format("woff"); } 6 | 7 | .icon { 8 | font-family: 'Material Icons', sans-serif; 9 | font-weight: normal; 10 | font-style: normal; 11 | font-size: 24px; 12 | display: inline-block; 13 | width: 1em; 14 | height: 1em; 15 | line-height: 1; 16 | text-transform: none; 17 | letter-spacing: normal; 18 | word-wrap: normal; 19 | white-space: nowrap; 20 | direction: ltr; 21 | /* Support for all WebKit browsers. */ 22 | -webkit-font-smoothing: antialiased; 23 | /* Support for Safari and Chrome. */ 24 | text-rendering: optimizeLegibility; 25 | /* Support for Firefox. */ 26 | -moz-osx-font-smoothing: grayscale; 27 | /* Support for IE. */ 28 | font-feature-settings: 'liga'; } 29 | 30 | @font-face { 31 | font-family: 'Roboto'; 32 | font-style: normal; 33 | font-weight: 400; 34 | src: local("Roboto"), local("Roboto-Regular"), url("../fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff2") format("woff2"), url("../fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff") format("woff"); } 35 | 36 | @font-face { 37 | font-family: 'Roboto'; 38 | font-style: normal; 39 | font-weight: 700; 40 | src: local("Roboto Bold"), local("Roboto-Bold"), url("../fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff2") format("woff2"), url("../fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff") format("woff"); } 41 | 42 | * { 43 | padding: 0; 44 | margin: 0; 45 | box-sizing: border-box; 46 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } 47 | 48 | html { 49 | background: #F2F2F2; } 50 | 51 | body { 52 | background: #F2F2F2; 53 | font-family: 'Roboto', sans-serif; 54 | font-size: 81.25%; 55 | display: flex; 56 | flex-direction: column; 57 | min-height: 100vh; 58 | position: relative; 59 | padding-right: .9375rem; 60 | padding-left: .9375rem; } 61 | body .header, body .footer { 62 | flex-grow: 1; } 63 | 64 | *:focus { 65 | outline: none; } 66 | 67 | ::-moz-focus-inner { 68 | border: 0; } 69 | 70 | form { 71 | background: #fff; 72 | max-width: 320px; 73 | width: 100%; 74 | padding: 20px 48px; 75 | margin: 0 auto; 76 | color: #212121; 77 | box-shadow: 0 -1px 0 #e5e5e5, 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.24); } 78 | @media (max-width: 320px) { 79 | form { 80 | padding: 8px 16px; 81 | margin: initial; 82 | width: initial; 83 | max-width: initial; } } 84 | 85 | .header { 86 | display: flex; 87 | flex-direction: column; } 88 | .header:before { 89 | content: ""; 90 | flex-grow: 1; } 91 | 92 | h1 { 93 | color: #555; 94 | font-size: 32px; 95 | font-weight: 300; 96 | margin: 5px 0 15px; 97 | line-height: 1.5; 98 | text-align: center; } 99 | 100 | h2 { 101 | color: #555; 102 | font-size: 18px; 103 | font-weight: 400; 104 | margin: 5px 0 15px; 105 | line-height: 1.5; 106 | text-align: center; } 107 | 108 | input[type=text], input[type=password] { 109 | -webkit-appearance: none; 110 | -moz-appearance: none; 111 | -ms-appearance: none; 112 | -o-appearance: none; 113 | appearance: none; 114 | appearance: none; 115 | height: 36px; 116 | padding: 0 8px; 117 | background: #fff; 118 | border: 1px solid #d9d9d9; 119 | border-top: 1px solid #c0c0c0; 120 | box-sizing: border-box; 121 | border-radius: 1px; 122 | font-size: 15px; 123 | color: #404040; 124 | width: 100%; 125 | display: block; 126 | margin: 0 0 10px; 127 | z-index: 1; 128 | position: relative; } 129 | 130 | input[type="submit"] { 131 | display: inline-block; 132 | position: relative; 133 | border: 0 none; 134 | white-space: nowrap; 135 | text-align: center; 136 | font: inherit; 137 | text-transform: uppercase; 138 | text-decoration: none; 139 | outline: 0 none; 140 | border-radius: 3px; 141 | cursor: pointer; 142 | padding: 8px 16px; 143 | font-weight: 500; 144 | line-height: 20px; 145 | font-size: 14px; 146 | -webkit-user-select: none; 147 | -moz-user-select: none; 148 | -ms-user-select: none; 149 | -o-user-select: none; 150 | user-select: none; 151 | min-width: 88px; 152 | transition: background-size 500ms cubic-bezier(0.98, 0.005, 0.79, 1.005), background-color 500ms cubic-bezier(0.98, 0.005, 0.79, 1.005), color 500ms cubic-bezier(0.98, 0.005, 0.79, 1.005), box-shadow 300ms; 153 | background: #0a70c0 radial-gradient(circle, #105a94 10%, rgba(0, 0, 0, 0) 10.001%, rgba(0, 0, 0, 0)) no-repeat center center; 154 | background-size: 0; 155 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2); 156 | color: #fff; 157 | width: 100%; 158 | margin: 20px 0 15px; } 159 | input[type="submit"]:hover, input[type="submit"]:focus { 160 | background-color: #0a70c0; 161 | background-size: 1200%; 162 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.21), 0 3px 1px -2px rgba(0, 0, 0, 0.18), 0 1px 5px 0 rgba(0, 0, 0, 0.3); } 163 | input[type="submit"]:active { 164 | box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2); } 165 | 166 | form .message { 167 | margin: 0 0 1rem 0; 168 | font-size: 14px; } 169 | 170 | form .message.error { 171 | color: #b71c1c; } 172 | -------------------------------------------------------------------------------- /res/css/login.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "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", 4 | "sources": ["_icons.sass","_font.sass","login.sass"], 5 | "names": [], 6 | "file": "login.css" 7 | } -------------------------------------------------------------------------------- /res/css/login.sass: -------------------------------------------------------------------------------- 1 | @import "util" 2 | @import "icons" 3 | @import "font" 4 | 5 | * 6 | padding: 0 7 | margin: 0 8 | box-sizing: border-box 9 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0) 10 | 11 | html 12 | background: #F2F2F2 13 | 14 | body 15 | background: #F2F2F2 16 | font-family: 'Roboto', sans-serif 17 | font-size: 81.25% 18 | display: flex 19 | flex-direction: column 20 | min-height: 100vh 21 | position: relative 22 | padding-right: .9375rem 23 | padding-left: .9375rem 24 | 25 | .header, .footer 26 | flex-grow: 1 27 | 28 | *:focus 29 | outline: none 30 | 31 | ::-moz-focus-inner 32 | border: 0 33 | 34 | form 35 | background: #fff 36 | max-width: 320px 37 | width: 100% 38 | padding: 20px 48px 39 | margin: 0 auto 40 | color: #212121 41 | box-shadow: 0 -1px 0 #e5e5e5, 0 0 2px rgba(0, 0, 0, .12), 0 2px 4px rgba(0, 0, 0, .24) 42 | 43 | @media(max-width: 320px) 44 | padding: 8px 16px 45 | margin: initial 46 | width: initial 47 | max-width: initial 48 | 49 | .header 50 | display: flex 51 | flex-direction: column 52 | 53 | &:before 54 | content: "" 55 | flex-grow: 1 56 | 57 | h1 58 | color: #555 59 | font-size: 32px 60 | font-weight: 300 61 | margin: 5px 0 15px 62 | line-height: 1.5 63 | text-align: center 64 | 65 | h2 66 | color: #555 67 | font-size: 18px 68 | font-weight: 400 69 | margin: 5px 0 15px 70 | line-height: 1.5 71 | text-align: center 72 | 73 | input[type=text], input[type=password] 74 | @include vendor-prefix('appearance', 'none') 75 | appearance: none 76 | height: 36px 77 | padding: 0 8px 78 | background: #fff 79 | border: 1px solid #d9d9d9 80 | border-top: 1px solid #c0c0c0 81 | box-sizing: border-box 82 | border-radius: 1px 83 | font-size: 15px 84 | color: #404040 85 | width: 100% 86 | display: block 87 | margin: 0 0 10px 88 | z-index: 1 89 | position: relative 90 | 91 | input[type="submit"] 92 | display: inline-block 93 | position: relative 94 | border: 0 none 95 | white-space: nowrap 96 | text-align: center 97 | font: inherit 98 | text-transform: uppercase 99 | text-decoration: none 100 | outline: 0 none 101 | border-radius: 3px 102 | cursor: pointer 103 | padding: 8px 16px 104 | font-weight: 500 105 | line-height: 20px 106 | font-size: 14px 107 | @include vendor-prefix('user-select', 'none') 108 | min-width: 88px 109 | transition: background-size 500ms cubic-bezier(0.98, 0.005, 0.79, 1.005), background-color 500ms cubic-bezier(0.98, 0.005, 0.79, 1.005), color 500ms cubic-bezier(0.98, 0.005, 0.79, 1.005), box-shadow 300ms 110 | background: #0a70c0 radial-gradient(circle, #105a94 10%, rgba(0, 0, 0, 0) 10.001%, rgba(0, 0, 0, 0)) no-repeat center center 111 | background-size: 0 112 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2) 113 | color: #fff 114 | width: 100% 115 | margin: 20px 0 15px 116 | 117 | &:hover, &:focus 118 | background-color: #0a70c0 119 | background-size: 1200% 120 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.21), 0 3px 1px -2px rgba(0, 0, 0, 0.18), 0 1px 5px 0 rgba(0, 0, 0, 0.3) 121 | 122 | &:active 123 | box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2) 124 | 125 | form .message 126 | margin: 0 0 1rem 0 127 | font-size: 14px 128 | 129 | form .message.error 130 | color: #b71c1c -------------------------------------------------------------------------------- /res/css/search.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "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", 4 | "sources": [ 5 | "_icons.sass", 6 | "_font.sass", 7 | "search.sass", 8 | "_nav.sass", 9 | "_util.sass", 10 | "_searchoptions.sass", 11 | "_sendercolor.sass", 12 | "_mirccolor.sass", 13 | "_content.sass" 14 | ], 15 | "names": [], 16 | "file": "search.css" 17 | } -------------------------------------------------------------------------------- /res/css/search.sass: -------------------------------------------------------------------------------- 1 | @import "util" 2 | @import "icons" 3 | @import "font" 4 | 5 | * 6 | padding: 0 7 | margin: 0 8 | box-sizing: border-box 9 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0) 10 | 11 | body 12 | background: #F2F2F2 13 | font-family: 'Roboto', sans-serif 14 | font-size: 81.25% 15 | padding-top: 56px 16 | 17 | *:focus 18 | outline: none 19 | 20 | ::-moz-focus-inner 21 | border: 0 22 | 23 | *.hidden 24 | visibility: hidden !important 25 | display: none !important 26 | 27 | @import "loading" 28 | @import "nav" 29 | @import "searchoptions" 30 | @import "content" 31 | @import "animations" -------------------------------------------------------------------------------- /res/fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/res/fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff -------------------------------------------------------------------------------- /res/fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/res/fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-700.woff2 -------------------------------------------------------------------------------- /res/fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/res/fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff -------------------------------------------------------------------------------- /res/fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/res/fonts/roboto-v15-latin-ext_cyrillic_greek-ext_cyrillic-ext_latin_greek_vietnamese-regular.woff2 -------------------------------------------------------------------------------- /res/icons/MaterialIcons-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/res/icons/MaterialIcons-Regular.woff -------------------------------------------------------------------------------- /res/icons/MaterialIcons-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/res/icons/MaterialIcons-Regular.woff2 -------------------------------------------------------------------------------- /res/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /res/icons/chevron-up.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /res/icons/dots-horizontal.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /res/icons/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justjanne/quassel-rest-search/014d624e17bdf06fa1711db3238819fe095a77f8/res/icons/error.png -------------------------------------------------------------------------------- /res/js/component/app.js: -------------------------------------------------------------------------------- 1 | const statehandler = new StateHandler(); 2 | class App { 3 | constructor() { 4 | this.navigation = new Navigation(); 5 | this.buffers = []; 6 | this.loadingQuery = 0; 7 | this.error = null; 8 | if (Storage.exists('language')) { 9 | moment.locale(Storage.get('language')); 10 | } 11 | this.render(); 12 | this.navigation.addEventListener('search', query => { 13 | this.search(query); 14 | }); 15 | statehandler.addEventListener('update', query => { 16 | this.search(query); 17 | }); 18 | statehandler.init(); 19 | } 20 | render() { 21 | this.elem = function () { 22 | var $$a = document.createElement('div'); 23 | $$a.appendChildren(this.navigation.elem); 24 | $$a.appendChildren(this.resultContainer = function () { 25 | var $$d = document.createElement('div'); 26 | $$d.setAttribute('class', 'results'); 27 | return $$d; 28 | }.call(this)); 29 | return $$a; 30 | }.call(this); 31 | this.buffers.forEach(buffer => this.insert(buffer)); 32 | } 33 | search(query, sender, buffer, network, before, since) { 34 | this.clear(); 35 | this.navigation.loading.show(); 36 | this.navigation.input.blur(); 37 | this.navigation.historyView.resetNavigation(); 38 | this.navigation.historyView.add(new HistoryElement(query)); 39 | this.navigation.input.value = query; 40 | statehandler.replace(query, sender, buffer, network, before, since); 41 | if (query.trim() === '') 42 | return; 43 | this.loadingQuery++; 44 | const queryId = this.loadingQuery; 45 | load('api/search/', statehandler.parse()).then(result => { 46 | if (this.loadingQuery !== queryId) 47 | return; 48 | this.navigation.loading.hide(); 49 | this.buffers = result.map(buffer => { 50 | return new Buffer(buffer.bufferid, buffer.buffername, buffer.networkname, buffer.hasmore, buffer.messages.map(msg => { 51 | return new Context(new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message, true)); 52 | })); 53 | }); 54 | this.buffers.forEach(buffer => this.insert(buffer)); 55 | if (this.buffers.length === 0) { 56 | this.showError(translation.error.none_found); 57 | } 58 | }); 59 | } 60 | clear() { 61 | while (this.buffers.length) { 62 | const buffer = this.buffers.pop(); 63 | this.resultContainer.removeChild(buffer.elem); 64 | } 65 | if (this.error) { 66 | this.resultContainer.removeChild(this.error.elem); 67 | this.error = null; 68 | } 69 | } 70 | clearAll() { 71 | this.clear(); 72 | this.navigation.historyView.clear(); 73 | statehandler.clear(); 74 | } 75 | showError(text) { 76 | this.error = new Error(text); 77 | this.resultContainer.appendChild(this.error.elem); 78 | } 79 | insert(buffer) { 80 | this.resultContainer.appendChild(buffer.elem); 81 | buffer.addEventListener('loadMore', () => this.bufferLoadMore(buffer)); 82 | buffer.addEventListener('loadBefore', context => { 83 | this.contextLoadBefore(buffer, context); 84 | }); 85 | buffer.addEventListener('loadAfter', context => { 86 | this.contextLoadAfter(buffer, context); 87 | }); 88 | buffer.addEventListener('loadInitial', context => { 89 | this.contextLoadInitial(buffer, context); 90 | }); 91 | } 92 | bufferLoadMore(buffer) { 93 | if (buffer.loading) 94 | return; 95 | buffer.setLoading(true); 96 | const offset = buffer.count(); 97 | console.log(offset); 98 | load('api/searchbuffer/', statehandler.parse({ 99 | buffer: buffer.id, 100 | offset: offset 101 | })).then(result => { 102 | buffer.load(result); 103 | buffer.setLoading(false); 104 | }); 105 | } 106 | contextLoadBefore(buffer, context) { 107 | if (context.loading) 108 | return; 109 | context.setLoading(true); 110 | load('api/backlog/', statehandler.parse({ 111 | buffer: buffer.id, 112 | anchor: context.anchorBefore, 113 | after: 0, 114 | before: 10 115 | })).then(result => { 116 | context.loadBefore(result.map(msg => new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message))); 117 | context.setLoading(false); 118 | }); 119 | } 120 | contextLoadAfter(buffer, context) { 121 | if (context.loading) 122 | return; 123 | context.setLoading(true); 124 | load('api/backlog/', statehandler.parse({ 125 | buffer: buffer.id, 126 | anchor: context.anchorAfter, 127 | after: 10, 128 | before: 0 129 | })).then(result => { 130 | context.loadAfter(result.map(msg => new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message))); 131 | context.setLoading(false); 132 | }); 133 | } 134 | contextLoadInitial(buffer, context) { 135 | if (context.loading) 136 | return; 137 | context.setLoading(true); 138 | load('api/backlog/', statehandler.parse({ 139 | buffer: buffer.id, 140 | anchor: context.anchorAfter, 141 | after: 4, 142 | before: 4 143 | })).then(result => { 144 | const before = result.filter(msg => msg.messageid < context.anchorBefore); 145 | const after = result.filter(msg => msg.messageid > context.anchorAfter); 146 | context.loadBefore(before.map(msg => new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message))); 147 | context.loadAfter(after.map(msg => new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message))); 148 | context.setLoading(false); 149 | }); 150 | } 151 | } 152 | const app = new App(); 153 | document.body.insertBefore(app.elem, document.body.firstChild); -------------------------------------------------------------------------------- /res/js/component/app.jsx: -------------------------------------------------------------------------------- 1 | const statehandler = new StateHandler(); 2 | 3 | class App { 4 | constructor() { 5 | this.navigation = new Navigation(); 6 | this.buffers = []; 7 | 8 | this.loadingQuery = 0; 9 | this.error = null; 10 | 11 | if (Storage.exists('language')) { 12 | moment.locale(Storage.get('language')); 13 | } 14 | 15 | this.render(); 16 | this.navigation.addEventListener("search", (query) => { 17 | this.search(query) 18 | }); 19 | statehandler.addEventListener("update", (query) => { 20 | this.search(query) 21 | }); 22 | statehandler.init(); 23 | } 24 | 25 | render() { 26 | this.elem =
27 | {this.navigation.elem} 28 | {this.resultContainer =
} 29 |
; 30 | 31 | this.buffers.forEach((buffer) => this.insert(buffer)); 32 | } 33 | 34 | search(query, sender, buffer, network, before, since) { 35 | this.clear(); 36 | this.navigation.loading.show(); 37 | 38 | this.navigation.input.blur(); 39 | this.navigation.historyView.resetNavigation(); 40 | this.navigation.historyView.add(new HistoryElement(query)); 41 | this.navigation.input.value = query; 42 | statehandler.replace(query, sender, buffer, network, before, since); 43 | 44 | if (query.trim() === "") 45 | return; 46 | 47 | this.loadingQuery++; 48 | const queryId = this.loadingQuery; 49 | load("api/search/", statehandler.parse()).then((result) => { 50 | if (this.loadingQuery !== queryId) 51 | return; 52 | 53 | this.navigation.loading.hide(); 54 | this.buffers = result.map((buffer) => { 55 | return new Buffer(buffer.bufferid, buffer.buffername, buffer.networkname, buffer.hasmore, buffer.messages.map((msg) => { 56 | return new Context(new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message, true)); 57 | })); 58 | }); 59 | this.buffers.forEach((buffer) => this.insert(buffer)); 60 | if (this.buffers.length === 0) { 61 | this.showError(translation.error.none_found); 62 | } 63 | }); 64 | } 65 | 66 | clear() { 67 | while (this.buffers.length) { 68 | const buffer = this.buffers.pop(); 69 | this.resultContainer.removeChild(buffer.elem); 70 | } 71 | 72 | if (this.error) { 73 | this.resultContainer.removeChild(this.error.elem); 74 | this.error = null; 75 | } 76 | } 77 | 78 | clearAll() { 79 | this.clear(); 80 | this.navigation.historyView.clear(); 81 | statehandler.clear(); 82 | } 83 | 84 | showError(text) { 85 | this.error = new Error(text); 86 | this.resultContainer.appendChild(this.error.elem); 87 | } 88 | 89 | insert(buffer) { 90 | this.resultContainer.appendChild(buffer.elem); 91 | buffer.addEventListener("loadMore", () => this.bufferLoadMore(buffer)); 92 | buffer.addEventListener("loadBefore", (context) => { 93 | this.contextLoadBefore(buffer, context); 94 | }); 95 | buffer.addEventListener("loadAfter", (context) => { 96 | this.contextLoadAfter(buffer, context); 97 | }); 98 | buffer.addEventListener("loadInitial", (context) => { 99 | this.contextLoadInitial(buffer, context); 100 | }); 101 | } 102 | 103 | bufferLoadMore(buffer) { 104 | if (buffer.loading) 105 | return; 106 | 107 | buffer.setLoading(true); 108 | const offset = buffer.count(); 109 | console.log(offset); 110 | load("api/searchbuffer/", statehandler.parse({buffer: buffer.id, offset: offset})).then((result) => { 111 | buffer.load(result); 112 | buffer.setLoading(false); 113 | }); 114 | } 115 | 116 | contextLoadBefore(buffer, context) { 117 | if (context.loading ) 118 | return; 119 | 120 | context.setLoading(true); 121 | load("api/backlog/", statehandler.parse({ 122 | buffer: buffer.id, 123 | anchor: context.anchorBefore, 124 | after: 0, 125 | before: 10 126 | })).then((result) => { 127 | context.loadBefore(result.map((msg) => new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message))); 128 | context.setLoading(false); 129 | }); 130 | } 131 | 132 | contextLoadAfter(buffer, context) { 133 | if (context.loading) 134 | return; 135 | 136 | context.setLoading(true); 137 | load("api/backlog/", statehandler.parse({ 138 | buffer: buffer.id, 139 | anchor: context.anchorAfter, 140 | after: 10, 141 | before: 0 142 | })).then((result) => { 143 | context.loadAfter(result.map((msg) => new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message))); 144 | context.setLoading(false); 145 | }); 146 | } 147 | 148 | contextLoadInitial(buffer, context) { 149 | if (context.loading) 150 | return; 151 | 152 | context.setLoading(true); 153 | load("api/backlog/", statehandler.parse({ 154 | buffer: buffer.id, 155 | anchor: context.anchorAfter, 156 | after: 4, 157 | before: 4 158 | })).then((result) => { 159 | const before = result.filter((msg) => msg.messageid < context.anchorBefore); 160 | const after = result.filter((msg) => msg.messageid > context.anchorAfter); 161 | 162 | context.loadBefore(before.map((msg) => new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message))); 163 | context.loadAfter(after.map((msg) => new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message))); 164 | context.setLoading(false); 165 | }); 166 | } 167 | } 168 | 169 | const app = new App(); 170 | document.body.insertBefore(app.elem, document.body.firstChild); 171 | -------------------------------------------------------------------------------- /res/js/component/buffer.js: -------------------------------------------------------------------------------- 1 | class Buffer extends Component { 2 | constructor(id, name, network, hasMore, contextList = []) { 3 | super(); 4 | this.id = id; 5 | this.name = name; 6 | this.network = network; 7 | this.contextList = contextList; 8 | this.render(); 9 | this.contextList.forEach(context => this.insert(context)); 10 | this.hasMore = hasMore; 11 | this.loading = false; 12 | this.neverLoaded = true; 13 | this.loadMoreBtn.setVisible(hasMore); 14 | } 15 | render() { 16 | this.elem = function () { 17 | var $$a = document.createElement('div'); 18 | $$a.setAttribute('class', 'buffer'); 19 | var $$b = document.createElement('div'); 20 | $$b.setAttribute('class', 'title'); 21 | $$a.appendChild($$b); 22 | var $$c = document.createElement('h2'); 23 | $$b.appendChild($$c); 24 | $$c.appendChildren(this.network); 25 | var $$e = document.createTextNode(' \u2013 '); 26 | $$c.appendChild($$e); 27 | $$c.appendChildren(this.name); 28 | var $$g = document.createElement('button'); 29 | $$g.addEventListener('click', () => this.focus()); 30 | $$b.appendChild($$g); 31 | var $$h = document.createElement('span'); 32 | $$h.setAttribute('class', 'close'); 33 | $$g.appendChild($$h); 34 | $$h.appendChildren(translation.buffer.close); 35 | var $$j = document.createElement('div'); 36 | $$j.setAttribute('class', 'container'); 37 | $$a.appendChild($$j); 38 | $$j.appendChildren(this.insertContainerFirst = function () { 39 | var $$l = document.createElement('div'); 40 | $$l.setAttribute('class', 'primary'); 41 | return $$l; 42 | }.call(this)); 43 | $$j.appendChildren(this.insertContainer = function () { 44 | var $$n = document.createElement('div'); 45 | $$n.setAttribute('class', 'secondary'); 46 | return $$n; 47 | }.call(this)); 48 | $$j.appendChildren((this.loadMoreBtn = new LoadMore(translation.results.show_more)).elem); 49 | return $$a; 50 | }.call(this); 51 | this.loadMoreBtn.addEventListener('click', () => this.loadMore()); 52 | return this.elem; 53 | } 54 | count() { 55 | return this.contextList.length; 56 | } 57 | loadMore() { 58 | if (this.elem.classList.contains('focus') || this.hasMore && this.neverLoaded) { 59 | this.sendEvent('loadMore', []); 60 | } 61 | this.focus(true); 62 | } 63 | focus(focus) { 64 | if (focus === undefined) 65 | focus = !this.elem.classList.contains('focus'); 66 | this.elem.classList.toggle('focus', focus); 67 | this.sendEvent('focus', focus); 68 | if (focus === false) { 69 | const bottomVisible = this.elem.offsetTop - this.insertContainerFirst.offsetTop + 20 + this.insertContainerFirst.offsetHeight; 70 | const fullyVisible = this.elem.offsetTop - this.insertContainerFirst.offsetTop + 20; 71 | const targetPosition = window.scrollY - this.insertContainer.offsetHeight; 72 | window.scrollTo(0, targetPosition > bottomVisible - 56 ? fullyVisible : targetPosition); 73 | } 74 | } 75 | load(resultSet) { 76 | resultSet.results.map(msg => new Context(new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message, true))).forEach(context => { 77 | this.contextList.push(context); 78 | this.insert(context); 79 | }); 80 | this.hasMore = resultSet.hasmore; 81 | this.elem.classList.toggle('hasmore', this.hasMore); 82 | } 83 | insert(context) { 84 | let container = this.insertContainerFirst.childElementCount < 4 ? this.insertContainerFirst : this.insertContainer; 85 | container.appendChild(context.elem); 86 | context.addEventListener('loadBefore', context => { 87 | this.sendEvent('loadBefore', [context]); 88 | }); 89 | context.addEventListener('loadAfter', context => { 90 | this.sendEvent('loadAfter', [context]); 91 | }); 92 | context.addEventListener('loadInitial', context => { 93 | this.sendEvent('loadInitial', [context]); 94 | }); 95 | this.neverLoaded = false; 96 | } 97 | setLoading(value) { 98 | this.loading = value; 99 | } 100 | } -------------------------------------------------------------------------------- /res/js/component/buffer.jsx: -------------------------------------------------------------------------------- 1 | class Buffer extends Component { 2 | constructor(id, name, network, hasMore, contextList = []) { 3 | super(); 4 | 5 | this.id = id; 6 | this.name = name; 7 | this.network = network; 8 | this.contextList = contextList; 9 | 10 | this.render(); 11 | this.contextList.forEach((context) => this.insert(context)); 12 | 13 | this.hasMore = hasMore; 14 | this.loading = false; 15 | this.neverLoaded = true; 16 | 17 | this.loadMoreBtn.setVisible(hasMore); 18 | } 19 | 20 | render() { 21 | this.elem = ( 22 |
23 |
24 |

{this.network} – {this.name}

25 | 28 |
29 |
30 | {this.insertContainerFirst = ( 31 |
32 | )} 33 | {this.insertContainer = ( 34 |
35 |
36 | )} 37 | {(this.loadMoreBtn = new LoadMore(translation.results.show_more)).elem} 38 |
39 |
40 | ); 41 | this.loadMoreBtn.addEventListener("click", () => this.loadMore()); 42 | return this.elem; 43 | } 44 | 45 | count() { 46 | return this.contextList.length 47 | } 48 | 49 | loadMore() { 50 | if (this.elem.classList.contains("focus") || (this.hasMore && this.neverLoaded)) { 51 | this.sendEvent("loadMore", []); 52 | } 53 | this.focus(true); 54 | } 55 | 56 | focus(focus) { 57 | if (focus === undefined) 58 | focus = !this.elem.classList.contains("focus"); 59 | 60 | this.elem.classList.toggle("focus", focus); 61 | this.sendEvent("focus", focus); 62 | 63 | if (focus === false) { 64 | const bottomVisible = this.elem.offsetTop - this.insertContainerFirst.offsetTop + 20 + this.insertContainerFirst.offsetHeight; 65 | const fullyVisible = this.elem.offsetTop - this.insertContainerFirst.offsetTop + 20; 66 | const targetPosition = window.scrollY - this.insertContainer.offsetHeight; 67 | window.scrollTo(0, (targetPosition > bottomVisible - 56) ? fullyVisible : targetPosition); 68 | } 69 | } 70 | 71 | load(resultSet) { 72 | resultSet.results 73 | .map((msg) => new Context(new Message(msg.messageid, msg.type, msg.time, msg.sender, msg.message, true))) 74 | .forEach((context) => { 75 | this.contextList.push(context); 76 | this.insert(context) 77 | }); 78 | this.hasMore = resultSet.hasmore; 79 | this.elem.classList.toggle('hasmore', this.hasMore); 80 | } 81 | 82 | insert(context) { 83 | let container = (this.insertContainerFirst.childElementCount < 4 ? this.insertContainerFirst : this.insertContainer); 84 | container.appendChild(context.elem); 85 | context.addEventListener("loadBefore", (context) => { 86 | this.sendEvent("loadBefore", [context]) 87 | }); 88 | context.addEventListener("loadAfter", (context) => { 89 | this.sendEvent("loadAfter", [context]) 90 | }); 91 | context.addEventListener("loadInitial", (context) => { 92 | this.sendEvent("loadInitial", [context]) 93 | }); 94 | this.neverLoaded = false; 95 | } 96 | 97 | setLoading(value) { 98 | // Add UI indicator 99 | this.loading = value; 100 | } 101 | } -------------------------------------------------------------------------------- /res/js/component/context.js: -------------------------------------------------------------------------------- 1 | class Context extends Component { 2 | constructor(message, beforeList = [], afterList = []) { 3 | super(); 4 | this.message = message; 5 | this.beforeList = beforeList; 6 | this.afterList = afterList; 7 | this.render(); 8 | this.insertAfterTarget = this.loadAfterBtn.elem; 9 | this.beforeList.forEach(it => this.insertBefore(it)); 10 | this.afterList.forEach(it => this.insertAfter(it)); 11 | this.message.addEventListener('focus', () => this.focus()); 12 | this.anchorBefore = this.message.id; 13 | this.anchorAfter = this.message.id; 14 | this.loading = false; 15 | } 16 | render() { 17 | return this.elem = function () { 18 | var $$a = document.createElement('span'); 19 | $$a.setAttribute('class', 'context'); 20 | $$a.appendChildren(this.containerBefore = function () { 21 | var $$c = document.createElement('span'); 22 | $$c.setAttribute('class', 'container before'); 23 | $$c.appendChildren((this.loadBeforeBtn = new LoadMore(translation.context.load_earlier, () => this.triggerLoadBefore())).elem); 24 | return $$c; 25 | }.call(this)); 26 | $$a.appendChildren(this.message.elem); 27 | $$a.appendChildren(this.containerAfter = function () { 28 | var $$g = document.createElement('span'); 29 | $$g.setAttribute('class', 'container after'); 30 | $$g.appendChildren((this.loadAfterBtn = new LoadMore(translation.context.load_later, () => this.triggerLoadAfter())).elem); 31 | return $$g; 32 | }.call(this)); 33 | return $$a; 34 | }.call(this); 35 | } 36 | focus(focus) { 37 | if (focus === undefined) 38 | focus = !this.elem.classList.contains('focus'); 39 | if (this.anchorBefore === this.message.id && this.anchorAfter === this.message.id) { 40 | this.triggerLoadInitial(); 41 | } 42 | this.elem.classList.toggle('focus', focus); 43 | this.sendEvent('focus', focus); 44 | } 45 | insertBefore(message) { 46 | this.containerBefore.insertBefore(message.elem, this.insertBeforeTarget); 47 | this.insertBeforeTarget = message.elem; 48 | this.anchorBefore = message.id; 49 | } 50 | insertAfter(message) { 51 | this.containerAfter.insertBefore(message.elem, this.insertAfterTarget); 52 | this.anchorAfter = message.id; 53 | } 54 | triggerLoadBefore() { 55 | this.sendEvent('loadBefore', [this]); 56 | } 57 | triggerLoadAfter() { 58 | this.sendEvent('loadAfter', [this]); 59 | } 60 | triggerLoadInitial() { 61 | this.sendEvent('loadInitial', [this]); 62 | } 63 | loadBefore(elements) { 64 | this.beforeList = elements.concat(this.beforeList); 65 | elements.forEach(it => this.insertBefore(it)); 66 | } 67 | loadAfter(elements) { 68 | this.afterList = elements.concat(this.afterList); 69 | elements.forEach(it => this.insertAfter(it)); 70 | } 71 | setLoading(value) { 72 | this.loading = value; 73 | } 74 | } -------------------------------------------------------------------------------- /res/js/component/context.jsx: -------------------------------------------------------------------------------- 1 | class Context extends Component { 2 | constructor(message, beforeList = [], afterList = []) { 3 | super(); 4 | 5 | this.message = message; 6 | this.beforeList = beforeList; 7 | this.afterList = afterList; 8 | 9 | this.render(); 10 | this.insertAfterTarget = this.loadAfterBtn.elem; 11 | this.beforeList.forEach((it) => this.insertBefore(it)); 12 | this.afterList.forEach((it) => this.insertAfter(it)); 13 | 14 | this.message.addEventListener("focus", () => this.focus()); 15 | 16 | this.anchorBefore = this.message.id; 17 | this.anchorAfter = this.message.id; 18 | 19 | this.loading = false; 20 | } 21 | 22 | render() { 23 | return this.elem = ( 24 | 25 | {this.containerBefore = ( 26 | 27 | {(this.loadBeforeBtn = new LoadMore(translation.context.load_earlier, () => this.triggerLoadBefore())).elem} 28 | 29 | )} 30 | {this.message.elem} 31 | {this.containerAfter = ( 32 | 33 | {(this.loadAfterBtn = new LoadMore(translation.context.load_later, () => this.triggerLoadAfter())).elem} 34 | 35 | )} 36 | 37 | ); 38 | } 39 | 40 | focus(focus) { 41 | if (focus === undefined) 42 | focus = !this.elem.classList.contains("focus"); 43 | 44 | if (this.anchorBefore === this.message.id && this.anchorAfter === this.message.id) { 45 | this.triggerLoadInitial(); 46 | } 47 | 48 | this.elem.classList.toggle("focus", focus); 49 | this.sendEvent("focus", focus); 50 | } 51 | 52 | insertBefore(message) { 53 | this.containerBefore.insertBefore(message.elem, this.insertBeforeTarget); 54 | this.insertBeforeTarget = message.elem; 55 | this.anchorBefore = message.id; 56 | } 57 | 58 | insertAfter(message) { 59 | this.containerAfter.insertBefore(message.elem, this.insertAfterTarget); 60 | this.anchorAfter = message.id; 61 | } 62 | 63 | triggerLoadBefore() { 64 | this.sendEvent("loadBefore", [this]); 65 | } 66 | 67 | triggerLoadAfter() { 68 | this.sendEvent("loadAfter", [this]); 69 | } 70 | 71 | triggerLoadInitial() { 72 | this.sendEvent("loadInitial", [this]); 73 | } 74 | 75 | loadBefore(elements) { 76 | this.beforeList = elements.concat(this.beforeList); 77 | elements.forEach((it) => this.insertBefore(it)); 78 | } 79 | 80 | loadAfter(elements) { 81 | this.afterList = elements.concat(this.afterList); 82 | elements.forEach((it) => this.insertAfter(it)); 83 | } 84 | 85 | setLoading(value) { 86 | // Add UI indicator 87 | this.loading = value; 88 | } 89 | } -------------------------------------------------------------------------------- /res/js/component/error.js: -------------------------------------------------------------------------------- 1 | class Error { 2 | constructor(text) { 3 | this.render(text); 4 | } 5 | render(text) { 6 | return this.elem = function () { 7 | var $$a = document.createElement('div'); 8 | $$a.setAttribute('class', 'error'); 9 | var $$b = document.createElement('img'); 10 | $$b.setAttribute('src', 'res/icons/error.png'); 11 | $$a.appendChild($$b); 12 | var $$c = document.createElement('h1'); 13 | $$a.appendChild($$c); 14 | $$c.appendChildren(text); 15 | return $$a; 16 | }.call(this); 17 | } 18 | } -------------------------------------------------------------------------------- /res/js/component/error.jsx: -------------------------------------------------------------------------------- 1 | class Error { 2 | constructor(text) { 3 | this.render(text); 4 | } 5 | 6 | render(text) { 7 | return this.elem = ( 8 |
9 | 10 |

{text}

11 |
12 | ); 13 | } 14 | } -------------------------------------------------------------------------------- /res/js/component/history.js: -------------------------------------------------------------------------------- 1 | const HISTORY_KEY = 'history'; 2 | const HISTORY_MAX_LENGTH = 4; 3 | class HistoryView { 4 | constructor() { 5 | this.index = -1; 6 | this.elements = this.load().map(function (query) { 7 | return new HistoryElement(query); 8 | }); 9 | this.render(); 10 | this.insert(this.elements); 11 | } 12 | render() { 13 | return this.elem = function () { 14 | var $$a = document.createElement('div'); 15 | $$a.setAttribute('class', 'history'); 16 | $$a.appendChildren(this.list = function () { 17 | var $$c = document.createElement('ul'); 18 | $$c.appendChildren((this.noHistory = new NoHistoryElement()).elem); 19 | return $$c; 20 | }.call(this)); 21 | return $$a; 22 | }.call(this); 23 | } 24 | insert(items) { 25 | if (!(items instanceof Array)) 26 | return this.insert([items]); 27 | const anchor = this.list.firstChild; 28 | items.forEach(item => this.list.insertBefore(item.elem, anchor)); 29 | if (items.length && this.noHistory.elem.parentNode === this.list) 30 | this.list.removeChild(this.noHistory.elem); 31 | } 32 | add(item) { 33 | if (item.query === '') 34 | return; 35 | const idx = this.elements.map(item => item.query).indexOf(item.query); 36 | if (idx !== -1) { 37 | this.list.removeChild(this.elements[idx].elem); 38 | this.elements.splice(idx, 1); 39 | } 40 | this.elements.unshift(item); 41 | this.insert(item); 42 | this.truncate(); 43 | this.store(); 44 | } 45 | clear() { 46 | while (this.elements.length) { 47 | this.list.removeChild(this.elements.pop().elem); 48 | } 49 | this.store(); 50 | this.list.appendChild(this.noHistory.elem); 51 | } 52 | load() { 53 | const loaded = localStorage[HISTORY_KEY]; 54 | return JSON.parse(loaded === undefined ? '[]' : loaded); 55 | } 56 | store() { 57 | localStorage[HISTORY_KEY] = JSON.stringify(this.elements.map(item => item.query)); 58 | } 59 | navigateBefore() { 60 | if (this.elements[this.index]) 61 | this.elements[this.index].focus(false); 62 | this.index++; 63 | this.index %= this.elements.length; 64 | if (this.elements[this.index]) 65 | this.elements[this.index].focus(true); 66 | } 67 | navigateLater() { 68 | if (this.elements[this.index]) 69 | this.elements[this.index].focus(false); 70 | this.index--; 71 | if (this.index < 0) 72 | this.index = -1; 73 | else 74 | this.index %= this.elements.length; 75 | if (this.elements[this.index]) 76 | this.elements[this.index].focus(true); 77 | } 78 | resetNavigation() { 79 | if (this.elements[this.index]) 80 | this.elements[this.index].focus(false); 81 | this.index = -1; 82 | } 83 | truncate() { 84 | while (this.elements.length > HISTORY_MAX_LENGTH) 85 | this.list.removeChild(this.elements.pop().elem); 86 | } 87 | } -------------------------------------------------------------------------------- /res/js/component/history.jsx: -------------------------------------------------------------------------------- 1 | const HISTORY_KEY = "history"; 2 | const HISTORY_MAX_LENGTH = 4; 3 | 4 | class HistoryView { 5 | constructor() { 6 | this.index = -1; 7 | 8 | this.elements = this.load().map(function (query) { 9 | return new HistoryElement(query); 10 | }); 11 | 12 | this.render(); 13 | this.insert(this.elements); 14 | } 15 | 16 | render() { 17 | return this.elem = ( 18 |
19 | {this.list = ( 20 |
    21 | {(this.noHistory = new NoHistoryElement()).elem} 22 |
23 | )} 24 |
25 | ); 26 | } 27 | 28 | insert(items) { 29 | if (!(items instanceof Array)) 30 | return this.insert([items]); 31 | 32 | const anchor = this.list.firstChild; 33 | items.forEach(item => this.list.insertBefore(item.elem, anchor)); 34 | if (items.length && this.noHistory.elem.parentNode === this.list) 35 | this.list.removeChild(this.noHistory.elem); 36 | } 37 | 38 | add(item) { 39 | if (item.query === "") 40 | return; 41 | 42 | const idx = this.elements.map((item) => item.query).indexOf(item.query); 43 | if (idx !== -1) { 44 | this.list.removeChild(this.elements[idx].elem); 45 | this.elements.splice(idx, 1); 46 | } 47 | 48 | this.elements.unshift(item); 49 | this.insert(item); 50 | 51 | this.truncate(); 52 | 53 | this.store(); 54 | } 55 | 56 | clear() { 57 | while (this.elements.length) { 58 | this.list.removeChild(this.elements.pop().elem); 59 | } 60 | this.store(); 61 | this.list.appendChild(this.noHistory.elem); 62 | } 63 | 64 | load() { 65 | const loaded = localStorage[HISTORY_KEY]; 66 | return JSON.parse(loaded === undefined ? "[]" : loaded); 67 | } 68 | 69 | store() { 70 | localStorage[HISTORY_KEY] = JSON.stringify(this.elements.map((item) => item.query)); 71 | } 72 | 73 | navigateBefore() { 74 | if (this.elements[this.index]) 75 | this.elements[this.index].focus(false); 76 | 77 | this.index++; 78 | this.index %= this.elements.length; 79 | 80 | if (this.elements[this.index]) 81 | this.elements[this.index].focus(true); 82 | } 83 | 84 | navigateLater() { 85 | if (this.elements[this.index]) 86 | this.elements[this.index].focus(false); 87 | 88 | this.index--; 89 | if (this.index < 0) 90 | this.index = -1; 91 | else 92 | this.index %= this.elements.length; 93 | 94 | if (this.elements[this.index]) 95 | this.elements[this.index].focus(true); 96 | } 97 | 98 | resetNavigation() { 99 | if (this.elements[this.index]) 100 | this.elements[this.index].focus(false); 101 | 102 | this.index = -1; 103 | } 104 | 105 | truncate() { 106 | while (this.elements.length > HISTORY_MAX_LENGTH) 107 | this.list.removeChild(this.elements.pop().elem); 108 | } 109 | } -------------------------------------------------------------------------------- /res/js/component/historyelement.js: -------------------------------------------------------------------------------- 1 | class HistoryElement { 2 | constructor(query) { 3 | this.query = query; 4 | this.render(); 5 | this.elem.addEventListener('mousedown', event => { 6 | if (event.buttons === 0 || event.buttons === 1) { 7 | statehandler.replace(this.query); 8 | event.preventDefault(); 9 | } 10 | }); 11 | } 12 | render() { 13 | return this.elem = function () { 14 | var $$a = document.createElement('li'); 15 | var $$b = document.createElement('span'); 16 | $$b.setAttribute('class', 'icon'); 17 | $$a.appendChild($$b); 18 | var $$c = document.createTextNode('history'); 19 | $$b.appendChild($$c); 20 | $$a.appendChildren(this.query); 21 | return $$a; 22 | }.call(this); 23 | } 24 | focus(focus) { 25 | this.elem.classList.toggle('focus', focus); 26 | } 27 | } -------------------------------------------------------------------------------- /res/js/component/historyelement.jsx: -------------------------------------------------------------------------------- 1 | class HistoryElement { 2 | constructor(query) { 3 | this.query = query; 4 | 5 | this.render(); 6 | this.elem.addEventListener("mousedown", (event) => { 7 | if (event.buttons === 0 || event.buttons === 1) { 8 | statehandler.replace(this.query); 9 | event.preventDefault(); 10 | } 11 | }); 12 | } 13 | 14 | render() { 15 | return this.elem = ( 16 |
  • 17 | history 18 | {this.query} 19 |
  • 20 | ); 21 | } 22 | 23 | focus(focus) { 24 | this.elem.classList.toggle("focus", focus); 25 | } 26 | } -------------------------------------------------------------------------------- /res/js/component/loading.js: -------------------------------------------------------------------------------- 1 | class Loading { 2 | constructor() { 3 | this.render(); 4 | } 5 | render() { 6 | return this.elem = function () { 7 | var $$a = document.createElement('div'); 8 | $$a.setAttribute('class', 'progress'); 9 | var $$b = document.createElement('div'); 10 | $$b.setAttribute('class', 'indeterminate'); 11 | $$a.appendChild($$b); 12 | return $$a; 13 | }.call(this); 14 | } 15 | show() { 16 | this.elem.classList.add('visible'); 17 | } 18 | hide() { 19 | this.elem.classList.remove('visible'); 20 | } 21 | } -------------------------------------------------------------------------------- /res/js/component/loading.jsx: -------------------------------------------------------------------------------- 1 | class Loading { 2 | constructor() { 3 | this.render(); 4 | } 5 | 6 | render() { 7 | return this.elem = ( 8 |
    9 |
    10 |
    11 |
    12 | ); 13 | } 14 | 15 | show() { 16 | this.elem.classList.add("visible"); 17 | } 18 | 19 | hide() { 20 | this.elem.classList.remove("visible"); 21 | } 22 | } -------------------------------------------------------------------------------- /res/js/component/loadmore.js: -------------------------------------------------------------------------------- 1 | class LoadMore extends Component { 2 | constructor(text, eventListener) { 3 | super(); 4 | this.render(text); 5 | if (eventListener) 6 | this.addEventListener('click', eventListener); 7 | } 8 | render(text) { 9 | return this.elem = function () { 10 | var $$a = document.createElement('div'); 11 | $$a.setAttribute('class', 'inline-button'); 12 | $$a.addEventListener('click', event => this.sendEvent('click', [event])); 13 | $$a.appendChildren(text); 14 | return $$a; 15 | }.call(this); 16 | } 17 | } -------------------------------------------------------------------------------- /res/js/component/loadmore.jsx: -------------------------------------------------------------------------------- 1 | class LoadMore extends Component { 2 | constructor(text, eventListener) { 3 | super(); 4 | this.render(text); 5 | if (eventListener) 6 | this.addEventListener("click", eventListener); 7 | } 8 | 9 | render(text) { 10 | return this.elem = ( 11 |
    this.sendEvent("click", [event])}> 12 | {text} 13 |
    14 | ); 15 | } 16 | } -------------------------------------------------------------------------------- /res/js/component/message.js: -------------------------------------------------------------------------------- 1 | class Message extends Component { 2 | constructor(id, type, time, sender, content, isAnchor) { 3 | super(); 4 | this.id = id; 5 | this.type = type; 6 | this.time = time.replace(' ', 'T') + 'Z'; 7 | this.sender = sender; 8 | this.content = content; 9 | this.isAnchor = isAnchor; 10 | this.render(); 11 | } 12 | render() { 13 | const classes = ['message']; 14 | if ((this.type & 2) !== 0) 15 | classes.push('notice'); 16 | if ((this.type & 4) !== 0) 17 | classes.push('action'); 18 | const content = MircColorHandler.render(this.content); 19 | return this.elem = function () { 20 | var $$a = document.createElement('span'); 21 | $$a.setAttribute('class', classes.join(' ')); 22 | var $$b = document.createElement('span'); 23 | $$a.appendChild($$b); 24 | var $$c = document.createElement('span'); 25 | $$c.setAttribute('class', 'invisible'); 26 | $$c.setAttribute('aria-hidden', 'true'); 27 | $$b.appendChild($$c); 28 | var $$d = document.createTextNode('['); 29 | $$c.appendChild($$d); 30 | var $$e = document.createElement('time'); 31 | $$e.setAttribute('dateTime', this.time); 32 | $$b.appendChild($$e); 33 | $$e.appendChildren(this.formatTime()); 34 | var $$g = document.createElement('span'); 35 | $$g.setAttribute('class', 'invisible'); 36 | $$g.setAttribute('aria-hidden', 'true'); 37 | $$b.appendChild($$g); 38 | var $$h = document.createTextNode(']'); 39 | $$g.appendChild($$h); 40 | var $$i = document.createElement('span'); 41 | $$i.setAttribute('class', 'container'); 42 | $$a.appendChild($$i); 43 | var $$j = document.createElement('span'); 44 | $$j.setAttribute('class', 'sender'); 45 | $$j.setAttribute('data-sendercolor', SenderColorHandler.nickToColor(this.getNick())); 46 | $$i.appendChild($$j); 47 | var $$k = document.createElement('span'); 48 | $$k.setAttribute('class', 'invisible'); 49 | $$k.setAttribute('aria-hidden', 'true'); 50 | $$j.appendChild($$k); 51 | var $$l = document.createTextNode(' <'); 52 | $$k.appendChild($$l); 53 | $$j.appendChildren(this.getNick()); 54 | var $$n = document.createElement('span'); 55 | $$n.setAttribute('class', 'invisible'); 56 | $$n.setAttribute('aria-hidden', 'true'); 57 | $$j.appendChild($$n); 58 | var $$o = document.createTextNode('> '); 59 | $$n.appendChild($$o); 60 | var $$p = document.createElement('span'); 61 | $$p.setAttribute('class', 'content'); 62 | $$i.appendChild($$p); 63 | $$p.appendChildren(content.length ? content : null); 64 | $$a.appendChildren(this.isAnchor ? function () { 65 | var $$s = document.createElement('a'); 66 | $$s.setAttribute('class', 'more icon'); 67 | $$s.setAttribute('role', 'button'); 68 | $$s.setAttribute('aria-label', translation.context.show_hide); 69 | $$s.addEventListener('click', () => this.sendEvent('focus', [])); 70 | var $$t = document.createTextNode('list'); 71 | $$s.appendChild($$t); 72 | return $$s; 73 | }.call(this) : null); 74 | var $$u = document.createElement('br'); 75 | $$a.appendChild($$u); 76 | return $$a; 77 | }.call(this); 78 | } 79 | formatTime() { 80 | const dateFormat = Storage.exists('dateformat') ? Storage.get('dateformat') : 'L'; 81 | const timeFormat = Storage.exists('timeformat') ? Storage.get('timeformat') : 'LT'; 82 | const dateTimeFormat = dateFormat + ' ' + timeFormat; 83 | return moment(new Date(this.time)).format(dateTimeFormat); 84 | } 85 | getNick() { 86 | return this.sender.split('!')[0]; 87 | } 88 | getIdent() { 89 | return this.sender.split('@')[0].split('!')[1]; 90 | } 91 | getHost() { 92 | return this.sender.split('@')[1]; 93 | } 94 | } -------------------------------------------------------------------------------- /res/js/component/message.jsx: -------------------------------------------------------------------------------- 1 | class Message extends Component { 2 | constructor(id, type, time, sender, content, isAnchor) { 3 | super(); 4 | 5 | this.id = id; 6 | this.type = type; 7 | this.time = time.replace(" ", "T") + "Z"; 8 | this.sender = sender; 9 | this.content = content; 10 | this.isAnchor = isAnchor; 11 | 12 | this.render(); 13 | } 14 | 15 | render() { 16 | const classes = ["message"]; 17 | if ((this.type & 0x00000002) !== 0) 18 | classes.push("notice"); 19 | if ((this.type & 0x00000004) !== 0) 20 | classes.push("action"); 21 | 22 | const content = MircColorHandler.render(this.content); 23 | 24 | return this.elem = ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {this.getNick()} 35 | 36 | 37 | 38 | {content.length ? content : null} 39 | 40 | 41 | {this.isAnchor ? ( 42 | this.sendEvent("focus", [])}>list 43 | ) : null} 44 |
    45 |
    46 | ); 47 | } 48 | 49 | formatTime() { 50 | const dateFormat = Storage.exists('dateformat') ? Storage.get('dateformat') : 'L'; 51 | const timeFormat = Storage.exists('timeformat') ? Storage.get('timeformat') : 'LT'; 52 | const dateTimeFormat = dateFormat + " " + timeFormat; 53 | 54 | return moment(new Date(this.time)).format(dateTimeFormat); 55 | } 56 | 57 | getNick() { 58 | return this.sender.split("!")[0]; 59 | } 60 | 61 | getIdent() { 62 | return this.sender.split("@")[0].split("!")[1]; 63 | } 64 | 65 | getHost() { 66 | return this.sender.split("@")[1]; 67 | } 68 | } -------------------------------------------------------------------------------- /res/js/component/nav.js: -------------------------------------------------------------------------------- 1 | const keyMapping = { 2 | 13: 'Enter', 3 | 27: 'Escape', 4 | 38: 'ArrowUp', 5 | 40: 'ArrowDown' 6 | }; 7 | class Navigation extends Component { 8 | constructor() { 9 | super(); 10 | this.render(); 11 | } 12 | render() { 13 | return this.elem = function () { 14 | var $$a = document.createElement('div'); 15 | $$a.setAttribute('class', 'nav'); 16 | var $$b = document.createElement('div'); 17 | $$b.setAttribute('class', 'bar'); 18 | $$a.appendChild($$b); 19 | var $$c = document.createElement('div'); 20 | $$c.setAttribute('class', 'container'); 21 | $$b.appendChild($$c); 22 | var $$d = document.createElement('div'); 23 | $$d.setAttribute('class', 'searchBar'); 24 | $$c.appendChild($$d); 25 | var $$e = document.createElement('p'); 26 | $$e.setAttribute('class', 'icon'); 27 | $$d.appendChild($$e); 28 | var $$f = document.createTextNode('search'); 29 | $$e.appendChild($$f); 30 | $$d.appendChildren(this.input = function () { 31 | var $$h = document.createElement('input'); 32 | $$h.setAttribute('class', 'search'); 33 | $$h.setAttribute('placeholder', translation.search); 34 | $$h.setAttribute('type', 'search'); 35 | $$h.setAttribute('autoComplete', 'off'); 36 | $$h.addEventListener('focus', () => this.elem.classList.add('focus')); 37 | $$h.addEventListener('blur', () => { 38 | this.elem.classList.remove('focus'); 39 | this.historyView.resetNavigation(); 40 | }); 41 | $$h.addEventListener('keydown', e => this.inputKeyDown(e)); 42 | $$h.setAttribute('tabIndex', '0'); 43 | return $$h; 44 | }.call(this)); 45 | var $$i = document.createElement('div'); 46 | $$i.setAttribute('class', 'actions'); 47 | $$c.appendChild($$i); 48 | var $$j = document.createElement('a'); 49 | $$j.setAttribute('href', 'login.php?action=logout'); 50 | $$j.setAttribute('title', translation.logout); 51 | $$j.setAttribute('class', 'icon'); 52 | $$i.appendChild($$j); 53 | var $$k = document.createTextNode('exit_to_app'); 54 | $$j.appendChild($$k); 55 | $$b.appendChildren((this.loading = new Loading()).elem); 56 | $$a.appendChildren((this.historyView = new HistoryView()).elem); 57 | return $$a; 58 | }.call(this); 59 | } 60 | inputKeyDown(event) { 61 | switch (event.key || keyMapping[event.keyCode]) { 62 | case 'ArrowUp': 63 | this.historyView.navigateLater(); 64 | event.preventDefault(); 65 | break; 66 | case 'ArrowDown': 67 | this.historyView.navigateBefore(); 68 | event.preventDefault(); 69 | break; 70 | case 'Enter': 71 | statehandler.replace(this.historyView.index === -1 ? this.input.value : this.historyView.elements[this.historyView.index].query); 72 | event.preventDefault(); 73 | break; 74 | case 'Escape': 75 | this.input.blur(); 76 | this.historyView.resetNavigation(); 77 | event.preventDefault(); 78 | break; 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /res/js/component/nav.jsx: -------------------------------------------------------------------------------- 1 | const keyMapping = { 2 | 13: "Enter", 3 | 27: "Escape", 4 | 38: "ArrowUp", 5 | 40: "ArrowDown" 6 | }; 7 | 8 | class Navigation extends Component { 9 | constructor() { 10 | super(); 11 | this.render(); 12 | } 13 | 14 | render() { 15 | return this.elem = ( 16 |
    17 |
    18 |
    19 |
    20 |

    search

    21 | {this.input = ( 22 | this.elem.classList.add("focus")} 25 | onBlur={() => { 26 | this.elem.classList.remove("focus"); 27 | this.historyView.resetNavigation(); 28 | }} 29 | onKeyDown={(e) => this.inputKeyDown(e)} 30 | tabIndex="0" 31 | /> 32 | )} 33 |
    34 |
    35 | exit_to_app 37 |
    38 |
    39 | {(this.loading = new Loading()).elem} 40 |
    41 | {(this.historyView = new HistoryView()).elem} 42 |
    43 | ); 44 | } 45 | 46 | inputKeyDown(event) { 47 | switch (event.key || keyMapping[event.keyCode]) { 48 | case "ArrowUp": 49 | this.historyView.navigateLater(); 50 | event.preventDefault(); 51 | break; 52 | case "ArrowDown": 53 | this.historyView.navigateBefore(); 54 | event.preventDefault(); 55 | break; 56 | case "Enter": 57 | statehandler.replace(this.historyView.index === -1 ? this.input.value : this.historyView.elements[this.historyView.index].query); 58 | event.preventDefault(); 59 | break; 60 | case "Escape": 61 | this.input.blur(); 62 | this.historyView.resetNavigation(); 63 | event.preventDefault(); 64 | break; 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /res/js/component/nohistoryelement.js: -------------------------------------------------------------------------------- 1 | class NoHistoryElement { 2 | constructor() { 3 | this.render(); 4 | } 5 | render() { 6 | return this.elem = function () { 7 | var $$a = document.createElement('p'); 8 | $$a.appendChildren(translation.history.error_unavailable); 9 | return $$a; 10 | }.call(this); 11 | } 12 | } -------------------------------------------------------------------------------- /res/js/component/nohistoryelement.jsx: -------------------------------------------------------------------------------- 1 | class NoHistoryElement { 2 | constructor() { 3 | this.render(); 4 | } 5 | 6 | render() { 7 | return this.elem = ( 8 |

    {translation.history.error_unavailable}

    9 | ); 10 | } 11 | } -------------------------------------------------------------------------------- /res/js/util/component.js: -------------------------------------------------------------------------------- 1 | class Component { 2 | constructor() { 3 | this.eventListeners = {}; 4 | } 5 | 6 | addEventListener(type, handler) { 7 | this.getListeners(type).push(handler); 8 | } 9 | 10 | removeEventListener(type, handler) { 11 | const listeners = this.getListeners(type); 12 | listeners.splice(listeners.indexOf(handler), 1); 13 | } 14 | 15 | getListeners(type) { 16 | if (!this.eventListeners[type]) 17 | this.eventListeners[type] = []; 18 | return this.eventListeners[type]; 19 | } 20 | 21 | sendEvent(type, argv) { 22 | this.getListeners(type).forEach((listener) => { 23 | listener.apply(null, argv); 24 | }) 25 | } 26 | 27 | setVisible(value) { 28 | if (this.elem) 29 | this.elem.classList.toggle("hidden", !value); 30 | } 31 | } -------------------------------------------------------------------------------- /res/js/util/highlighthandler.js: -------------------------------------------------------------------------------- 1 | class HighlightHandler { 2 | render(text) { 3 | 4 | } 5 | } -------------------------------------------------------------------------------- /res/js/util/loader.js: -------------------------------------------------------------------------------- 1 | function load(url, data = null) { 2 | return new Promise((resolve, reject) => { 3 | const encodeData = (data) => { 4 | const result = []; 5 | for (key in data) { 6 | if (data.hasOwnProperty(key)) { 7 | result.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key])); 8 | } 9 | } 10 | return result.join("&"); 11 | }; 12 | 13 | const request = new XMLHttpRequest(); 14 | const method = data === null ? "GET" : "POST"; 15 | request.onreadystatechange = () => { 16 | if (request.readyState === 4) { 17 | if (request.status >= 200 && request.status < 300) { 18 | try { 19 | resolve(JSON.parse(request.responseText)); 20 | } catch (e) { 21 | reject(e); 22 | } 23 | } else { 24 | reject(request.status + ": " + request.responseText); 25 | } 26 | } 27 | }; 28 | request.open(method, url, true); 29 | request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); 30 | if (data !== null) { 31 | request.send(encodeData(data)); 32 | } else { 33 | request.send(); 34 | } 35 | }); 36 | } -------------------------------------------------------------------------------- /res/js/util/mirccolorhandler.js: -------------------------------------------------------------------------------- 1 | class MircColorHandler { 2 | static render(text) { 3 | const CODE_BOLD = '\x02'; 4 | const CODE_COLOR = '\x03'; 5 | const CODE_HEXCOLOR = '\x04'; 6 | const CODE_ITALIC = '\x1D'; 7 | const CODE_UNDERLINE = '\x1F'; 8 | const CODE_STRIKETHROUGH = '\x1E'; 9 | const CODE_MONOSPACE = '\x11'; 10 | const CODE_SWAP = '\x16'; 11 | const CODE_RESET = '\x0F'; 12 | 13 | const urlRegex = /\b((?:(?:mailto:|(?:[+.-]?\w)+:\/\/)|www(?=\.\S+\.))(?:(?:[,.;@:]?[-\w]+)+\.?|\[[0-9a-f:.]+])(?::\d+)?(?:\/(?:[,.;:]*[\w~@/?&=+$()!%#*-])*)?)(?:>|[,.;:"]*\b|$)/gi; 14 | const readNumber = function (str, start, end) { 15 | if (start >= end || start >= str.length) 16 | return -1; 17 | else 18 | return parseInt(str.substr(start, end - start), 10); 19 | }; 20 | const findEndOfNumber = function (str, start) { 21 | const validCharCodes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; 22 | let i; 23 | let tmp = str.substr(start, 2); 24 | for (i = 0; i < 2 && i < tmp.length; i++) { 25 | if (validCharCodes.indexOf(tmp.charAt(i)) === -1) 26 | break; 27 | } 28 | return i + start; 29 | }; 30 | const readHexNumber = function (str, start, end) { 31 | if (start >= end || start >= str.length) 32 | return -1; 33 | else 34 | return parseInt(str.substr(start, end - start), 16); 35 | }; 36 | const findEndOfHexNumber = function (str, start) { 37 | const validCharCodes = [ 38 | '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 39 | 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F' 40 | ]; 41 | let i; 42 | let tmp = str.substr(start, 6); 43 | for (i = 0; i < 6 && i < tmp.length; i++) { 44 | if (validCharCodes.indexOf(tmp.charAt(i)) === -1) 45 | break; 46 | } 47 | return i + start; 48 | }; 49 | const fromState = function (state) { 50 | const elem = document.createElement((state.url) ? 'a' : 'span'); 51 | if (state.bold) 52 | elem.classList.add('irc_bold'); 53 | if (state.italic) 54 | elem.classList.add('irc_italic'); 55 | if (state.underline) 56 | elem.classList.add('irc_underline'); 57 | if (state.strikethrough) 58 | elem.classList.add('irc_strikethrough'); 59 | if (state.monospace) 60 | elem.classList.add('irc_monospace'); 61 | if (state.foreground !== null) 62 | elem.dataset['irc_foreground'] = state.foreground; 63 | if (state.background !== null) 64 | elem.dataset['irc_background'] = state.background; 65 | if (state.hexforeground !== null) 66 | elem.style.color = '#' + ("000000" + state.hexforeground.toString(16)).substr(-6); 67 | if (state.highlight) 68 | elem.classList.add('irc_highlight'); 69 | return elem; 70 | }; 71 | const unescape = function (str) { 72 | return str.replace(/</g, '<').replace(/>/g, '>'); 73 | }; 74 | const apply = function (lastTag, str, i, normalCount, nodes) { 75 | const s = unescape(str.substr(i - normalCount, normalCount)); 76 | if (normalCount === 0) 77 | return; 78 | if (lastTag.tagName === 'A') { 79 | lastTag.target = '_blank'; 80 | lastTag.rel = 'noopener' 81 | if (s.indexOf('://') !== -1) 82 | lastTag.href = s; 83 | else 84 | lastTag.href = "http://" + s; 85 | } 86 | lastTag.appendChild(document.createTextNode(s)); 87 | nodes.push(lastTag); 88 | }; 89 | const urlStart = []; 90 | const urlEnd = []; 91 | let m; 92 | do { 93 | if (m) { 94 | urlStart.push(m.index); 95 | urlEnd.push(m.index + m[0].length); 96 | } 97 | m = urlRegex.exec(text); 98 | } while (m); 99 | const formatString = function (str) { 100 | if (!str) 101 | return document.createTextNode(''); 102 | let state = { 103 | bold: false, 104 | italic: false, 105 | underline: false, 106 | strikethrough: false, 107 | monospace: false, 108 | foreground: null, 109 | hexforeground: null, 110 | background: null, 111 | highlight: false, 112 | url: false 113 | }; 114 | let lastTag = fromState(state); 115 | let nodes = []; 116 | let normalCount = 0; 117 | for (let i = 0; i < str.length; i++) { 118 | const character = str.charAt(i); 119 | if (urlEnd.includes(i)) { 120 | apply(lastTag, str, i, normalCount, nodes); 121 | normalCount = 0; 122 | state.url = false; 123 | lastTag = fromState(state); 124 | } 125 | if (urlStart.includes(i)) { 126 | apply(lastTag, str, i, normalCount, nodes); 127 | normalCount = 0; 128 | state.url = true; 129 | lastTag = fromState(state); 130 | } 131 | switch (character) { 132 | case CODE_BOLD: { 133 | apply(lastTag, str, i, normalCount, nodes); 134 | normalCount = 0; 135 | state.bold = !state.bold; 136 | lastTag = fromState(state); 137 | } 138 | break; 139 | case CODE_ITALIC: { 140 | apply(lastTag, str, i, normalCount, nodes); 141 | normalCount = 0; 142 | state.italic = !state.italic; 143 | lastTag = fromState(state); 144 | } 145 | break; 146 | case CODE_UNDERLINE: { 147 | apply(lastTag, str, i, normalCount, nodes); 148 | normalCount = 0; 149 | state.underline = !state.underline; 150 | lastTag = fromState(state); 151 | } 152 | break; 153 | case CODE_STRIKETHROUGH: { 154 | apply(lastTag, str, i, normalCount, nodes); 155 | normalCount = 0; 156 | state.strikethrough = !state.strikethrough; 157 | lastTag = fromState(state); 158 | } 159 | break; 160 | case CODE_MONOSPACE: { 161 | apply(lastTag, str, i, normalCount, nodes); 162 | normalCount = 0; 163 | state.monospace = !state.monospace; 164 | lastTag = fromState(state); 165 | } 166 | break; 167 | case CODE_COLOR: { 168 | apply(lastTag, str, i, normalCount, nodes); 169 | normalCount = 0; 170 | let foregroundStart = i + 1; 171 | let foregroundEnd = findEndOfNumber(str, foregroundStart); 172 | if (foregroundEnd > foregroundStart) { 173 | let foreground = readNumber(str, foregroundStart, foregroundEnd); 174 | let background = -1; 175 | let backgroundStart = foregroundEnd + 1; 176 | let backgroundEnd = -1; 177 | if (str.length > foregroundEnd && str.charAt(foregroundEnd) === ',') { 178 | backgroundEnd = findEndOfNumber(str, backgroundStart); 179 | background = readNumber(str, backgroundStart, backgroundEnd); 180 | } 181 | if (state.foreground !== null) { 182 | if (background === -1) 183 | background = state.background; 184 | } 185 | if (foreground === -1) { 186 | state.foreground = null; 187 | state.hexforeground = null; 188 | } else { 189 | state.foreground = foreground; 190 | state.hexforeground = null; 191 | } 192 | state.background = background === -1 ? null : background; 193 | lastTag = fromState(state); 194 | i = (backgroundEnd === -1 ? foregroundEnd : backgroundEnd) - 1; 195 | } else if (state.foreground !== null) { 196 | state.foreground = null; 197 | state.background = null; 198 | lastTag = fromState(state); 199 | } 200 | } 201 | break; 202 | case CODE_HEXCOLOR: { 203 | apply(lastTag, str, i, normalCount, nodes); 204 | normalCount = 0; 205 | let foregroundStart = i + 1; 206 | let foregroundEnd = findEndOfHexNumber(str, foregroundStart); 207 | if (foregroundEnd > foregroundStart) { 208 | let foreground = readHexNumber(str, foregroundStart, foregroundEnd); 209 | if (foreground === -1) { 210 | state.hexforeground = null; 211 | state.foreground = null; 212 | } else { 213 | state.hexforeground = foreground; 214 | state.foreground = null; 215 | } 216 | lastTag = fromState(state); 217 | i = foregroundEnd - 1; 218 | } else if (state.hexforeground !== null) { 219 | state.hexforeground = null; 220 | lastTag = fromState(state); 221 | } 222 | } 223 | break; 224 | case CODE_SWAP: { 225 | apply(lastTag, str, i, normalCount, nodes); 226 | normalCount = 0; 227 | if (state.foreground !== null) { 228 | state.foreground = state.background; 229 | state.background = state.foreground; 230 | lastTag = fromState(state); 231 | } 232 | } 233 | break; 234 | case CODE_RESET: { 235 | apply(lastTag, str, i, normalCount, nodes); 236 | normalCount = 0; 237 | state.bold = false; 238 | state.italic = false; 239 | state.underline = false; 240 | state.foreground = null; 241 | state.background = null; 242 | lastTag = fromState(state); 243 | } 244 | break; 245 | case '<': { 246 | const start_tag = ''; 247 | const end_tag = ''; 248 | if (str.substr(i, start_tag.length) === start_tag) { 249 | apply(lastTag, str, i, normalCount, nodes); 250 | normalCount = 0; 251 | state.highlight = true; 252 | lastTag = fromState(state); 253 | 254 | i += start_tag.length - 1; 255 | } else if (str.substr(i, end_tag.length) === end_tag) { 256 | apply(lastTag, str, i, normalCount, nodes); 257 | normalCount = 0; 258 | state.highlight = false; 259 | lastTag = fromState(state); 260 | 261 | i += end_tag.length - 1; 262 | } else { 263 | normalCount++; 264 | } 265 | } 266 | break; 267 | default: { 268 | normalCount++; 269 | } 270 | break; 271 | } 272 | } 273 | apply(lastTag, str, str.length, normalCount, nodes); 274 | return nodes; 275 | }; 276 | return formatString(text); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /res/js/util/nativejsx-prototypes.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | /******/ 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | /******/ 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) 10 | /******/ return installedModules[moduleId].exports; 11 | /******/ 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ i: moduleId, 15 | /******/ l: false, 16 | /******/ exports: {} 17 | /******/ }; 18 | /******/ 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | /******/ 22 | /******/ // Flag the module as loaded 23 | /******/ module.l = true; 24 | /******/ 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | /******/ 29 | /******/ 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | /******/ 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | /******/ 36 | /******/ // identity function for calling harmony imports with the correct context 37 | /******/ __webpack_require__.i = function(value) { return value; }; 38 | /******/ 39 | /******/ // define getter function for harmony exports 40 | /******/ __webpack_require__.d = function(exports, name, getter) { 41 | /******/ if(!__webpack_require__.o(exports, name)) { 42 | /******/ Object.defineProperty(exports, name, { 43 | /******/ configurable: false, 44 | /******/ enumerable: true, 45 | /******/ get: getter 46 | /******/ }); 47 | /******/ } 48 | /******/ }; 49 | /******/ 50 | /******/ // getDefaultExport function for compatibility with non-harmony modules 51 | /******/ __webpack_require__.n = function(module) { 52 | /******/ var getter = module && module.__esModule ? 53 | /******/ function getDefault() { return module['default']; } : 54 | /******/ function getModuleExports() { return module; }; 55 | /******/ __webpack_require__.d(getter, 'a', getter); 56 | /******/ return getter; 57 | /******/ }; 58 | /******/ 59 | /******/ // Object.prototype.hasOwnProperty.call 60 | /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; 61 | /******/ 62 | /******/ // __webpack_public_path__ 63 | /******/ __webpack_require__.p = ""; 64 | /******/ 65 | /******/ // Load entry module and return exports 66 | /******/ return __webpack_require__(__webpack_require__.s = 6); 67 | /******/ }) 68 | /************************************************************************/ 69 | /******/ ([ 70 | /* 0 */ 71 | /***/ (function(module, exports, __webpack_require__) { 72 | 73 | (function () { 74 | if (typeof HTMLElement.prototype.appendChildren !== 'function') { 75 | HTMLElement.prototype.appendChildren = function (children) { 76 | return __webpack_require__(3)(this, children) 77 | } 78 | } 79 | })() 80 | 81 | 82 | /***/ }), 83 | /* 1 */ 84 | /***/ (function(module, exports, __webpack_require__) { 85 | 86 | (function () { 87 | if (typeof HTMLElement.prototype.setAttributes !== 'function') { 88 | HTMLElement.prototype.setAttributes = function (attributes) { 89 | return __webpack_require__(4)(this, attributes) 90 | } 91 | } 92 | })() 93 | 94 | 95 | /***/ }), 96 | /* 2 */ 97 | /***/ (function(module, exports, __webpack_require__) { 98 | 99 | (function () { 100 | if (typeof HTMLElement.prototype.setStyles !== 'function') { 101 | HTMLElement.prototype.setStyles = function (styles) { 102 | return __webpack_require__(5)(this, styles) 103 | } 104 | } 105 | })() 106 | 107 | 108 | /***/ }), 109 | /* 3 */ 110 | /***/ (function(module, exports) { 111 | 112 | module.exports = function appendChildren (element, children) { 113 | children = Array.isArray(children) ? children : [children] 114 | children.forEach(function (child) { 115 | if (child instanceof HTMLElement) { 116 | element.appendChild(child) 117 | } else if (child || typeof child === 'string') { 118 | element.appendChild(document.createTextNode(child.toString())) 119 | } 120 | }) 121 | } 122 | 123 | 124 | /***/ }), 125 | /* 4 */ 126 | /***/ (function(module, exports) { 127 | 128 | module.exports = function setAttributes (element, attributes) { 129 | var isPlainObject = Object.prototype.toString.call(attributes) === '[object Object]' && 130 | typeof attributes.constructor === 'function' && 131 | Object.prototype.toString.call(attributes.constructor.prototype) === '[object Object]' && 132 | attributes.constructor.prototype.hasOwnProperty('isPrototypeOf') 133 | 134 | if (isPlainObject) { 135 | for (var key in attributes) { 136 | element.setAttribute(key, attributes[key]) 137 | } 138 | } else { 139 | throw new DOMException('Failed to execute \'setAttributes\' on \'Element\': ' + Object.prototype.toString.call(attributes) + ' is not a plain object.') 140 | } 141 | } 142 | 143 | 144 | /***/ }), 145 | /* 5 */ 146 | /***/ (function(module, exports) { 147 | 148 | module.exports = function setStyles (element, styles) { 149 | for (var style in styles) element.style[style] = styles[style] 150 | } 151 | 152 | 153 | /***/ }), 154 | /* 6 */ 155 | /***/ (function(module, exports, __webpack_require__) { 156 | 157 | __webpack_require__(1) 158 | __webpack_require__(2) 159 | __webpack_require__(0) 160 | 161 | 162 | /***/ }) 163 | /******/ ]); -------------------------------------------------------------------------------- /res/js/util/sendercolorhandler.js: -------------------------------------------------------------------------------- 1 | class SenderColorHandler { 2 | static reflect(crc, n) { 3 | let j = 1, crcout = 0; 4 | for (let i = (1 << (n - 1)); i > 0; i >>= 1) { 5 | if ((crc & i) > 0) { 6 | crcout |= j; 7 | } 8 | j <<= 1; 9 | } 10 | return crcout; 11 | } 12 | 13 | static qChecksum(str) { 14 | let crc = 0xffff; 15 | const crcHighBitMask = 0x8000; 16 | 17 | for (let i = 0; i < str.length; i++) { 18 | const b = str.codePointAt(i); 19 | const c = SenderColorHandler.reflect(b, 8); 20 | for (let j = 0x80; j > 0; j >>= 1) { 21 | let highBit = crc & crcHighBitMask; 22 | crc <<= 1; 23 | if ((c & j) > 0) { 24 | highBit ^= crcHighBitMask; 25 | } 26 | if (highBit > 0) { 27 | crc ^= 0x1021; 28 | } 29 | } 30 | } 31 | 32 | crc = SenderColorHandler.reflect(crc, 16); 33 | crc ^= 0xffff; 34 | crc &= 0xffff; 35 | 36 | return crc; 37 | } 38 | 39 | static senderIndex(str) { 40 | const nickToHash = str.replace(/_*$/, "").toLowerCase(); 41 | return SenderColorHandler.qChecksum(nickToHash) & 0xF; 42 | } 43 | 44 | static nickToColor(str) { 45 | return SenderColorHandler.senderIndex(str).toString(16); 46 | } 47 | } -------------------------------------------------------------------------------- /res/js/util/statehandler.js: -------------------------------------------------------------------------------- 1 | class StateHandler extends Component { 2 | constructor() { 3 | super(); 4 | this.state = null; 5 | window.addEventListener("hashchange", () => { 6 | this.update() 7 | }); 8 | } 9 | 10 | init() { 11 | this.update(); 12 | } 13 | 14 | replace(value) { 15 | if (this.state.length > 0 || value === this.state) 16 | history.replaceState(null, "", "#" + encodeURIComponent(value)); 17 | else 18 | history.pushState(null, "", "#" + encodeURIComponent(value)); 19 | this.update(); 20 | } 21 | 22 | push(value) { 23 | history.pushState(null, "", "#" + encodeURIComponent(value)); 24 | this.update(); 25 | } 26 | 27 | update() { 28 | const oldState = this.state; 29 | this.state = decodeURIComponent(window.location.hash.substr(1)); 30 | 31 | if (this.state !== oldState && this.state.length > 0) 32 | this.sendEvent("update", [this.state]); 33 | } 34 | 35 | parse(overrides = {}) { 36 | const options = {}; 37 | 38 | function splitWithLimit(str, sep, n) { 39 | const out = []; 40 | let lastIndex = 0; 41 | let index; 42 | while (n-- > 1 && (index = str.indexOf(sep, lastIndex)) >= 0) { 43 | out.push(str.slice(lastIndex, index)); 44 | lastIndex = index + sep.length; 45 | } 46 | out.push(str.slice(lastIndex)); 47 | return out; 48 | } 49 | 50 | let query = []; 51 | const words = this.state.match(/\S+"[^"]+"|\S+/g); 52 | words.forEach((word) => { 53 | const parts = splitWithLimit(word, ":", 2); 54 | if ([ 55 | "sender", 56 | "buffer", 57 | "network", 58 | "before", 59 | "since" 60 | ].includes(parts[0])) { 61 | options[parts[0]] = parts[1]; 62 | } else { 63 | query.push(word); 64 | } 65 | }); 66 | return { 67 | ...options, 68 | ...overrides, 69 | query: query.join(" ") 70 | } 71 | } 72 | 73 | clear() { 74 | this.replace(""); 75 | } 76 | } -------------------------------------------------------------------------------- /res/js/util/storage.js: -------------------------------------------------------------------------------- 1 | class Storage { 2 | static get(key) { 3 | try { 4 | return JSON.parse(localStorage.getItem(key)); 5 | } catch (e) { 6 | return null; 7 | } 8 | } 9 | 10 | static set(key, value) { 11 | localStorage.setItem(key, JSON.stringify(value)); 12 | } 13 | 14 | static exists(key) { 15 | return !!Storage.get(key); 16 | } 17 | } -------------------------------------------------------------------------------- /templates/login.phtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <?php $t('title'); ?> 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
    29 |

    30 |
    31 | 32 |
    33 |

    34 | 35 |

    36 | 37 | 38 | 39 | 40 |
    41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /templates/search.phtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <?php $t('title'); ?> 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "buffer": { 3 | "open": "Öffnen", 4 | "close": "Schließen" 5 | }, 6 | "results": { 7 | "show_more": "Mehr Suchergebnisse anzeigen", 8 | "load_more": "Mehr Suchergebnisse laden" 9 | }, 10 | "context": { 11 | "load_later": "Ältere Nachrichten Laden", 12 | "load_earlier": "Neuere Nachrichten Laden", 13 | "show_hide": "Nachrichtenkontext anzeigen/verstecken" 14 | }, 15 | "history": { 16 | "error_unavailable": "Keine vorherigen Suchbegriffe vorhanden" 17 | }, 18 | "login": { 19 | "username": "Benutzername", 20 | "password": "Passwort", 21 | "submit": "Anmelden", 22 | "message": { 23 | "success_logout": "Sie haben sich erfolgreich abgemeldet.", 24 | "error_invalid": "Falsche Benutzername/Passwort Kombination", 25 | "error_unauthed": "Sie müssen angemeldet sein, um diese Seite zu nutzen." 26 | } 27 | }, 28 | "error": { 29 | "none_found": "Keine Ergebnisse gefunden" 30 | }, 31 | "search": "Suchen", 32 | "logout": "Abmelden", 33 | "title": "QuasselSearch" 34 | } -------------------------------------------------------------------------------- /translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "buffer": { 3 | "open": "Open", 4 | "close": "Close" 5 | }, 6 | "results": { 7 | "show_more": "Show More Results", 8 | "load_more": "Load More Results" 9 | }, 10 | "context": { 11 | "load_later": "Load Later Context", 12 | "load_earlier": "Load Earlier Context", 13 | "show_hide": "Toggle message context" 14 | }, 15 | "history": { 16 | "error_unavailable": "No search history available" 17 | }, 18 | "login": { 19 | "username": "Username", 20 | "password": "Password", 21 | "submit": "Log In", 22 | "message": { 23 | "success_logout": "You have successfully logged out.", 24 | "error_invalid": "Invalid username/password combination.", 25 | "error_unauthed": "You need to be logged in to access this page." 26 | } 27 | }, 28 | "error": { 29 | "none_found": "No results found" 30 | }, 31 | "search": "Search", 32 | "logout": "Logout", 33 | "title": "QuasselSearch" 34 | } --------------------------------------------------------------------------------