diff options
165 files changed, 11758 insertions, 2991 deletions
@@ -1,5 +1,5 @@ { - "presets": ["es2015", "stage-2"], - "plugins": ["transform-runtime", "lodash"], + "presets": ["es2015", "stage-2", "env"], + "plugins": ["transform-runtime", "lodash", "transform-vue-jsx"], "comments": false } @@ -6,3 +6,4 @@ test/unit/coverage test/e2e/reports selenium-debug.log .idea/ +config/local.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 694b77f9..6c83a123 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,32 +3,10 @@ # https://hub.docker.com/r/library/node/tags/ image: node:7 -before_script: - # Install ssh-agent if not already installed, it is required by Docker. - # (change apt-get to yum if you use a CentOS-based image) - - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' - - # Run ssh-agent (inside the build environment) - - eval $(ssh-agent -s) - - # For Docker builds disable host key checking. Be aware that by adding that - # you are suspectible to man-in-the-middle attacks. - # WARNING: Use this only with the Docker executor, if you use it with shell - # you will overwrite your user's SSH config. - - mkdir -p ~/.ssh - - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config' - -# This folder is cached between builds -# http://docs.gitlab.com/ce/ci/yaml/README.html#cache -#cache: -# paths: -# - node_modules/ - stages: - lint - build - test - - deploy lint: stage: lint @@ -50,14 +28,3 @@ build: artifacts: paths: - dist/ - -deploy: - stage: deploy - environment: dev - only: - - develop - script: - - yarn - - npm run build - - ssh-add <(echo "$SSH_PRIVATE_KEY") - - scp -r dist/* pleroma@tenshi.heldscal.la:~/pleroma diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 3673b8b7..d7c217ce 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -8,3 +8,4 @@ Contributors of this project. - hakui (hakui@freezepeach.xyz): CSS and styling - shpuld (shpuld@shitposter.club): CSS and styling - Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images. +- hj (hj@shigusegubu.club): Code @@ -29,4 +29,21 @@ npm run build npm run unit ``` -For detailed explanation on how things work, checkout the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). +# For Contributors: + +You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/config/local.example.json)) to enable some convenience dev options: + +* `target`: makes local dev server redirect to some existing instance's BE instead of local BE, useful for testing things in near-production environment and searching for real-life use-cases. +* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode. + +FE Build process also leaves current commit hash in global variable `___pleromafe_commit_hash` so that you can easily see which pleroma-fe commit instance is running, also helps pinpointing which commit was used when FE was bundled into BE. + +# Configuration + +Edit config.json for configuration. scopeOptionsEnabled gives you input fields for CWs and the scope settings. + +## Options + +### Login methods + +```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations. diff --git a/build/webpack.base.conf.js b/build/webpack.base.conf.js index 7bba3a10..ea46ce6f 100644 --- a/build/webpack.base.conf.js +++ b/build/webpack.base.conf.js @@ -2,6 +2,7 @@ var path = require('path') var config = require('../config') var utils = require('./utils') var projectRoot = path.resolve(__dirname, '../') +var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin') var env = process.env.NODE_ENV // check env & config/index.js to decide weither to enable CSS Sourcemaps for the @@ -54,7 +55,7 @@ module.exports = { loader: 'vue' }, { - test: /\.js$/, + test: /\.jsx?$/, loader: 'babel', include: projectRoot, exclude: /node_modules\/(?!tributejs)/ @@ -91,5 +92,10 @@ module.exports = { browsers: ['last 2 versions'] }) ] - } + }, + plugins: [ + new ServiceWorkerWebpackPlugin({ + entry: path.join(__dirname, '..', 'src/sw.js') + }) + ] } diff --git a/build/webpack.dev.conf.js b/build/webpack.dev.conf.js index 7e1a104f..9f34619c 100644 --- a/build/webpack.dev.conf.js +++ b/build/webpack.dev.conf.js @@ -18,7 +18,9 @@ module.exports = merge(baseWebpackConfig, { devtool: '#eval-source-map', plugins: [ new webpack.DefinePlugin({ - 'process.env': config.dev.env + 'process.env': config.dev.env, + 'COMMIT_HASH': JSON.stringify('DEV'), + 'DEV_OVERRIDES': JSON.stringify(config.dev.settings) }), // https://github.com/glenjamin/webpack-hot-middleware#installation--usage new webpack.optimize.OccurenceOrderPlugin(), diff --git a/build/webpack.prod.conf.js b/build/webpack.prod.conf.js index 6119f700..c02f8e86 100644 --- a/build/webpack.prod.conf.js +++ b/build/webpack.prod.conf.js @@ -7,8 +7,13 @@ var baseWebpackConfig = require('./webpack.base.conf') var ExtractTextPlugin = require('extract-text-webpack-plugin') var HtmlWebpackPlugin = require('html-webpack-plugin') var env = process.env.NODE_ENV === 'testing' - ? require('../config/test.env') - : config.build.env + ? require('../config/test.env') + : config.build.env + +let commitHash = require('child_process') + .execSync('git rev-parse --short HEAD') + .toString(); +console.log(commitHash) var webpackConfig = merge(baseWebpackConfig, { module: { @@ -29,7 +34,9 @@ var webpackConfig = merge(baseWebpackConfig, { plugins: [ // http://vuejs.github.io/vue-loader/workflow/production.html new webpack.DefinePlugin({ - 'process.env': env + 'process.env': env, + 'COMMIT_HASH': JSON.stringify(commitHash), + 'DEV_OVERRIDES': JSON.stringify(undefined) }), new webpack.optimize.UglifyJsPlugin({ compress: { diff --git a/config/index.js b/config/index.js index c48d91b8..56fa5940 100644 --- a/config/index.js +++ b/config/index.js @@ -1,5 +1,15 @@ // see http://vuejs-templates.github.io/webpack for documentation. -var path = require('path') +const path = require('path') +let settings = {} +try { + settings = require('./local.json') + console.log('Using local dev server settings (/config/local.json):') + console.log(JSON.stringify(settings, null, 2)) +} catch (e) { + console.log('Local dev server settings not found (/config/local.json)') +} + +const target = settings.target || 'http://localhost:4000/' module.exports = { build: { @@ -19,16 +29,22 @@ module.exports = { dev: { env: require('./dev.env'), port: 8080, + settings, assetsSubDirectory: 'static', assetsPublicPath: '/', proxyTable: { '/api': { - target: 'htts://localhost:4000/', + target, + changeOrigin: true, + cookieDomainRewrite: 'localhost' + }, + '/nodeinfo': { + target, changeOrigin: true, cookieDomainRewrite: 'localhost' }, '/socket': { - target: 'htts://localhost:4000/', + target, changeOrigin: true, cookieDomainRewrite: 'localhost', ws: true diff --git a/config/local.example.json b/config/local.example.json new file mode 100644 index 00000000..2a3bd00d --- /dev/null +++ b/config/local.example.json @@ -0,0 +1,4 @@ +{ + "target": "https://pleroma.soykaf.com/", + "staticConfigPreference": false +} @@ -4,6 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Pleroma</title> + <link rel="icon" type="image/png" href="/favicon.png"> <link rel="stylesheet" href="/static/font/css/fontello.css"> <link rel="stylesheet" href="/static/font/css/animation.css"> </head> diff --git a/package.json b/package.json index 5718d24d..60e5ca02 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-lodash": "^3.2.11", + "chromatism": "^3.0.0", "diff": "^3.0.1", "karma-mocha-reporter": "^2.2.1", "localforage": "^1.5.0", @@ -30,6 +31,7 @@ "vue-router": "^3.0.1", "vue-template-compiler": "^2.3.4", "vue-timeago": "^3.1.2", + "vuelidate": "^0.7.4", "vuex": "^3.0.1", "whatwg-fetch": "^2.0.3" }, @@ -37,8 +39,12 @@ "autoprefixer": "^6.4.0", "babel-core": "^6.0.0", "babel-eslint": "^7.0.0", + "babel-helper-vue-jsx-merge-props": "^2.0.3", "babel-loader": "^6.0.0", + "babel-plugin-syntax-jsx": "^6.18.0", "babel-plugin-transform-runtime": "^6.0.0", + "babel-plugin-transform-vue-jsx": "3", + "babel-preset-env": "^1.7.0", "babel-preset-es2015": "^6.0.0", "babel-preset-stage-2": "^6.0.0", "babel-register": "^6.0.0", @@ -63,6 +69,7 @@ "html-webpack-plugin": "^2.8.1", "http-proxy-middleware": "^0.17.2", "inject-loader": "^2.0.1", + "iso-639-1": "^2.0.3", "isparta-loader": "^2.0.0", "json-loader": "^0.5.4", "karma": "^1.3.0", @@ -83,6 +90,7 @@ "raw-loader": "^0.5.1", "selenium-server": "2.53.1", "semver": "^5.3.0", + "serviceworker-webpack-plugin": "0.2.3", "shelljs": "^0.7.4", "sinon": "^1.17.3", "sinon-chai": "^2.8.0", @@ -3,6 +3,8 @@ import NavPanel from './components/nav_panel/nav_panel.vue' import Notifications from './components/notifications/notifications.vue' import UserFinder from './components/user_finder/user_finder.vue' import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' +import FeaturesPanel from './components/features_panel/features_panel.vue' +import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import ChatPanel from './components/chat_panel/chat_panel.vue' export default { @@ -12,22 +14,61 @@ export default { NavPanel, Notifications, UserFinder, - ChatPanel, - InstanceSpecificPanel + InstanceSpecificPanel, + FeaturesPanel, + WhoToFollowPanel, + ChatPanel }, data: () => ({ - mobileActivePanel: 'timeline' + mobileActivePanel: 'timeline', + supportsMask: window.CSS && window.CSS.supports && ( + window.CSS.supports('mask-size', 'contain') || + window.CSS.supports('-webkit-mask-size', 'contain') || + window.CSS.supports('-moz-mask-size', 'contain') || + window.CSS.supports('-ms-mask-size', 'contain') || + window.CSS.supports('-o-mask-size', 'contain') + ) }), + created () { + // Load the locale from the storage + this.$i18n.locale = this.$store.state.config.interfaceLanguage + }, computed: { currentUser () { return this.$store.state.users.currentUser }, background () { - return this.currentUser.background_image || this.$store.state.config.background + return this.currentUser.background_image || this.$store.state.instance.background + }, + enableMask () { return this.supportsMask && this.$store.state.instance.logoMask }, + logoStyle () { + return { + 'visibility': this.enableMask ? 'hidden' : 'visible' + } + }, + logoMaskStyle () { + return this.enableMask ? { + 'mask-image': `url(${this.$store.state.instance.logo})` + } : { + 'background-color': this.enableMask ? '' : 'transparent' + } + }, + logoBgStyle () { + return Object.assign({ + 'margin': `${this.$store.state.instance.logoMargin} 0` + }, this.enableMask ? {} : { + 'background-color': this.enableMask ? '' : 'transparent' + }) + }, + logo () { return this.$store.state.instance.logo }, + style () { + return { + '--body-background-image': `url(${this.background})`, + 'background-image': `url(${this.background})` + } }, - logoStyle () { return { 'background-image': `url(${this.$store.state.config.logo})` } }, - style () { return { 'background-image': `url(${this.background})` } }, - sitename () { return this.$store.state.config.name }, + sitename () { return this.$store.state.instance.name }, chat () { return this.$store.state.chat.channel.state === 'joined' }, - showInstanceSpecificPanel () { return this.$store.state.config.showInstanceSpecificPanel } + suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, + showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel } }, methods: { activatePanel (panelName) { @@ -37,6 +78,7 @@ export default { window.scrollTo(0, 0) }, logout () { + this.$router.replace('/main/public') this.$store.dispatch('logout') } } diff --git a/src/App.scss b/src/App.scss index a8601220..5355d899 100644 --- a/src/App.scss +++ b/src/App.scss @@ -34,10 +34,11 @@ h4 { body { font-family: sans-serif; + font-family: var(--interfaceFont, sans-serif); font-size: 14px; margin: 0; - color: $fallback--fg; - color: var(--fg, $fallback--fg); + color: $fallback--text; + color: var(--text, $fallback--text); max-width: 100vw; overflow-x: hidden; } @@ -48,24 +49,39 @@ a { color: var(--link, $fallback--link); } -button{ +button { user-select: none; - color: $fallback--fg; - color: var(--fg, $fallback--fg); - background-color: $fallback--btn; - background-color: var(--btn, $fallback--btn); + color: $fallback--text; + color: var(--btnText, $fallback--text); + background-color: $fallback--fg; + background-color: var(--btn, $fallback--fg); border: none; border-radius: $fallback--btnRadius; border-radius: var(--btnRadius, $fallback--btnRadius); cursor: pointer; - border-top: 1px solid rgba(255, 255, 255, 0.2); - border-bottom: 1px solid rgba(0, 0, 0, 0.2); - box-shadow: 0px 0px 2px black; + box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset; + box-shadow: var(--buttonShadow); font-size: 14px; font-family: sans-serif; + font-family: var(--interfaceFont, sans-serif); + + i[class*=icon-] { + color: $fallback--text; + color: var(--btnText, $fallback--text); + } + + &::-moz-focus-inner { + border: none; + } &:hover { box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3); + box-shadow: var(--buttonHoverShadow); + } + + &:active { + box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset; + box-shadow: var(--buttonPressedShadow); } &:disabled { @@ -88,23 +104,29 @@ label.select { input, textarea, .select { border: none; - border-radius: $fallback--btnRadius; - border-radius: var(--btnRadius, $fallback--btnRadius); - border-bottom: 1px solid rgba(255, 255, 255, 0.2); - border-top: 1px solid rgba(0, 0, 0, 0.2); - box-shadow: 0px 0px 2px black inset; - background-color: $fallback--lightBg; - background-color: var(--lightBg, $fallback--lightBg); - color: $fallback--lightFg; - color: var(--lightFg, $fallback--lightFg); + border-radius: $fallback--inputRadius; + border-radius: var(--inputRadius, $fallback--inputRadius); + box-shadow: 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px 0px 2px 0px rgba(0, 0, 0, 1) inset; + box-shadow: var(--inputShadow); + background-color: $fallback--fg; + background-color: var(--input, $fallback--fg); + color: $fallback--lightText; + color: var(--inputText, $fallback--lightText); font-family: sans-serif; + font-family: var(--inputFont, sans-serif); font-size: 14px; - padding: 8px 7px; + padding: 8px .5em; box-sizing: border-box; display: inline-block; position: relative; - height: 29px; + height: 28px; line-height: 16px; + hyphens: none; + + &:disabled, &[disabled=disabled] { + cursor: not-allowed; + opacity: 0.5; + } .icon-down-open { position: absolute; @@ -112,9 +134,9 @@ input, textarea, .select { bottom: 0; right: 5px; height: 100%; - color: $fallback--fg; - color: var(--fg, $fallback--fg); - line-height: 29px; + color: $fallback--text; + color: var(--text, $fallback--text); + line-height: 28px; z-index: 0; pointer-events: none; } @@ -125,22 +147,41 @@ input, textarea, .select { appearance: none; background: transparent; border: none; + color: $fallback--text; + color: var(--text, $fallback--text); margin: 0; - color: $fallback--fg; - color: var(--fg, $fallback--fg); - padding: 4px 2em 3px 3px; + padding: 0 2em 0 .2em; + font-family: sans-serif; + font-family: var(--inputFont, sans-serif); + font-size: 14px; width: 100%; z-index: 1; - height: 29px; + height: 28px; line-height: 16px; } + &[type=range] { + background: none; + border: none; + margin: 0; + box-shadow: none; + flex: 1; + } + &[type=radio], &[type=checkbox] { display: none; &:checked + label::before { - color: $fallback--fg; - color: var(--fg, $fallback--fg); + color: $fallback--text; + color: var(--text, $fallback--text); + } + &:disabled, + { + &, + & + label, + & + label::before { + opacity: .5; + } } + label::before { display: inline-block; @@ -148,14 +189,13 @@ input, textarea, .select { transition: color 200ms; width: 1.1em; height: 1.1em; - border-radius: $fallback--checkBoxRadius; - border-radius: var(--checkBoxRadius, $fallback--checkBoxRadius); - border-bottom: 1px solid rgba(255, 255, 255, 0.2); - border-top: 1px solid rgba(0, 0, 0, 0.2); + border-radius: $fallback--checkboxRadius; + border-radius: var(--checkboxRadius, $fallback--checkboxRadius); box-shadow: 0px 0px 2px black inset; + box-shadow: var(--inputShadow); margin-right: .5em; - background-color: $fallback--btn; - background-color: var(--btn, $fallback--btn); + background-color: $fallback--fg; + background-color: var(--input, $fallback--fg); vertical-align: top; text-align: center; line-height: 1.1em; @@ -168,6 +208,13 @@ input, textarea, .select { } } +option { + color: $fallback--text; + color: var(--text, $fallback--text); + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); +} + i[class*=icon-] { color: $fallback--icon; color: var(--icon, $fallback--icon) @@ -211,6 +258,40 @@ nav { position: fixed; height: 50px; + .logo { + display: flex; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + align-items: stretch; + justify-content: center; + flex: 0 0 auto; + z-index: -1; + + .mask { + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: $fallback--fg; + background-color: var(--topBarText, $fallback--fg); + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + img { + height: 100%; + object-fit: contain; + display: block; + flex: 0; + } + } + .inner-nav { padding-left: 20px; padding-right: 20px; @@ -219,13 +300,10 @@ nav { flex-basis: 970px; margin: auto; height: 50px; - background-repeat: no-repeat; - background-position: center; - background-size: auto 80%; - a i { + a, a i { color: $fallback--link; - color: var(--link, $fallback--link); + color: var(--topBarLink, $fallback--link); } } } @@ -248,15 +326,33 @@ main-router { .panel { display: flex; + position: relative; + flex-direction: column; margin: 0.5em; background-color: $fallback--bg; background-color: var(--bg, $fallback--bg); - border-radius: $fallback--panelRadius; - border-radius: var(--panelRadius, $fallback--panelRadius); - box-shadow: 1px 1px 4px rgba(0,0,0,.6); + &::after, & { + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + } + + &::after { + content: ''; + position: absolute; + + top: 0; + bottom: 0; + left: 0; + right: 0; + + pointer-events: none; + + box-shadow: 1px 1px 4px rgba(0,0,0,.6); + box-shadow: var(--panelShadow); + } } .panel-body:empty::before { @@ -267,15 +363,55 @@ main-router { } .panel-heading { + display: flex; border-radius: $fallback--panelRadius $fallback--panelRadius 0 0; border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0; background-size: cover; - padding: 0.6em 1.0em; + padding: .6em .6em; text-align: left; - font-size: 1.3em; - line-height: 24px; - background-color: $fallback--btn; - background-color: var(--btn, $fallback--btn); + line-height: 28px; + color: var(--panelText); + background-color: $fallback--fg; + background-color: var(--panel, $fallback--fg); + align-items: baseline; + box-shadow: var(--panelHeaderShadow); + + .title { + flex: 1 0 auto; + font-size: 1.3em; + } + + .faint { + background-color: transparent; + color: $fallback--faint; + color: var(--panelFaint, $fallback--faint); + } + + .alert { + white-space: nowrap; + text-overflow: ellipsis; + overflow-x: hidden; + } + + button { + flex-shrink: 0; + } + + button, .alert { + // height: 100%; + line-height: 21px; + min-height: 0; + box-sizing: border-box; + margin: 0; + margin-left: .25em; + min-width: 1px; + align-self: stretch; + } + + a { + color: $fallback--link; + color: var(--panelLink, $fallback--link) + } } .panel-heading.stub { @@ -286,6 +422,11 @@ main-router { .panel-footer { border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); + + a { + color: $fallback--link; + color: var(--panelLink, $fallback--link) + } } .panel-body > p { @@ -304,11 +445,13 @@ main-router { nav { z-index: 1000; - background-color: $fallback--btn; - background-color: var(--btn, $fallback--btn); + color: var(--topBarText); + background-color: $fallback--fg; + background-color: var(--topBar, $fallback--fg); color: $fallback--faint; color: var(--faint, $fallback--faint); box-shadow: 0px 0px 4px rgba(0,0,0,.6); + box-shadow: var(--topBarShadow); } .fade-enter-active, .fade-leave-active { @@ -382,20 +525,46 @@ nav { flex-grow: 0; } } +.badge { + display: inline-block; + border-radius: 99px; + min-width: 22px; + max-width: 22px; + min-height: 22px; + max-height: 22px; + font-size: 15px; + line-height: 22px; + text-align: center; + vertical-align: middle; + white-space: nowrap; + padding: 0; + + &.badge-notification { + background-color: $fallback--cRed; + background-color: var(--badgeNotification, $fallback--cRed); + color: white; + color: var(--badgeNotificationText, white); + } +} .alert { margin: 0.35em; padding: 0.25em; border-radius: $fallback--tooltipRadius; border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - color: $fallback--faint; - color: var(--faint, $fallback--faint); min-height: 28px; line-height: 28px; &.error { - background-color: $fallback--cAlertRed; - background-color: var(--cAlertRed, $fallback--cAlertRed); + background-color: $fallback--alertError; + background-color: var(--alertError, $fallback--alertError); + color: $fallback--text; + color: var(--alertErrorText, $fallback--text); + + .panel-heading & { + color: $fallback--text; + color: var(--alertErrorPanelText, $fallback--text); + } } } @@ -426,3 +595,30 @@ nav { text-align: right; padding-right: 20px; } + +.visibility-tray { + font-size: 1.2em; + padding: 3px; + cursor: pointer; + + .selected { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + + .text-format { + float: right; + } + + div { + padding-top: 5px; + } +} + +.visibility-notice { + padding: .5em; + border: 1px solid $fallback--faint; + border: 1px solid var(--faint, $fallback--faint); + border-radius: $fallback--inputRadius; + border-radius: var(--inputRadius, $fallback--inputRadius); +} diff --git a/src/App.vue b/src/App.vue index a8d17fa7..16cd08d4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,13 +1,17 @@ <template> <div id="app" v-bind:style="style"> <nav class='container' @click="scrollToTop()" id="nav"> - <div class='inner-nav' :style="logoStyle"> + <div class='logo' :style='logoBgStyle'> + <div class='mask' :style='logoMaskStyle'></div> + <img :src='logo' :style='logoStyle'> + </div> + <div class='inner-nav'> <div class='item'> <router-link :to="{ name: 'root'}">{{sitename}}</router-link> </div> <div class='item right'> <user-finder class="nav-icon"></user-finder> - <router-link :to="{ name: 'settings'}"><i class="icon-cog nav-icon"></i></router-link> + <router-link @click.native="activatePanel('timeline')" :to="{ name: 'settings'}"><i class="icon-cog nav-icon" :title="$t('nav.preferences')"></i></router-link> <a href="#" v-if="currentUser" @click.prevent="logout"><i class="icon-logout nav-icon" :title="$t('login.logout')"></i></a> </div> </div> @@ -21,10 +25,12 @@ <div class="sidebar-bounds"> <div class="sidebar-scroller"> <div class="sidebar"> - <user-panel></user-panel> - <nav-panel></nav-panel> + <user-panel :activatePanel="activatePanel"></user-panel> + <nav-panel :activatePanel="activatePanel"></nav-panel> <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> - <notifications v-if="currentUser"></notifications> + <features-panel v-if="!currentUser"></features-panel> + <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> + <notifications :activatePanel="activatePanel" v-if="currentUser"></notifications> </div> </div> </div> diff --git a/src/_variables.scss b/src/_variables.scss index d90a1d48..150e4fb5 100644 --- a/src/_variables.scss +++ b/src/_variables.scss @@ -3,24 +3,25 @@ $main-background: white; $darkened-background: whitesmoke; $fallback--bg: #121a24; -$fallback--btn: #182230; -$fallback--faint: #999; -$fallback--fg: #b9b9ba; +$fallback--fg: #182230; +$fallback--faint: rgba(185, 185, 186, .5); +$fallback--text: #b9b9ba; $fallback--link: #d8a070; $fallback--icon: #666; $fallback--lightBg: rgb(21, 30, 42); -$fallback--lightFg: #b9b9ba; +$fallback--lightText: #b9b9ba; $fallback--border: #222; $fallback--cRed: #ff0000; $fallback--cBlue: #0095ff; $fallback--cGreen: #0fa00f; $fallback--cOrange: orange; -$fallback--cAlertRed: rgba(211,16,20,.5); +$fallback--alertError: rgba(211,16,20,.5); $fallback--panelRadius: 10px; -$fallback--checkBoxRadius: 2px; +$fallback--checkboxRadius: 2px; $fallback--btnRadius: 4px; +$fallback--inputRadius: 4px; $fallback--tooltipRadius: 5px; $fallback--avatarRadius: 4px; $fallback--avatarAltRadius: 10px; diff --git a/src/assets/nsfw.png b/src/assets/nsfw.png Binary files differindex bb6556b4..42749033 100644 --- a/src/assets/nsfw.png +++ b/src/assets/nsfw.png diff --git a/src/boot/after_store.js b/src/boot/after_store.js new file mode 100644 index 00000000..07337595 --- /dev/null +++ b/src/boot/after_store.js @@ -0,0 +1,199 @@ +import Vue from 'vue' +import VueRouter from 'vue-router' + +import App from '../App.vue' +import PublicTimeline from '../components/public_timeline/public_timeline.vue' +import PublicAndExternalTimeline from '../components/public_and_external_timeline/public_and_external_timeline.vue' +import FriendsTimeline from '../components/friends_timeline/friends_timeline.vue' +import TagTimeline from '../components/tag_timeline/tag_timeline.vue' +import ConversationPage from '../components/conversation-page/conversation-page.vue' +import Mentions from '../components/mentions/mentions.vue' +import DMs from '../components/dm_timeline/dm_timeline.vue' +import UserProfile from '../components/user_profile/user_profile.vue' +import Settings from '../components/settings/settings.vue' +import Registration from '../components/registration/registration.vue' +import UserSettings from '../components/user_settings/user_settings.vue' +import FollowRequests from '../components/follow_requests/follow_requests.vue' +import OAuthCallback from '../components/oauth_callback/oauth_callback.vue' +import UserSearch from '../components/user_search/user_search.vue' + +const afterStoreSetup = ({ store, i18n }) => { + window.fetch('/api/statusnet/config.json') + .then((res) => res.json()) + .then((data) => { + const { name, closed: registrationClosed, textlimit, server, vapidPublicKey } = data.site + + store.dispatch('setInstanceOption', { name: 'name', value: name }) + store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') }) + store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) }) + store.dispatch('setInstanceOption', { name: 'server', value: server }) + + if (vapidPublicKey) { + store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) + } + + var apiConfig = data.site.pleromafe + + window.fetch('/static/config.json') + .then((res) => res.json()) + .catch((err) => { + console.warn('Failed to load static/config.json, continuing without it.') + console.warn(err) + return {} + }) + .then((staticConfig) => { + const overrides = window.___pleromafe_dev_overrides || {} + const env = window.___pleromafe_mode.NODE_ENV + + // This takes static config and overrides properties that are present in apiConfig + let config = {} + if (overrides.staticConfigPreference && env === 'development') { + console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG') + config = Object.assign({}, apiConfig, staticConfig) + } else { + config = Object.assign({}, staticConfig, apiConfig) + } + + var theme = (config.theme) + var background = (config.background) + var hidePostStats = (config.hidePostStats) + var hideUserStats = (config.hideUserStats) + var logo = (config.logo) + var logoMask = (typeof config.logoMask === 'undefined' ? true : config.logoMask) + var logoMargin = (typeof config.logoMargin === 'undefined' ? 0 : config.logoMargin) + var redirectRootNoLogin = (config.redirectRootNoLogin) + var redirectRootLogin = (config.redirectRootLogin) + var chatDisabled = (config.chatDisabled) + var showInstanceSpecificPanel = (config.showInstanceSpecificPanel) + var scopeOptionsEnabled = (config.scopeOptionsEnabled) + var formattingOptionsEnabled = (config.formattingOptionsEnabled) + var collapseMessageWithSubject = (config.collapseMessageWithSubject) + var loginMethod = (config.loginMethod) + var scopeCopy = (config.scopeCopy) + var subjectLineBehavior = (config.subjectLineBehavior) + var alwaysShowSubjectInput = (config.alwaysShowSubjectInput) + + store.dispatch('setInstanceOption', { name: 'theme', value: theme }) + store.dispatch('setInstanceOption', { name: 'background', value: background }) + store.dispatch('setInstanceOption', { name: 'hidePostStats', value: hidePostStats }) + store.dispatch('setInstanceOption', { name: 'hideUserStats', value: hideUserStats }) + store.dispatch('setInstanceOption', { name: 'logo', value: logo }) + store.dispatch('setInstanceOption', { name: 'logoMask', value: logoMask }) + store.dispatch('setInstanceOption', { name: 'logoMargin', value: logoMargin }) + store.dispatch('setInstanceOption', { name: 'redirectRootNoLogin', value: redirectRootNoLogin }) + store.dispatch('setInstanceOption', { name: 'redirectRootLogin', value: redirectRootLogin }) + store.dispatch('setInstanceOption', { name: 'showInstanceSpecificPanel', value: showInstanceSpecificPanel }) + store.dispatch('setInstanceOption', { name: 'scopeOptionsEnabled', value: scopeOptionsEnabled }) + store.dispatch('setInstanceOption', { name: 'formattingOptionsEnabled', value: formattingOptionsEnabled }) + store.dispatch('setInstanceOption', { name: 'collapseMessageWithSubject', value: collapseMessageWithSubject }) + store.dispatch('setInstanceOption', { name: 'loginMethod', value: loginMethod }) + store.dispatch('setInstanceOption', { name: 'scopeCopy', value: scopeCopy }) + store.dispatch('setInstanceOption', { name: 'subjectLineBehavior', value: subjectLineBehavior }) + store.dispatch('setInstanceOption', { name: 'alwaysShowSubjectInput', value: alwaysShowSubjectInput }) + if (chatDisabled) { + store.dispatch('disableChat') + } + + const routes = [ + { name: 'root', + path: '/', + redirect: to => { + return (store.state.users.currentUser + ? store.state.instance.redirectRootLogin + : store.state.instance.redirectRootNoLogin) || '/main/all' + }}, + { path: '/main/all', component: PublicAndExternalTimeline }, + { path: '/main/public', component: PublicTimeline }, + { path: '/main/friends', component: FriendsTimeline }, + { path: '/tag/:tag', component: TagTimeline }, + { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, + { name: 'user-profile', path: '/users/:id', component: UserProfile }, + { name: 'mentions', path: '/:username/mentions', component: Mentions }, + { name: 'dms', path: '/:username/dms', component: DMs }, + { name: 'settings', path: '/settings', component: Settings }, + { name: 'registration', path: '/registration', component: Registration }, + { name: 'registration', path: '/registration/:token', component: Registration }, + { name: 'friend-requests', path: '/friend-requests', component: FollowRequests }, + { name: 'user-settings', path: '/user-settings', component: UserSettings }, + { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, + { name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) } + ] + + const router = new VueRouter({ + mode: 'history', + routes, + scrollBehavior: (to, from, savedPosition) => { + if (to.matched.some(m => m.meta.dontScroll)) { + return false + } + return savedPosition || { x: 0, y: 0 } + } + }) + + /* eslint-disable no-new */ + new Vue({ + router, + store, + i18n, + el: '#app', + render: h => h(App) + }) + }) + }) + + window.fetch('/static/terms-of-service.html') + .then((res) => res.text()) + .then((html) => { + store.dispatch('setInstanceOption', { name: 'tos', value: html }) + }) + + window.fetch('/api/pleroma/emoji.json') + .then( + (res) => res.json() + .then( + (values) => { + const emoji = Object.keys(values).map((key) => { + return { shortcode: key, image_url: values[key] } + }) + store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji }) + store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) + }, + (failure) => { + store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false }) + } + ), + (error) => console.log(error) + ) + + window.fetch('/static/emoji.json') + .then((res) => res.json()) + .then((values) => { + const emoji = Object.keys(values).map((key) => { + return { shortcode: key, image_url: false, 'utf': values[key] } + }) + store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) + }) + + window.fetch('/instance/panel.html') + .then((res) => res.text()) + .then((html) => { + store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html }) + }) + + window.fetch('/nodeinfo/2.0.json') + .then((res) => res.json()) + .then((data) => { + const metadata = data.metadata + + const features = metadata.features + store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) + store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) + store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) + + const suggestions = metadata.suggestions + store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled }) + store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web }) + }) +} + +export default afterStoreSetup diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index ad46d0a1..97c4f283 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -13,9 +13,11 @@ const Attachment = { return { nsfwImage: this.$store.state.config.nsfwCensorImage || nsfwImage, hideNsfwLocal: this.$store.state.config.hideNsfw, + preloadImage: this.$store.state.config.preloadImage, + loopVideo: this.$store.state.config.loopVideo, showHidden: false, loading: false, - img: document.createElement('img') + img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img') } }, components: { @@ -45,14 +47,35 @@ const Attachment = { } }, toggleHidden () { - if (this.img.onload) { - this.img.onload() + if (this.img && !this.preloadImage) { + if (this.img.onload) { + this.img.onload() + } else { + this.loading = true + this.img.src = this.attachment.url + this.img.onload = () => { + this.loading = false + this.showHidden = !this.showHidden + } + } } else { - this.loading = true - this.img.src = this.attachment.url - this.img.onload = () => { - this.loading = false - this.showHidden = !this.showHidden + this.showHidden = !this.showHidden + } + }, + onVideoDataLoad (e) { + if (typeof e.srcElement.webkitAudioDecodedByteCount !== 'undefined') { + // non-zero if video has audio track + if (e.srcElement.webkitAudioDecodedByteCount > 0) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly + } + } else if (typeof e.srcElement.mozHasAudio !== 'undefined') { + // true if video has audio track + if (e.srcElement.mozHasAudio) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly + } + } else if (typeof e.srcElement.audioTracks !== 'undefined') { + if (e.srcElement.audioTracks.length > 0) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly } } } diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index b2f63668..5eaa0d1d 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -2,19 +2,18 @@ <div v-if="size==='hide'"> <a class="placeholder" v-if="type !== 'html'" target="_blank" :href="attachment.url">[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]</a> </div> - <div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth}" v-show="!isEmpty"> + <div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" v-show="!isEmpty"> <a class="image-attachment" v-if="hidden" @click.prevent="toggleHidden()"> <img :key="nsfwImage" :src="nsfwImage"/> </a> <div class="hider" v-if="nsfw && hideNsfwLocal && !hidden"> <a href="#" @click.prevent="toggleHidden()">Hide</a> </div> - - <a v-if="type === 'image' && !hidden" class="image-attachment" :href="attachment.url" target="_blank"> + <a v-if="type === 'image' && (!hidden || preloadImage)" class="image-attachment" :class="{'hidden': hidden && preloadImage}" :href="attachment.url" target="_blank" :title="attachment.description"> <StillImage :class="{'small': isSmall}" referrerpolicy="no-referrer" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/> </a> - <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" :src="attachment.url" controls loop></video> + <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" @loadeddata="onVideoDataLoad" :src="attachment.url" controls :loop="loopVideo" playsinline></video> <audio v-if="type === 'audio'" :src="attachment.url" controls></audio> @@ -38,7 +37,6 @@ .attachments { display: flex; flex-wrap: wrap; - margin-right: -0.7em; .attachment.media-upload-container { flex: 0 0 auto; @@ -50,6 +48,14 @@ margin-right: 0.5em; } + .nsfw-placeholder { + cursor: pointer; + + &.loading { + cursor: progress; + } + } + .small-attachment { &.image, &.video { max-width: 35%; @@ -58,6 +64,7 @@ } .attachment { + position: relative; flex: 1 0 30%; margin: 0.5em 0.7em 0.6em 0.0em; align-self: flex-start; @@ -85,10 +92,6 @@ display: flex; } - &.loading { - cursor: progress; - } - .hider { position: absolute; margin: 10px; @@ -96,6 +99,9 @@ background: rgba(230,230,230,0.6); font-weight: bold; z-index: 4; + line-height: 1; + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); } .small { @@ -154,6 +160,10 @@ display: flex; flex: 1; + &.hidden { + display: none; + } + .still-image { width: 100%; height: 100%; diff --git a/src/components/chat_panel/chat_panel.js b/src/components/chat_panel/chat_panel.js index d528d0a1..d8736d17 100644 --- a/src/components/chat_panel/chat_panel.js +++ b/src/components/chat_panel/chat_panel.js @@ -3,7 +3,7 @@ const chatPanel = { return { currentMessage: '', channel: null, - collapsed: false + collapsed: true } }, computed: { diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue index 30070d3e..f174319a 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -55,8 +55,8 @@ .chat-heading { cursor: pointer; .icon-comment-empty { - color: $fallback--fg; - color: var(--fg, $fallback--fg); + color: $fallback--text; + color: var(--text, $fallback--text); } } diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue new file mode 100644 index 00000000..34eec248 --- /dev/null +++ b/src/components/color_input/color_input.vue @@ -0,0 +1,53 @@ +<template> +<div class="color-control style-control" :class="{ disabled: !present || disabled }"> + <label :for="name" class="label"> + {{label}} + </label> + <input + v-if="typeof fallback !== 'undefined'" + class="opt exlcude-disabled" + :id="name + '-o'" + type="checkbox" + :checked="present" + @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"> + <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label> + <input + :id="name" + class="color-input" + type="color" + :value="value || fallback" + :disabled="!present || disabled" + @input="$emit('input', $event.target.value)" + > + <input + :id="name + '-t'" + class="text-input" + type="text" + :value="value || fallback" + :disabled="!present || disabled" + @input="$emit('input', $event.target.value)" + > +</div> +</template> + +<script> +export default { + props: [ + 'name', 'label', 'value', 'fallback', 'disabled' + ], + computed: { + present () { + return typeof this.value !== 'undefined' + } + } +} +</script> + +<style lang="scss"> +.color-control { + input.text-input { + max-width: 7em; + flex: 1; + } +} +</style> diff --git a/src/components/contrast_ratio/contrast_ratio.vue b/src/components/contrast_ratio/contrast_ratio.vue new file mode 100644 index 00000000..bd971d00 --- /dev/null +++ b/src/components/contrast_ratio/contrast_ratio.vue @@ -0,0 +1,69 @@ +<template> +<span v-if="contrast" class="contrast-ratio"> + <span :title="hint" class="rating"> + <span v-if="contrast.aaa"> + <i class="icon-thumbs-up-alt"/> + </span> + <span v-if="!contrast.aaa && contrast.aa"> + <i class="icon-adjust"/> + </span> + <span v-if="!contrast.aaa && !contrast.aa"> + <i class="icon-attention"/> + </span> + </span> + <span class="rating" v-if="contrast && large" :title="hint_18pt"> + <span v-if="contrast.laaa"> + <i class="icon-thumbs-up-alt"/> + </span> + <span v-if="!contrast.laaa && contrast.laa"> + <i class="icon-adjust"/> + </span> + <span v-if="!contrast.laaa && !contrast.laa"> + <i class="icon-attention"/> + </span> + </span> +</span> +</template> + +<script> +export default { + props: [ + 'large', 'contrast' + ], + computed: { + hint () { + const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad') + const level = this.$t(`settings.style.common.contrast.level.${levelVal}`) + const context = this.$t('settings.style.common.contrast.context.text') + const ratio = this.contrast.text + return this.$t('settings.style.common.contrast.hint', { level, context, ratio }) + }, + hint_18pt () { + const levelVal = this.contrast.laaa ? 'aaa' : (this.contrast.laa ? 'aa' : 'bad') + const level = this.$t(`settings.style.common.contrast.level.${levelVal}`) + const context = this.$t('settings.style.common.contrast.context.18pt') + const ratio = this.contrast.text + return this.$t('settings.style.common.contrast.hint', { level, context, ratio }) + } + } +} +</script> + +<style lang="scss"> +.contrast-ratio { + display: flex; + justify-content: flex-end; + + margin-top: -4px; + margin-bottom: 5px; + + .label { + margin-right: 1em; + } + + .rating { + display: inline-block; + text-align: center; + } +} +</style> diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 308e5e7d..5528fef6 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -1,9 +1,9 @@ <template> <div class="timeline panel panel-default"> <div class="panel-heading conversation-heading"> - {{ $t('timeline.conversation') }} - <span v-if="collapsable" style="float:right;"> - <small><a href="#" @click.prevent="$emit('toggleExpanded')">Collapse</a></small> + <span class="title"> {{ $t('timeline.conversation') }} </span> + <span v-if="collapsable"> + <a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a> </span> </div> <div class="panel-body"> diff --git a/src/components/delete_button/delete_button.vue b/src/components/delete_button/delete_button.vue index d13547e2..b458b0dc 100644 --- a/src/components/delete_button/delete_button.vue +++ b/src/components/delete_button/delete_button.vue @@ -14,8 +14,8 @@ .icon-cancel,.delete-status { cursor: pointer; &:hover { - color: var(--cRed, $fallback--cRed); color: $fallback--cRed; + color: var(--cRed, $fallback--cRed); } } </style> diff --git a/src/components/dm_timeline/dm_timeline.js b/src/components/dm_timeline/dm_timeline.js new file mode 100644 index 00000000..8b5393a9 --- /dev/null +++ b/src/components/dm_timeline/dm_timeline.js @@ -0,0 +1,14 @@ +import Timeline from '../timeline/timeline.vue' + +const DMs = { + computed: { + timeline () { + return this.$store.state.statuses.timelines.dms + } + }, + components: { + Timeline + } +} + +export default DMs diff --git a/src/components/dm_timeline/dm_timeline.vue b/src/components/dm_timeline/dm_timeline.vue new file mode 100644 index 00000000..f03da4d3 --- /dev/null +++ b/src/components/dm_timeline/dm_timeline.vue @@ -0,0 +1,5 @@ +<template> + <Timeline :title="$t('nav.dms')" v-bind:timeline="timeline" v-bind:timeline-name="'dms'"/> +</template> + +<script src="./dm_timeline.js"></script> diff --git a/src/components/export_import/export_import.vue b/src/components/export_import/export_import.vue new file mode 100644 index 00000000..451a2668 --- /dev/null +++ b/src/components/export_import/export_import.vue @@ -0,0 +1,87 @@ +<template> +<div class="import-export-container"> + <slot name="before"/> + <button class="btn" @click="exportData">{{ exportLabel }}</button> + <button class="btn" @click="importData">{{ importLabel }}</button> + <slot name="afterButtons"/> + <p v-if="importFailed" class="alert error">{{ importFailedText }}</p> + <slot name="afterError"/> +</div> +</template> + +<script> +export default { + props: [ + 'exportObject', + 'importLabel', + 'exportLabel', + 'importFailedText', + 'validator', + 'onImport', + 'onImportFailure' + ], + data () { + return { + importFailed: false + } + }, + methods: { + exportData () { + const stringified = JSON.stringify(this.exportObject) // Pretty-print and indent with 2 spaces + + // Create an invisible link with a data url and simulate a click + const e = document.createElement('a') + e.setAttribute('download', 'pleroma_theme.json') + e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified)) + e.style.display = 'none' + + document.body.appendChild(e) + e.click() + document.body.removeChild(e) + }, + importData () { + this.importFailed = false + const filePicker = document.createElement('input') + filePicker.setAttribute('type', 'file') + filePicker.setAttribute('accept', '.json') + + filePicker.addEventListener('change', event => { + if (event.target.files[0]) { + // eslint-disable-next-line no-undef + const reader = new FileReader() + reader.onload = ({target}) => { + try { + const parsed = JSON.parse(target.result) + const valid = this.validator(parsed) + if (valid) { + this.onImport(parsed) + } else { + this.importFailed = true + // this.onImportFailure(valid) + } + } catch (e) { + // This will happen both if there is a JSON syntax error or the theme is missing components + this.importFailed = true + // this.onImportFailure(e) + } + } + reader.readAsText(event.target.files[0]) + } + }) + + document.body.appendChild(filePicker) + filePicker.click() + document.body.removeChild(filePicker) + } + } +} +</script> + +<style lang="scss"> +.import-export-container { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: center; +} +</style> diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js index 1266be90..a2b4cb65 100644 --- a/src/components/favorite_button/favorite_button.js +++ b/src/components/favorite_button/favorite_button.js @@ -2,6 +2,9 @@ const FavoriteButton = { props: ['status', 'loggedIn'], data () { return { + hidePostStatsLocal: typeof this.$store.state.config.hidePostStats === 'undefined' + ? this.$store.state.instance.hidePostStats + : this.$store.state.config.hidePostStats, animated: false } }, diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue index 1e1a6970..1decd070 100644 --- a/src/components/favorite_button/favorite_button.vue +++ b/src/components/favorite_button/favorite_button.vue @@ -1,11 +1,11 @@ <template> <div v-if="loggedIn"> - <i :class='classes' class='favorite-button fav-active' @click.prevent='favorite()'/> - <span v-if='status.fave_num > 0'>{{status.fave_num}}</span> + <i :class='classes' class='favorite-button fav-active' @click.prevent='favorite()' :title="$t('tool_tip.favorite')"/> + <span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span> </div> <div v-else> - <i :class='classes' class='favorite-button'/> - <span v-if='status.fave_num > 0'>{{status.fave_num}}</span> + <i :class='classes' class='favorite-button' :title="$t('tool_tip.favorite')"/> + <span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span> </div> </template> diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js new file mode 100644 index 00000000..e0b7a118 --- /dev/null +++ b/src/components/features_panel/features_panel.js @@ -0,0 +1,14 @@ +const FeaturesPanel = { + computed: { + chat: function () { + return this.$store.state.instance.chatAvailable && (!this.$store.state.chatDisabled) + }, + gopher: function () { return this.$store.state.instance.gopherAvailable }, + whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, + mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable }, + scopeOptions: function () { return this.$store.state.instance.scopeOptionsEnabled }, + textlimit: function () { return this.$store.state.instance.textlimit } + } +} + +export default FeaturesPanel diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue new file mode 100644 index 00000000..445143e9 --- /dev/null +++ b/src/components/features_panel/features_panel.vue @@ -0,0 +1,29 @@ +<template> + <div class="features-panel"> + <div class="panel panel-default base01-background"> + <div class="panel-heading timeline-heading base02-background base04"> + <div class="title"> + {{$t('features_panel.title')}} + </div> + </div> + <div class="panel-body features-panel"> + <ul> + <li v-if="chat">{{$t('features_panel.chat')}}</li> + <li v-if="gopher">{{$t('features_panel.gopher')}}</li> + <li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li> + <li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li> + <li v-if="scopeOptions">{{$t('features_panel.scope_options')}}</li> + <li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li> + </ul> + </div> + </div> + </div> +</template> + +<script src="./features_panel.js" ></script> + +<style lang="scss"> + .features-panel li { + line-height: 24px; + } +</style> diff --git a/src/components/follow_requests/follow_requests.js b/src/components/follow_requests/follow_requests.js new file mode 100644 index 00000000..11a228aa --- /dev/null +++ b/src/components/follow_requests/follow_requests.js @@ -0,0 +1,23 @@ +import UserCard from '../user_card/user_card.vue' + +const FollowRequests = { + components: { + UserCard + }, + created () { + this.updateRequests() + }, + computed: { + requests () { + return this.$store.state.api.followRequests + } + }, + methods: { + updateRequests () { + this.$store.state.api.backendInteractor.fetchFollowRequests() + .then((requests) => { this.$store.commit('setFollowRequests', requests) }) + } + } +} + +export default FollowRequests diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue new file mode 100644 index 00000000..87dc4194 --- /dev/null +++ b/src/components/follow_requests/follow_requests.vue @@ -0,0 +1,12 @@ +<template> + <div class="settings panel panel-default"> + <div class="panel-heading"> + {{$t('nav.friend_requests')}} + </div> + <div class="panel-body"> + <user-card v-for="request in requests" :key="request.id" :user="request" :showFollows="false" :showApproval="true"></user-card> + </div> + </div> +</template> + +<script src="./follow_requests.js"></script> diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js new file mode 100644 index 00000000..8e2b0e45 --- /dev/null +++ b/src/components/font_control/font_control.js @@ -0,0 +1,58 @@ +import { set } from 'vue' + +export default { + props: [ + 'name', 'label', 'value', 'fallback', 'options', 'no-inherit' + ], + data () { + return { + lValue: this.value, + availableOptions: [ + this.noInherit ? '' : 'inherit', + 'custom', + ...(this.options || []), + 'serif', + 'monospace', + 'sans-serif' + ].filter(_ => _) + } + }, + beforeUpdate () { + this.lValue = this.value + }, + computed: { + present () { + return typeof this.lValue !== 'undefined' + }, + dValue () { + return this.lValue || this.fallback || {} + }, + family: { + get () { + return this.dValue.family + }, + set (v) { + set(this.lValue, 'family', v) + this.$emit('input', this.lValue) + } + }, + isCustom () { + return this.preset === 'custom' + }, + preset: { + get () { + if (this.family === 'serif' || + this.family === 'sans-serif' || + this.family === 'monospace' || + this.family === 'inherit') { + return this.family + } else { + return 'custom' + } + }, + set (v) { + this.family = v === 'custom' ? '' : v + } + } + } +} diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue new file mode 100644 index 00000000..ed36b280 --- /dev/null +++ b/src/components/font_control/font_control.vue @@ -0,0 +1,54 @@ +<template> +<div class="font-control style-control" :class="{ custom: isCustom }"> + <label :for="preset === 'custom' ? name : name + '-font-switcher'" class="label"> + {{label}} + </label> + <input + v-if="typeof fallback !== 'undefined'" + class="opt exlcude-disabled" + type="checkbox" + :id="name + '-o'" + :checked="present" + @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"> + <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label> + <label :for="name + '-font-switcher'" class="select" :disabled="!present"> + <select + :disabled="!present" + v-model="preset" + class="font-switcher" + :id="name + '-font-switcher'"> + <option v-for="option in availableOptions" :value="option"> + {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} + </option> + </select> + <i class="icon-down-open"/> + </label> + <input + v-if="isCustom" + class="custom-font" + type="text" + :id="name" + v-model="family"> +</div> +</template> + +<script src="./font_control.js" ></script> + +<style lang="scss"> +@import '../../_variables.scss'; +.font-control { + input.custom-font { + min-width: 10em; + } + &.custom { + .select { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .custom-font { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } +} +</style> diff --git a/src/components/instance_specific_panel/instance_specific_panel.js b/src/components/instance_specific_panel/instance_specific_panel.js index abd408c8..9bb5e945 100644 --- a/src/components/instance_specific_panel/instance_specific_panel.js +++ b/src/components/instance_specific_panel/instance_specific_panel.js @@ -1,7 +1,10 @@ const InstanceSpecificPanel = { computed: { instanceSpecificPanelContent () { - return this.$store.state.config.instanceSpecificPanelContent + return this.$store.state.instance.instanceSpecificPanelContent + }, + show () { + return !this.$store.state.config.hideISP } } } diff --git a/src/components/instance_specific_panel/instance_specific_panel.vue b/src/components/instance_specific_panel/instance_specific_panel.vue index ca8e00c0..a7b74667 100644 --- a/src/components/instance_specific_panel/instance_specific_panel.vue +++ b/src/components/instance_specific_panel/instance_specific_panel.vue @@ -1,5 +1,5 @@ <template> - <div class="instance-specific-panel"> + <div v-if="show" class="instance-specific-panel"> <div class="panel panel-default"> <div class="panel-body"> <div v-html="instanceSpecificPanelContent"> diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue new file mode 100644 index 00000000..3f58af2c --- /dev/null +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -0,0 +1,41 @@ +<template> + <div> + <label for="interface-language-switcher"> + {{ $t('settings.interfaceLanguage') }} + </label> + <label for="interface-language-switcher" class='select'> + <select id="interface-language-switcher" v-model="language"> + <option v-for="(langCode, i) in languageCodes" :value="langCode"> + {{ languageNames[i] }} + </option> + </select> + <i class="icon-down-open"/> + </label> + </div> +</template> + +<script> + import languagesObject from '../../i18n/messages' + import ISO6391 from 'iso-639-1' + import _ from 'lodash' + + export default { + computed: { + languageCodes () { + return Object.keys(languagesObject) + }, + + languageNames () { + return _.map(this.languageCodes, ISO6391.getName) + }, + + language: { + get: function () { return this.$store.state.config.interfaceLanguage }, + set: function (val) { + this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) + this.$i18n.locale = val + } + } + } + } +</script> diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js index a117b76f..49868aed 100644 --- a/src/components/login_form/login_form.js +++ b/src/components/login_form/login_form.js @@ -1,22 +1,40 @@ +import oauthApi from '../../services/new_api/oauth.js' const LoginForm = { data: () => ({ user: {}, authError: false }), computed: { + loginMethod () { return this.$store.state.instance.loginMethod }, loggingIn () { return this.$store.state.users.loggingIn }, - registrationOpen () { return this.$store.state.config.registrationOpen } + registrationOpen () { return this.$store.state.instance.registrationOpen } }, methods: { + oAuthLogin () { + oauthApi.login({ + oauth: this.$store.state.oauth, + instance: this.$store.state.instance.server, + commit: this.$store.commit + }) + }, submit () { - this.$store.dispatch('loginUser', this.user).then( - () => {}, - (error) => { - this.authError = error - this.user.username = '' - this.user.password = '' - } - ) + const data = { + oauth: this.$store.state.oauth, + instance: this.$store.state.instance.server + } + oauthApi.getOrCreateApp(data).then((app) => { + oauthApi.getTokenWithCredentials( + { + app, + instance: data.instance, + username: this.user.username, + password: this.user.password}) + .then((result) => { + this.$store.commit('setToken', result.access_token) + this.$store.dispatch('loginUser', result.access_token) + this.$router.push('/main/friends') + }) + }) } } } diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue index 67fa95a8..12971882 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -5,10 +5,10 @@ {{$t('login.login')}} </div> <div class="panel-body"> - <form v-on:submit.prevent='submit(user)' class='login-form'> + <form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'> <div class='form-group'> <label for='username'>{{$t('login.username')}}</label> - <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' placeholder='e.g. lain'> + <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')"> </div> <div class='form-group'> <label for='password'>{{$t('login.password')}}</label> @@ -20,8 +20,17 @@ <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button> </div> </div> - <div v-if="authError" class='form-group'> - <div class='alert error'>{{authError}}</div> + </form> + + <form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form"> + <div class="form-group"> + <p>{{$t('login.description')}}</p> + </div> + <div class='form-group'> + <div class='login-bottom'> + <div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div> + <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button> + </div> </div> </form> </div> diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index 8b4e7ad4..66337c3f 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -6,8 +6,10 @@ const mediaUpload = { const input = this.$el.querySelector('input') input.addEventListener('change', ({target}) => { - const file = target.files[0] - this.uploadFile(file) + for (var i = 0; i < target.files.length; i++) { + let file = target.files[i] + this.uploadFile(file) + } }) }, data () { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 8b931d2d..768d3565 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -1,9 +1,9 @@ <template> <div class="media-upload" @drop.prevent @dragover.prevent="fileDrag" @drop="fileDrop"> - <label class="btn btn-default"> + <label class="btn btn-default" :title="$t('tool_tip.media_upload')"> <i class="icon-spin4 animate-spin" v-if="uploading"></i> <i class="icon-upload" v-if="!uploading"></i> - <input type=file style="position: fixed; top: -100em"></input> + <input type="file" style="position: fixed; top: -100em" multiple="true"></input> </label> </div> </template> diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index ea5d7ea4..19ce56c3 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,4 +1,5 @@ const NavPanel = { + props: [ 'activatePanel' ], computed: { currentUser () { return this.$store.state.users.currentUser diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 6f949afb..b224c5f3 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -3,22 +3,32 @@ <div class="panel panel-default"> <ul> <li v-if='currentUser'> - <router-link to='/main/friends'> + <router-link @click.native="activatePanel('timeline')" to='/main/friends'> {{ $t("nav.timeline") }} </router-link> </li> <li v-if='currentUser'> - <router-link :to="{ name: 'mentions', params: { username: currentUser.screen_name } }"> + <router-link @click.native="activatePanel('timeline')" :to="{ name: 'mentions', params: { username: currentUser.screen_name } }"> {{ $t("nav.mentions") }} </router-link> </li> + <li v-if='currentUser'> + <router-link @click.native="activatePanel('timeline')" :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> + {{ $t("nav.dms") }} + </router-link> + </li> + <li v-if='currentUser && currentUser.locked'> + <router-link @click.native="activatePanel('timeline')" to='/friend-requests'> + {{ $t("nav.friend_requests") }} + </router-link> + </li> <li> - <router-link to='/main/public'> + <router-link @click.native="activatePanel('timeline')" to='/main/public'> {{ $t("nav.public_tl") }} </router-link> </li> <li> - <router-link to='/main/all'> + <router-link @click.native="activatePanel('timeline')" to='/main/all'> {{ $t("nav.twkn") }} </router-link> </li> @@ -45,8 +55,6 @@ border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); - background-color: $fallback--bg; - background-color: var(--bg, $fallback--bg); padding: 0; &:first-child a { diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 3a274374..345fe3ee 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,15 +1,18 @@ import Status from '../status/status.vue' import StillImage from '../still-image/still-image.vue' import UserCardContent from '../user_card_content/user_card_content.vue' +import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' const Notification = { data () { return { - userExpanded: false + userExpanded: false, + betterShadow: this.$store.state.interface.browserSupport.cssFilter } }, props: [ - 'notification' + 'notification', + 'activatePanel' ], components: { Status, StillImage, UserCardContent @@ -18,6 +21,16 @@ const Notification = { toggleUserExpanded () { this.userExpanded = !this.userExpanded } + }, + computed: { + userClass () { + return highlightClass(this.notification.action.user) + }, + userStyle () { + const highlight = this.$store.state.config.highlight + const user = this.notification.action.user + return highlightStyle(highlight[user.screen_name]) + } } } diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index eed598a8..e84ce0b6 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -1,8 +1,8 @@ <template> - <status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status> - <div class="non-mention" v-else> + <status :activatePanel="activatePanel" v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status> + <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else> <a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> - <StillImage class='avatar-compact' :src="notification.action.user.profile_image_url_original"/> + <StillImage class='avatar-compact' :class="{'better-shadow': betterShadow}" :src="notification.action.user.profile_image_url_original"/> </a> <div class='notification-right'> <div class="usercard notification-usercard" v-if="userExpanded"> @@ -10,13 +10,14 @@ </div> <span class="notification-details"> <div class="name-and-action"> - <span class="username" :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> - <span v-if="notification.type === 'favorite'"> + <span class="username" v-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span> + <span class="username" v-else :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> + <span v-if="notification.type === 'like'"> <i class="fa icon-star lit"></i> <small>{{$t('notifications.favorited_you')}}</small> </span> <span v-if="notification.type === 'repeat'"> - <i class="fa icon-retweet lit"></i> + <i class="fa icon-retweet lit" :title="$t('tool_tip.repeat')"></i> <small>{{$t('notifications.repeated_you')}}</small> </span> <span v-if="notification.type === 'follow'"> @@ -24,12 +25,17 @@ <small>{{$t('notifications.followed_you')}}</small> </span> </div> - <small class="timeago"><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> + <small class="timeago"><router-link @click.native="activatePanel('timeline')" v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> </span> <div class="follow-text" v-if="notification.type === 'follow'"> - <router-link :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{notification.action.user.screen_name}}</router-link> + <router-link @click.native="activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{notification.action.user.screen_name}}</router-link> </div> - <status v-else class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status> + <template v-else> + <status :activatePanel="activatePanel" v-if="notification.status" class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status> + <div class="broken-favorite" v-else> + {{$t('notifications.broken_favorite')}} + </div> + </template> </div> </div> </template> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index f8314bfc..4b7a591d 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -1,25 +1,39 @@ import Notification from '../notification/notification.vue' +import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' -import { sortBy, take, filter } from 'lodash' +import { sortBy, filter } from 'lodash' const Notifications = { - data () { - return { - visibleNotificationCount: 20 - } + props: [ 'activatePanel' ], + created () { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + + notificationsFetcher.startFetching({ store, credentials }) }, computed: { + visibleTypes () { + return [ + this.$store.state.config.notificationVisibility.likes && 'like', + this.$store.state.config.notificationVisibility.mentions && 'mention', + this.$store.state.config.notificationVisibility.repeats && 'repeat', + this.$store.state.config.notificationVisibility.follows && 'follow' + ].filter(_ => _) + }, notifications () { - return this.$store.state.statuses.notifications + return this.$store.state.statuses.notifications.data + }, + error () { + return this.$store.state.statuses.notifications.error }, unseenNotifications () { - return filter(this.notifications, ({seen}) => !seen) + return filter(this.visibleNotifications, ({seen}) => !seen) }, visibleNotifications () { // Don't know why, but sortBy([seen, -action.id]) doesn't work. let sortedNotifications = sortBy(this.notifications, ({action}) => -action.id) sortedNotifications = sortBy(sortedNotifications, 'seen') - return take(sortedNotifications, this.visibleNotificationCount) + return sortedNotifications.filter((notification) => this.visibleTypes.includes(notification.type)) }, unseenCount () { return this.unseenNotifications.length @@ -39,7 +53,16 @@ const Notifications = { }, methods: { markAsSeen () { - this.$store.commit('markNotificationsAsSeen', this.visibleNotifications) + this.$store.dispatch('markNotificationsAsSeen', this.visibleNotifications) + }, + fetchOlderNotifications () { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + notificationsFetcher.fetchAndUpdate({ + store, + credentials, + older: true + }) } } } diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 9cbb1226..a6468e01 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -4,50 +4,28 @@ // a bit of a hack to allow scrolling below notifications padding-bottom: 15em; - .panel { - background: $fallback--bg; - background: var(--bg, $fallback--bg) + .loadmore-error { + color: $fallback--text; + color: var(--text, $fallback--text); } - .panel-body { - border-color: $fallback--border; - border-color: var(--border, $fallback--border) - } - - .panel-heading { - // force the text to stay centered, while keeping - // the button in the right side of the panel heading + .notification { position: relative; - background: $fallback--btn; - background: var(--btn, $fallback--btn); - color: $fallback--fg; - color: var(--fg, $fallback--fg); - .read-button { + + .notification-overlay { position: absolute; - right: 0.7em; - height: 1.8em; - line-height: 100%; + top: 0; + right: 0; + left: 0; + bottom: 0; + pointer-events: none; } - } - .unseen-count { - display: inline-block; - background-color: $fallback--cRed; - background-color: var(--cRed, $fallback--cRed); - text-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5); - min-width: 1.3em; - border-radius: 1.3em; - margin: 0 0.2em 0 -0.4em; - color: white; - font-size: 0.9em; - text-align: center; - line-height: 1.3em; - } - - .unseen { - border-left: 4px solid $fallback--cRed; - border-left: 4px solid var(--cRed, $fallback--cRed); - padding-left: 0; + &.unseen { + .notification-overlay { + background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px) + } + } } } @@ -55,23 +33,39 @@ box-sizing: border-box; display: flex; border-bottom: 1px solid; - border-bottom-color: inherit; - padding-left: 4px; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + + .broken-favorite { + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + color: $fallback--text; + color: var(--alertErrorText, $fallback--text); + background-color: $fallback--alertError; + background-color: var(--alertError, $fallback--alertError); + padding: 2px .5em + } .avatar-compact { width: 32px; height: 32px; + box-shadow: var(--avatarStatusShadow); border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); overflow: hidden; line-height: 0; + &.better-shadow { + box-shadow: var(--avatarStatusShadowInset); + filter: var(--avatarStatusShadowFilter) + } + &.animated::before { display: none; } } - &:hover .animated.avatar { + &:hover .animated.avatar-compact { canvas { display: none; } @@ -98,7 +92,10 @@ .status { padding: 0.25em 0; color: $fallback--faint; - color: var($fallback--faint, --faint); + color: var(--faint, $fallback--faint); + a { + color: var(--faintLink); + } } padding: 0; .media-body { @@ -147,6 +144,13 @@ max-width: 100%; text-overflow: ellipsis; white-space: nowrap; + + img { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } } .timeago { float: right; @@ -196,15 +200,4 @@ margin-bottom: 0.3em; } } - - // ugly as heck - &:last-child { - border-bottom: none; - border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; - border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); - .status-el { - border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; - border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); - } - } } diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 4fa6e925..bef48567 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -2,15 +2,27 @@ <div class="notifications"> <div class="panel panel-default"> <div class="panel-heading"> - <span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span> - {{$t('notifications.notifications')}} + <div class="title"> + {{$t('notifications.notifications')}} + <span class="badge badge-notification unseen-count" v-if="unseenCount">{{unseenCount}}</span> + </div> + <div @click.prevent class="loadmore-error alert error" v-if="error"> + {{$t('timeline.error_fetching')}} + </div> <button v-if="unseenCount" @click.prevent="markAsSeen" class="read-button">{{$t('notifications.read')}}</button> </div> <div class="panel-body"> <div v-for="notification in visibleNotifications" :key="notification.action.id" class="notification" :class='{"unseen": !notification.seen}'> - <notification :notification="notification"></notification> + <div class="notification-overlay"></div> + <notification :activatePanel="activatePanel" :notification="notification"></notification> </div> </div> + <div class="panel-footer"> + <a href="#" v-on:click.prevent='fetchOlderNotifications()' v-if="!notifications.loading"> + <div class="new-status-notification text-center panel-footer">{{$t('notifications.load_older')}}</div> + </a> + <div class="new-status-notification text-center panel-footer" v-else>...</div> + </div> </div> </div> </template> diff --git a/src/components/oauth_callback/oauth_callback.js b/src/components/oauth_callback/oauth_callback.js new file mode 100644 index 00000000..7a5132ad --- /dev/null +++ b/src/components/oauth_callback/oauth_callback.js @@ -0,0 +1,20 @@ +import oauth from '../../services/new_api/oauth.js' + +const oac = { + props: ['code'], + mounted () { + if (this.code) { + oauth.getToken({ + app: this.$store.state.oauth, + instance: this.$store.state.instance.server, + code: this.code + }).then((result) => { + this.$store.commit('setToken', result.access_token) + this.$store.dispatch('loginUser', result.access_token) + this.$router.push('/main/friends') + }) + } + } +} + +export default oac diff --git a/src/components/oauth_callback/oauth_callback.vue b/src/components/oauth_callback/oauth_callback.vue new file mode 100644 index 00000000..9c806916 --- /dev/null +++ b/src/components/oauth_callback/oauth_callback.vue @@ -0,0 +1,5 @@ +<template> + <h1>...</h1> +</template> + +<script src="./oauth_callback.js"></script> diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue new file mode 100644 index 00000000..3926915b --- /dev/null +++ b/src/components/opacity_input/opacity_input.vue @@ -0,0 +1,38 @@ +<template> +<div class="opacity-control style-control" :class="{ disabled: !present || disabled }"> + <label :for="name" class="label"> + {{$t('settings.style.common.opacity')}} + </label> + <input + v-if="typeof fallback !== 'undefined'" + class="opt exclude-disabled" + :id="name + '-o'" + type="checkbox" + :checked="present" + @input="$emit('input', !present ? fallback : undefined)"> + <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label> + <input + :id="name" + class="input-number" + type="number" + :value="value || fallback" + :disabled="!present || disabled" + @input="$emit('input', $event.target.value)" + max="1" + min="0" + step=".05"> +</div> +</template> + +<script> +export default { + props: [ + 'name', 'value', 'fallback', 'disabled' + ], + computed: { + present () { + return typeof this.value !== 'undefined' + } + } +} +</script> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 6c95873c..f9252f73 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -23,22 +23,33 @@ const PostStatusForm = { props: [ 'replyTo', 'repliedUser', - 'attentions' + 'attentions', + 'copyMessageScope', + 'subject' ], components: { MediaUpload }, mounted () { this.resize(this.$refs.textarea) + + if (this.replyTo) { + this.$refs.textarea.focus() + } }, data () { - let statusText = '' + const preset = this.$route.query.message + let statusText = preset || '' if (this.replyTo) { const currentUser = this.$store.state.users.currentUser statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) } + const scope = (this.copyMessageScope && this.$store.state.config.scopeCopy || this.copyMessageScope === 'direct') + ? this.copyMessageScope + : this.$store.state.users.currentUser.default_scope + return { dropFiles: [], submitDisabled: false, @@ -46,18 +57,33 @@ const PostStatusForm = { posting: false, highlighted: 0, newStatus: { + spoilerText: this.subject || '', status: statusText, - files: [] + contentType: 'text/plain', + nsfw: false, + files: [], + visibility: scope }, caret: 0 } }, computed: { + vis () { + return { + public: { selected: this.newStatus.visibility === 'public' }, + unlisted: { selected: this.newStatus.visibility === 'unlisted' }, + private: { selected: this.newStatus.visibility === 'private' }, + direct: { selected: this.newStatus.visibility === 'direct' } + } + }, candidates () { const firstchar = this.textAtCaret.charAt(0) if (firstchar === '@') { - const matchedUsers = filter(this.users, (user) => (String(user.name + user.screen_name)).toUpperCase() - .match(this.textAtCaret.slice(1).toUpperCase())) + const query = this.textAtCaret.slice(1).toUpperCase() + const matchedUsers = filter(this.users, (user) => { + return user.screen_name.toUpperCase().startsWith(query) || + user.name && user.name.toUpperCase().startsWith(query) + }) if (matchedUsers.length <= 0) { return false } @@ -71,16 +97,16 @@ const PostStatusForm = { })) } else if (firstchar === ':') { if (this.textAtCaret === ':') { return } - const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.match(this.textAtCaret.slice(1))) + const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) if (matchedEmoji.length <= 0) { return false } return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ - // eslint-disable-next-line camelcase screen_name: `:${shortcode}:`, name: '', utf: utf || '', - img: image_url, + // eslint-disable-next-line camelcase + img: utf ? '' : this.$store.state.instance.server + image_url, highlighted: index === this.highlighted })) } else { @@ -98,25 +124,43 @@ const PostStatusForm = { return this.$store.state.users.users }, emoji () { - return this.$store.state.config.emoji || [] + return this.$store.state.instance.emoji || [] }, customEmoji () { - return this.$store.state.config.customEmoji || [] + return this.$store.state.instance.customEmoji || [] }, statusLength () { return this.newStatus.status.length }, + spoilerTextLength () { + return this.newStatus.spoilerText.length + }, statusLengthLimit () { - return this.$store.state.config.textlimit + return this.$store.state.instance.textlimit }, hasStatusLengthLimit () { return this.statusLengthLimit > 0 }, charactersLeft () { - return this.statusLengthLimit - this.statusLength + return this.statusLengthLimit - (this.statusLength + this.spoilerTextLength) }, isOverLengthLimit () { - return this.hasStatusLengthLimit && (this.statusLength > this.statusLengthLimit) + return this.hasStatusLengthLimit && (this.charactersLeft < 0) + }, + scopeOptionsEnabled () { + return this.$store.state.instance.scopeOptionsEnabled + }, + alwaysShowSubject () { + if (typeof this.$store.state.config.alwaysShowSubjectInput !== 'undefined') { + return this.$store.state.config.alwaysShowSubjectInput + } else if (typeof this.$store.state.instance.alwaysShowSubjectInput !== 'undefined') { + return this.$store.state.instance.alwaysShowSubjectInput + } else { + return this.$store.state.instance.scopeOptionsEnabled + } + }, + formattingOptionsEnabled () { + return this.$store.state.instance.formattingOptionsEnabled } }, methods: { @@ -184,14 +228,21 @@ const PostStatusForm = { this.posting = true statusPoster.postStatus({ status: newStatus.status, + spoilerText: newStatus.spoilerText || null, + visibility: newStatus.visibility, + sensitive: newStatus.nsfw, media: newStatus.files, store: this.$store, - inReplyToStatusId: this.replyTo + inReplyToStatusId: this.replyTo, + contentType: newStatus.contentType }).then((data) => { if (!data.error) { this.newStatus = { status: '', - files: [] + spoilerText: '', + files: [], + visibility: newStatus.visibility, + contentType: newStatus.contentType } this.$emit('posted') let el = this.$el.querySelector('textarea') @@ -238,18 +289,20 @@ const PostStatusForm = { e.dataTransfer.dropEffect = 'copy' }, resize (e) { - const target = e.target || e - target.style.height = 'auto' - const heightPx = target.scrollHeight - 10 - if (heightPx > 54) { - target.style.height = `${target.scrollHeight - 10}px` - } - if (target.value === '') { - target.style.height = '16px' + if (!e.target) { return } + const vertPadding = Number(window.getComputedStyle(e.target)['padding-top'].substr(0, 1)) + + Number(window.getComputedStyle(e.target)['padding-bottom'].substr(0, 1)) + e.target.style.height = 'auto' + e.target.style.height = `${e.target.scrollHeight - vertPadding}px` + if (e.target.value === '') { + e.target.style.height = '16px' } }, clearError () { this.error = null + }, + changeVis (visibility) { + this.newStatus.visibility = visibility } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 88627e3a..fcf5c873 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -2,6 +2,20 @@ <div class="post-status-form"> <form @submit.prevent="postStatus(newStatus)"> <div class="form-group" > + <i18n + v-if="!this.$store.state.users.currentUser.locked && this.newStatus.visibility == 'private'" + path="post_status.account_not_locked_warning" + tag="p" + class="visibility-notice"> + <router-link to="/user-settings">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> + </i18n> + <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p> + <input + v-if="newStatus.spoilerText || alwaysShowSubject" + type="text" + :placeholder="$t('post_status.content_warning')" + v-model="newStatus.spoilerText" + class="form-cw"> <textarea ref="textarea" @click="setCaret" @@ -18,16 +32,30 @@ @input="resize" @paste="paste"> </textarea> + <div class="visibility-tray"> + <span class="text-format" v-if="formattingOptionsEnabled"> + <label for="post-content-type" class="select"> + <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> + <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option> + <option value="text/html">HTML</option> + <option value="text/markdown">Markdown</option> + </select> + <i class="icon-down-open"></i> + </label> + </span> + + <div v-if="scopeOptionsEnabled"> + <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')"></i> + <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i> + <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i> + <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i> + </div> + </div> </div> <div style="position:relative;" v-if="candidates"> <div class="autocomplete-panel"> <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> - <div v-if="candidate.highlighted" class="autocomplete"> - <span v-if="candidate.img"><img :src="candidate.img"></span> - <span v-else>{{candidate.utf}}</span> - <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> - </div> - <div v-else class="autocomplete"> + <div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> <span v-if="candidate.img"><img :src="candidate.img"></img></span> <span v-else>{{candidate.utf}}</span> <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> @@ -50,14 +78,20 @@ <i class="icon-cancel" @click="clearError"></i> </div> <div class="attachments"> - <div class="media-upload-container attachment" v-for="file in newStatus.files"> + <div class="media-upload-wrapper" v-for="file in newStatus.files"> <i class="fa icon-cancel" @click="removeMediaFile(file)"></i> - <img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img> - <video v-if="type(file) === 'video'" :src="file.image" controls></video> - <audio v-if="type(file) === 'audio'" :src="file.image" controls></audio> - <a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a> + <div class="media-upload-container attachment"> + <img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img> + <video v-if="type(file) === 'video'" :src="file.image" controls></video> + <audio v-if="type(file) === 'audio'" :src="file.image" controls></audio> + <a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a> + </div> </div> </div> + <div class="upload_settings" v-if="newStatus.files.length > 0"> + <input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw"> + <label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label> + </div> </form> </div> </template> @@ -105,14 +139,49 @@ text-align: center; } + .media-upload-wrapper { + flex: 0 0 auto; + max-width: 100%; + min-width: 50px; + margin-right: .2em; + margin-bottom: .5em; + + .icon-cancel { + display: inline-block; + position: static; + margin: 0; + padding-bottom: 0; + margin-left: $fallback--attachmentRadius; + margin-left: var(--attachmentRadius, $fallback--attachmentRadius); + background-color: $fallback--fg; + background-color: var(--btn, $fallback--fg); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + .attachments { padding: 0 0.5em; .attachment { + margin: 0; position: relative; + flex: 0 0 auto; border: 1px solid $fallback--border; border: 1px solid var(--border, $fallback--border); - margin: 0.5em 0.8em 0.2em 0; + text-align: center; + + audio { + min-width: 300px; + flex: 1 0 auto; + } + + a { + display: block; + text-align: left; + line-height: 1.2; + padding: .5em; + } } i { @@ -135,10 +204,6 @@ cursor: not-allowed; } - .icon-cancel { - cursor: pointer; - } - form { display: flex; flex-direction: column; @@ -152,7 +217,15 @@ line-height:24px; } - form textarea { + form textarea.form-cw { + line-height:16px; + resize: none; + overflow: hidden; + transition: min-height 200ms 100ms; + min-height: 1px; + } + + form textarea.form-control { line-height:16px; resize: none; overflow: hidden; @@ -161,7 +234,7 @@ box-sizing: content-box; } - form textarea:focus { + form textarea.form-control:focus { min-height: 48px; } @@ -185,11 +258,13 @@ position: absolute; z-index: 1; box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); + // this doesn't match original but i don't care, making it uniform. + box-shadow: var(--popupShadow); min-width: 75%; - background: $fallback--btn; - background: var(--btn, $fallback--btn); - color: $fallback--lightFg; - color: var(--lightFg, $fallback--lightFg); + background: $fallback--bg; + background: var(--bg, $fallback--bg); + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); } .autocomplete { @@ -216,6 +291,11 @@ color: $fallback--faint; color: var(--faint, $fallback--faint); } + + &.highlighted { + background-color: $fallback--fg; + background-color: var(--lightBg, $fallback--fg); + } } } </style> diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue new file mode 100644 index 00000000..3e50664b --- /dev/null +++ b/src/components/range_input/range_input.vue @@ -0,0 +1,48 @@ +<template> +<div class="range-control style-control" :class="{ disabled: !present || disabled }"> + <label :for="name" class="label"> + {{label}} + </label> + <input + v-if="typeof fallback !== 'undefined'" + class="opt exclude-disabled" + :id="name + '-o'" + type="checkbox" + :checked="present" + @input="$emit('input', !present ? fallback : undefined)"> + <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label> + <input + :id="name" + class="input-number" + type="range" + :value="value || fallback" + :disabled="!present || disabled" + @input="$emit('input', $event.target.value)" + :max="max || hardMax || 100" + :min="min || hardMin || 0" + :step="step || 1"> + <input + :id="name" + class="input-number" + type="number" + :value="value || fallback" + :disabled="!present || disabled" + @input="$emit('input', $event.target.value)" + :max="hardMax" + :min="hardMin" + :step="step || 1"> +</div> +</template> + +<script> +export default { + props: [ + 'name', 'value', 'fallback', 'disabled', 'label', 'max', 'min', 'step', 'hardMin', 'hardMax' + ], + computed: { + present () { + return typeof this.value !== 'undefined' + } + } +} +</script> diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js index 771b3b27..e5ead8bc 100644 --- a/src/components/registration/registration.js +++ b/src/components/registration/registration.js @@ -1,35 +1,61 @@ +import { validationMixin } from 'vuelidate' +import { required, sameAs } from 'vuelidate/lib/validators' +import { mapActions, mapState } from 'vuex' + const registration = { + mixins: [validationMixin], data: () => ({ - user: {}, - error: false, - registering: false + user: { + email: '', + fullname: '', + username: '', + password: '', + confirm: '' + } }), + validations: { + user: { + email: { required }, + username: { required }, + fullname: { required }, + password: { required }, + confirm: { + required, + sameAsPassword: sameAs('password') + } + } + }, created () { - if (!this.$store.state.config.registrationOpen || !!this.$store.state.users.currentUser) { + if ((!this.registrationOpen && !this.token) || this.signedIn) { this.$router.push('/main/all') } }, computed: { - termsofservice () { return this.$store.state.config.tos } + token () { return this.$route.params.token }, + ...mapState({ + registrationOpen: (state) => state.instance.registrationOpen, + signedIn: (state) => !!state.users.currentUser, + isPending: (state) => state.users.signUpPending, + serverValidationErrors: (state) => state.users.signUpErrors, + termsOfService: (state) => state.instance.tos + }) }, methods: { - submit () { - this.registering = true + ...mapActions(['signUp']), + async submit () { this.user.nickname = this.user.username - this.$store.state.api.backendInteractor.register(this.user).then( - (response) => { - if (response.ok) { - this.$store.dispatch('loginUser', this.user) - this.$router.push('/main/all') - this.registering = false - } else { - this.registering = false - response.json().then((data) => { - this.error = data.error - }) - } + this.user.token = this.token + + this.$v.$touch() + + if (!this.$v.$invalid) { + try { + await this.signUp(this.user) + this.$router.push('/main/friends') + } catch (error) { + console.warn('Registration failed: ' + error) } - ) + } } } } diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index 00f665af..8cb1392b 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -7,46 +7,90 @@ <form v-on:submit.prevent='submit(user)' class='registration-form'> <div class='container'> <div class='text-fields'> - <div class='form-group'> - <label for='username'>{{$t('login.username')}}</label> - <input :disabled="registering" v-model='user.username' class='form-control' id='username' placeholder='e.g. lain'> + <div class='form-group' :class="{ 'form-group--error': $v.user.username.$error }"> + <label class='form--label' for='sign-up-username'>{{$t('login.username')}}</label> + <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' placeholder='e.g. lain'> </div> - <div class='form-group'> - <label for='fullname'>{{$t('registration.fullname')}}</label> - <input :disabled="registering" v-model='user.fullname' class='form-control' id='fullname' placeholder='e.g. Lain Iwakura'> + <div class="form-error" v-if="$v.user.username.$dirty"> + <ul> + <li v-if="!$v.user.username.required"> + <span>{{$t('registration.validations.username_required')}}</span> + </li> + </ul> </div> - <div class='form-group'> - <label for='email'>{{$t('registration.email')}}</label> - <input :disabled="registering" v-model='user.email' class='form-control' id='email' type="email"> + + <div class='form-group' :class="{ 'form-group--error': $v.user.fullname.$error }"> + <label class='form--label' for='sign-up-fullname'>{{$t('registration.fullname')}}</label> + <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' placeholder='e.g. Lain Iwakura'> </div> - <div class='form-group'> - <label for='bio'>{{$t('registration.bio')}}</label> - <input :disabled="registering" v-model='user.bio' class='form-control' id='bio'> + <div class="form-error" v-if="$v.user.fullname.$dirty"> + <ul> + <li v-if="!$v.user.fullname.required"> + <span>{{$t('registration.validations.fullname_required')}}</span> + </li> + </ul> </div> - <div class='form-group'> - <label for='password'>{{$t('login.password')}}</label> - <input :disabled="registering" v-model='user.password' class='form-control' id='password' type='password'> + + <div class='form-group' :class="{ 'form-group--error': $v.user.email.$error }"> + <label class='form--label' for='email'>{{$t('registration.email')}}</label> + <input :disabled="isPending" v-model='$v.user.email.$model' class='form-control' id='email' type="email"> </div> - <div class='form-group'> - <label for='password_confirmation'>{{$t('registration.password_confirm')}}</label> - <input :disabled="registering" v-model='user.confirm' class='form-control' id='password_confirmation' type='password'> + <div class="form-error" v-if="$v.user.email.$dirty"> + <ul> + <li v-if="!$v.user.email.required"> + <span>{{$t('registration.validations.email_required')}}</span> + </li> + </ul> </div> - <!-- + <div class='form-group'> - <label for='captcha'>Captcha</label> - <img src='/qvittersimplesecurity/captcha.jpg' alt='captcha' class='captcha'> - <input :disabled="registering" v-model='user.captcha' placeholder='Enter captcha' type='test' class='form-control' id='captcha'> + <label class='form--label' for='bio'>{{$t('registration.bio')}}</label> + <input :disabled="isPending" v-model='user.bio' class='form-control' id='bio'> + </div> + + <div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }"> + <label class='form--label' for='sign-up-password'>{{$t('login.password')}}</label> + <input :disabled="isPending" v-model='user.password' class='form-control' id='sign-up-password' type='password'> + </div> + <div class="form-error" v-if="$v.user.password.$dirty"> + <ul> + <li v-if="!$v.user.password.required"> + <span>{{$t('registration.validations.password_required')}}</span> + </li> + </ul> + </div> + + <div class='form-group' :class="{ 'form-group--error': $v.user.confirm.$error }"> + <label class='form--label' for='sign-up-password-confirmation'>{{$t('registration.password_confirm')}}</label> + <input :disabled="isPending" v-model='user.confirm' class='form-control' id='sign-up-password-confirmation' type='password'> + </div> + <div class="form-error" v-if="$v.user.confirm.$dirty"> + <ul> + <li v-if="!$v.user.confirm.required"> + <span>{{$t('registration.validations.password_confirmation_required')}}</span> + </li> + <li v-if="!$v.user.confirm.sameAsPassword"> + <span>{{$t('registration.validations.password_confirmation_match')}}</span> + </li> + </ul> + </div> + + <div class='form-group' v-if='token' > + <label for='token'>{{$t('registration.token')}}</label> + <input disabled='true' v-model='token' class='form-control' id='token' type='text'> </div> - --> <div class='form-group'> - <button :disabled="registering" type='submit' class='btn btn-default'>{{$t('general.submit')}}</button> + <button :disabled="isPending" type='submit' class='btn btn-default'>{{$t('general.submit')}}</button> </div> </div> - <div class='terms-of-service' v-html="termsofservice"> + + <div class='terms-of-service' v-html="termsOfService"> </div> </div> - <div v-if="error" class='form-group'> - <div class='alert error'>{{error}}</div> + <div v-if="serverValidationErrors.length" class='form-group'> + <div class='alert error'> + <span v-for="error in serverValidationErrors">{{error}}</span> + </div> </div> </form> </div> @@ -56,6 +100,7 @@ <script src="./registration.js"></script> <style lang="scss"> @import '../../_variables.scss'; +$validations-cRed: #f04124; .registration-form { display: flex; @@ -85,6 +130,55 @@ flex-direction: column; padding: 0.3em 0.0em 0.3em; line-height:24px; + margin-bottom: 1em; + } + + @keyframes shakeError { + 0% { + transform: translateX(0); } + 15% { + transform: translateX(0.375rem); } + 30% { + transform: translateX(-0.375rem); } + 45% { + transform: translateX(0.375rem); } + 60% { + transform: translateX(-0.375rem); } + 75% { + transform: translateX(0.375rem); } + 90% { + transform: translateX(-0.375rem); } + 100% { + transform: translateX(0); } } + + .form-group--error { + animation-name: shakeError; + animation-duration: .6s; + animation-timing-function: ease-in-out; + } + + .form-group--error .form--label { + color: $validations-cRed; + color: var(--cRed, $validations-cRed); + } + + .form-error { + margin-top: -0.7em; + text-align: left; + + span { + font-size: 12px; + } + } + + .form-error ul { + list-style: none; + padding: 0 0 0 5px; + margin-top: 0; + + li::before { + content: "• "; + } } form textarea { @@ -98,8 +192,6 @@ } .btn { - //align-self: flex-start; - //width: 10em; margin-top: 0.6em; height: 28px; } diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js index 4a43542d..eb4e4b41 100644 --- a/src/components/retweet_button/retweet_button.js +++ b/src/components/retweet_button/retweet_button.js @@ -1,7 +1,10 @@ const RetweetButton = { - props: ['status', 'loggedIn'], + props: ['status', 'loggedIn', 'visibility'], data () { return { + hidePostStatsLocal: typeof this.$store.state.config.hidePostStats === 'undefined' + ? this.$store.state.instance.hidePostStats + : this.$store.state.config.hidePostStats, animated: false } }, @@ -9,6 +12,8 @@ const RetweetButton = { retweet () { if (!this.status.repeated) { this.$store.dispatch('retweet', {id: this.status.id}) + } else { + this.$store.dispatch('unretweet', {id: this.status.id}) } this.animated = true setTimeout(() => { @@ -20,6 +25,7 @@ const RetweetButton = { classes () { return { 'retweeted': this.status.repeated, + 'retweeted-empty': !this.status.repeated, 'animate-spin': this.animated } } diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index 1bee3d08..c957fb77 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -1,11 +1,16 @@ <template> <div v-if="loggedIn"> - <i :class='classes' class='icon-retweet rt-active' v-on:click.prevent='retweet()'></i> - <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span> + <template v-if="visibility !== 'private' && visibility !== 'direct'"> + <i :class='classes' class='retweet-button icon-retweet rt-active' v-on:click.prevent='retweet()' :title="$t('tool_tip.repeat')"></i> + <span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span> + </template> + <template v-else> + <i :class='classes' class='icon-lock' :title="$t('timeline.no_retweet_hint')"></i> + </template> </div> - <div v-else> - <i :class='classes' class='icon-retweet'></i> - <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span> + <div v-else-if="!loggedIn"> + <i :class='classes' class='icon-retweet' :title="$t('tool_tip.repeat')"></i> + <span v-if='!hidePostStatsLocal && status.repeat_num > 0'>{{status.repeat_num}}</span> </div> </template> diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index a26111d6..681ccda8 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -1,25 +1,73 @@ +/* eslint-env browser */ +import TabSwitcher from '../tab_switcher/tab_switcher.jsx' import StyleSwitcher from '../style_switcher/style_switcher.vue' +import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue' import { filter, trim } from 'lodash' const settings = { data () { + const user = this.$store.state.config + const instance = this.$store.state.instance + return { - hideAttachmentsLocal: this.$store.state.config.hideAttachments, - hideAttachmentsInConvLocal: this.$store.state.config.hideAttachmentsInConv, - hideNsfwLocal: this.$store.state.config.hideNsfw, - muteWordsString: this.$store.state.config.muteWords.join('\n'), - autoLoadLocal: this.$store.state.config.autoLoad, - streamingLocal: this.$store.state.config.streaming, - hoverPreviewLocal: this.$store.state.config.hoverPreview, - stopGifs: this.$store.state.config.stopGifs + hideAttachmentsLocal: user.hideAttachments, + hideAttachmentsInConvLocal: user.hideAttachmentsInConv, + hideNsfwLocal: user.hideNsfw, + hideISPLocal: user.hideISP, + preloadImage: user.preloadImage, + hidePostStatsLocal: typeof user.hidePostStats === 'undefined' + ? instance.hidePostStats + : user.hidePostStats, + hidePostStatsDefault: this.$t('settings.values.' + instance.hidePostStats), + hideUserStatsLocal: typeof user.hideUserStats === 'undefined' + ? instance.hideUserStats + : user.hideUserStats, + hideUserStatsDefault: this.$t('settings.values.' + instance.hideUserStats), + notificationVisibilityLocal: user.notificationVisibility, + replyVisibilityLocal: user.replyVisibility, + loopVideoLocal: user.loopVideo, + loopVideoSilentOnlyLocal: user.loopVideoSilentOnly, + muteWordsString: user.muteWords.join('\n'), + autoLoadLocal: user.autoLoad, + streamingLocal: user.streaming, + pauseOnUnfocusedLocal: user.pauseOnUnfocused, + hoverPreviewLocal: user.hoverPreview, + collapseMessageWithSubjectLocal: typeof user.collapseMessageWithSubject === 'undefined' + ? instance.collapseMessageWithSubject + : user.collapseMessageWithSubject, + collapseMessageWithSubjectDefault: this.$t('settings.values.' + instance.collapseMessageWithSubject), + subjectLineBehaviorLocal: typeof user.subjectLineBehavior === 'undefined' + ? instance.subjectLineBehavior + : user.subjectLineBehavior, + subjectLineBehaviorDefault: instance.subjectLineBehavior, + alwaysShowSubjectInputLocal: typeof user.alwaysShowSubjectInput === 'undefined' + ? instance.alwaysShowSubjectInput + : user.alwaysShowSubjectInput, + alwaysShowSubjectInputDefault: instance.alwaysShowSubjectInput, + scopeCopyLocal: user.scopeCopy, + scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy), + stopGifs: user.stopGifs, + webPushNotificationsLocal: user.webPushNotifications, + loopSilentAvailable: + // Firefox + Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || + // Chrome-likes + Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') || + // Future spec, still not supported in Nightly 63 as of 08/2018 + Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks') } }, components: { - StyleSwitcher + TabSwitcher, + StyleSwitcher, + InterfaceLanguageSwitcher }, computed: { user () { return this.$store.state.users.currentUser + }, + currentSaveStateNotice () { + return this.$store.state.interface.settings.currentSaveStateNotice } }, watch: { @@ -29,15 +77,51 @@ const settings = { hideAttachmentsInConvLocal (value) { this.$store.dispatch('setOption', { name: 'hideAttachmentsInConv', value }) }, + hidePostStatsLocal (value) { + this.$store.dispatch('setOption', { name: 'hidePostStats', value }) + }, + hideUserStatsLocal (value) { + this.$store.dispatch('setOption', { name: 'hideUserStats', value }) + }, hideNsfwLocal (value) { this.$store.dispatch('setOption', { name: 'hideNsfw', value }) }, + preloadImage (value) { + this.$store.dispatch('setOption', { name: 'preloadImage', value }) + }, + hideISPLocal (value) { + this.$store.dispatch('setOption', { name: 'hideISP', value }) + }, + 'notificationVisibilityLocal.likes' (value) { + this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility }) + }, + 'notificationVisibilityLocal.follows' (value) { + this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility }) + }, + 'notificationVisibilityLocal.repeats' (value) { + this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility }) + }, + 'notificationVisibilityLocal.mentions' (value) { + this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility }) + }, + replyVisibilityLocal (value) { + this.$store.dispatch('setOption', { name: 'replyVisibility', value }) + }, + loopVideoLocal (value) { + this.$store.dispatch('setOption', { name: 'loopVideo', value }) + }, + loopVideoSilentOnlyLocal (value) { + this.$store.dispatch('setOption', { name: 'loopVideoSilentOnly', value }) + }, autoLoadLocal (value) { this.$store.dispatch('setOption', { name: 'autoLoad', value }) }, streamingLocal (value) { this.$store.dispatch('setOption', { name: 'streaming', value }) }, + pauseOnUnfocusedLocal (value) { + this.$store.dispatch('setOption', { name: 'pauseOnUnfocused', value }) + }, hoverPreviewLocal (value) { this.$store.dispatch('setOption', { name: 'hoverPreview', value }) }, @@ -45,8 +129,24 @@ const settings = { value = filter(value.split('\n'), (word) => trim(word).length > 0) this.$store.dispatch('setOption', { name: 'muteWords', value }) }, + collapseMessageWithSubjectLocal (value) { + this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value }) + }, + scopeCopyLocal (value) { + this.$store.dispatch('setOption', { name: 'scopeCopy', value }) + }, + alwaysShowSubjectInputLocal (value) { + this.$store.dispatch('setOption', { name: 'alwaysShowSubjectInput', value }) + }, + subjectLineBehaviorLocal (value) { + this.$store.dispatch('setOption', { name: 'subjectLineBehavior', value }) + }, stopGifs (value) { this.$store.dispatch('setOption', { name: 'stopGifs', value }) + }, + webPushNotificationsLocal (value) { + this.$store.dispatch('setOption', { name: 'webPushNotifications', value }) + if (value) this.$store.dispatch('registerPushNotifications') } } } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index b4514ba1..3f920de5 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -1,53 +1,234 @@ <template> - <div class="settings panel panel-default"> - <div class="panel-heading"> +<div class="settings panel panel-default"> + <div class="panel-heading"> + <div class="title"> {{$t('settings.settings')}} </div> - <div class="panel-body"> - <div class="setting-item"> - <h2>{{$t('settings.theme')}}</h2> - <style-switcher></style-switcher> - </div> - <div class="setting-item"> - <h2>{{$t('settings.filtering')}}</h2> - <p>{{$t('settings.filtering_explanation')}}</p> - <textarea id="muteWords" v-model="muteWordsString"></textarea> - </div> - <div class="setting-item"> - <h2>{{$t('settings.attachments')}}</h2> - <ul class="setting-list"> + + <transition name="fade"> + <template v-if="currentSaveStateNotice"> + <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error"> + {{ $t('settings.saving_err') }} + </div> + + <div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error"> + {{ $t('settings.saving_ok') }} + </div> + </template> + </transition> + </div> + <div class="panel-body"> +<keep-alive> + <tab-switcher> + <div :label="$t('settings.general')" > + <div class="setting-item"> + <h2>{{ $t('settings.interface') }}</h2> + <ul class="setting-list"> + <li> + <interface-language-switcher /> + </li> + <li> + <input type="checkbox" id="hideISP" v-model="hideISPLocal"> + <label for="hideISP">{{$t('settings.hide_isp')}}</label> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{$t('nav.timeline')}}</h2> + <ul class="setting-list"> + <li> + <input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal"> + <label for="collapseMessageWithSubject"> + {{$t('settings.collapse_subject')}} {{$t('settings.instance_default', { value: collapseMessageWithSubjectDefault })}} + </label> + </li> + <li> + <input type="checkbox" id="streaming" v-model="streamingLocal"> + <label for="streaming">{{$t('settings.streaming')}}</label> + <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]"> + <li> + <input :disabled="!streamingLocal" type="checkbox" id="pauseOnUnfocused" v-model="pauseOnUnfocusedLocal"> + <label for="pauseOnUnfocused">{{$t('settings.pause_on_unfocused')}}</label> + </li> + </ul> + </li> + <li> + <input type="checkbox" id="autoload" v-model="autoLoadLocal"> + <label for="autoload">{{$t('settings.autoload')}}</label> + </li> + <li> + <input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal"> + <label for="hoverPreview">{{$t('settings.reply_link_preview')}}</label> + </li> + </ul> + </div> + + <div class="setting-item"> + <h2>{{$t('settings.composing')}}</h2> + <ul class="setting-list"> + <li> + <input type="checkbox" id="scopeCopy" v-model="scopeCopyLocal"> + <label for="scopeCopy"> + {{$t('settings.scope_copy')}} {{$t('settings.instance_default', { value: scopeCopyDefault })}} + </label> + </li> + <li> + <input type="checkbox" id="subjectHide" v-model="alwaysShowSubjectInputLocal"> + <label for="subjectHide"> + {{$t('settings.subject_input_always_show')}} {{$t('settings.instance_default', { value: alwaysShowSubjectInputDefault })}} + </label> + </li> <li> - <input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal"> - <label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label> + <div> + {{$t('settings.subject_line_behavior')}} + <label for="subjectLineBehavior" class="select"> + <select id="subjectLineBehavior" v-model="subjectLineBehaviorLocal"> + <option value="email"> + {{$t('settings.subject_line_email')}} + {{subjectLineBehaviorDefault == 'email' ? $t('settings.instance_default_simple') : ''}} + </option> + <option value="masto"> + {{$t('settings.subject_line_mastodon')}} + {{subjectLineBehaviorDefault == 'mastodon' ? $t('settings.instance_default_simple') : ''}} + </option> + <option value="noop"> + {{$t('settings.subject_line_noop')}} + {{subjectLineBehaviorDefault == 'noop' ? $t('settings.instance_default_simple') : ''}} + </option> + </select> + <i class="icon-down-open"/> + </label> + </div> </li> + </ul> + </div> + + <div class="setting-item"> + <h2>{{$t('settings.attachments')}}</h2> + <ul class="setting-list"> <li> - <input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal"> - <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label> + <input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal"> + <label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label> </li> <li> - <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal"> - <label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label> + <input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal"> + <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label> </li> <li> - <input type="checkbox" id="autoload" v-model="autoLoadLocal"> - <label for="autoload">{{$t('settings.autoload')}}</label> + <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal"> + <label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label> </li> + <ul class="setting-list suboptions" > + <li> + <input :disabled="!hideAttachmentsInConvLocal" type="checkbox" id="preloadImage" v-model="preloadImage"> + <label for="preloadImage">{{$t('settings.preload_images')}}</label> + </li> + </ul> <li> - <input type="checkbox" id="streaming" v-model="streamingLocal"> - <label for="streaming">{{$t('settings.streaming')}}</label> + <input type="checkbox" id="stopGifs" v-model="stopGifs"> + <label for="stopGifs">{{$t('settings.stop_gifs')}}</label> </li> <li> - <input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal"> - <label for="hoverPreview">{{$t('settings.reply_link_preview')}}</label> + <input type="checkbox" id="loopVideo" v-model="loopVideoLocal"> + <label for="loopVideo">{{$t('settings.loop_video')}}</label> + <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]"> + <li> + <input :disabled="!loopVideoLocal || !loopSilentAvailable" type="checkbox" id="loopVideoSilentOnly" v-model="loopVideoSilentOnlyLocal"> + <label for="loopVideoSilentOnly">{{$t('settings.loop_video_silent_only')}}</label> + <div v-if="!loopSilentAvailable" class="unavailable"> + <i class="icon-globe"/>! {{$t('settings.limited_availability')}} + </div> + </li> + </ul> </li> + </ul> + </div> + + <div class="setting-item"> + <h2>{{$t('settings.notifications')}}</h2> + <ul class="setting-list"> <li> - <input type="checkbox" id="stopGifs" v-model="stopGifs"> - <label for="stopGifs">{{$t('settings.stop_gifs')}}</label> + <input type="checkbox" id="webPushNotifications" v-model="webPushNotificationsLocal"> + <label for="webPushNotifications"> + {{$t('settings.enable_web_push_notifications')}} + </label> </li> - </ul> + </ul> + </div> </div> - </div> + + <div :label="$t('settings.theme')" > + <div class="setting-item"> + <style-switcher></style-switcher> + </div> + </div> + + <div :label="$t('settings.filtering')" > + <div class="setting-item"> + <div class="select-multiple"> + <span class="label">{{$t('settings.notification_visibility')}}</span> + <ul class="option-list"> + <li> + <input type="checkbox" id="notification-visibility-likes" v-model="notificationVisibilityLocal.likes"> + <label for="notification-visibility-likes"> + {{$t('settings.notification_visibility_likes')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-visibility-repeats" v-model="notificationVisibilityLocal.repeats"> + <label for="notification-visibility-repeats"> + {{$t('settings.notification_visibility_repeats')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-visibility-follows" v-model="notificationVisibilityLocal.follows"> + <label for="notification-visibility-follows"> + {{$t('settings.notification_visibility_follows')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-visibility-mentions" v-model="notificationVisibilityLocal.mentions"> + <label for="notification-visibility-mentions"> + {{$t('settings.notification_visibility_mentions')}} + </label> + </li> + </ul> + </label> + </div> + <div> + {{$t('settings.replies_in_timeline')}} + <label for="replyVisibility" class="select"> + <select id="replyVisibility" v-model="replyVisibilityLocal"> + <option value="all" selected>{{$t('settings.reply_visibility_all')}}</option> + <option value="following">{{$t('settings.reply_visibility_following')}}</option> + <option value="self">{{$t('settings.reply_visibility_self')}}</option> + </select> + <i class="icon-down-open"/> + </label> + </div> + <div> + <input type="checkbox" id="hidePostStats" v-model="hidePostStatsLocal"> + <label for="hidePostStats"> + {{$t('settings.hide_post_stats')}} {{$t('settings.instance_default', { value: hidePostStatsDefault })}} + </label> + </div> + <div> + <input type="checkbox" id="hideUserStats" v-model="hideUserStatsLocal"> + <label for="hideUserStats"> + {{$t('settings.hide_user_stats')}} {{$t('settings.instance_default', { value: hideUserStatsDefault })}} + </label> + </div> + </div> + <div class="setting-item"> + <p>{{$t('settings.filtering_explanation')}}</p> + <textarea id="muteWords" v-model="muteWordsString"></textarea> + </div> + </div> + + </tab-switcher> +</keep-alive> </div> +</div> </template> <script src="./settings.js"> @@ -57,13 +238,39 @@ @import '../../_variables.scss'; .setting-item { + border-bottom: 2px solid var(--fg, $fallback--fg); margin: 1em 1em 1.4em; + padding-bottom: 1.4em; + + > div { + margin-bottom: .5em; + &:last-child { + margin-bottom: 0; + } + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 1em; + } + + select { + min-width: 10em; + } + textarea { width: 100%; height: 100px; } + .unavailable, + .unavailable i { + color: var(--cRed, $fallback--cRed); + color: $fallback--cRed; + } + .old-avatar { width: 128px; border-radius: $fallback--avatarRadius; @@ -79,15 +286,27 @@ } .btn { - margin-top: 1em; min-height: 28px; - width: 10em; + min-width: 10em; + padding: 0 2em; } } -.setting-list { +.select-multiple { + display: flex; + .option-list { + margin: 0; + padding-left: .5em; + } +} +.setting-list, +.option-list{ list-style-type: none; + padding-left: 2em; li { margin-bottom: 0.5em; } + .suboptions { + margin-top: 0.3em + } } </style> diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js new file mode 100644 index 00000000..44e4a22f --- /dev/null +++ b/src/components/shadow_control/shadow_control.js @@ -0,0 +1,87 @@ +import ColorInput from '../color_input/color_input.vue' +import OpacityInput from '../opacity_input/opacity_input.vue' +import { getCssShadow } from '../../services/style_setter/style_setter.js' +import { hex2rgb } from '../../services/color_convert/color_convert.js' + +export default { + // 'Value' and 'Fallback' can be undefined, but if they are + // initially vue won't detect it when they become something else + // therefore i'm using "ready" which should be passed as true when + // data becomes available + props: [ + 'value', 'fallback', 'ready' + ], + data () { + return { + selectedId: 0, + // TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason) + cValue: this.value || this.fallback || [] + } + }, + components: { + ColorInput, + OpacityInput + }, + methods: { + add () { + this.cValue.push(Object.assign({}, this.selected)) + this.selectedId = this.cValue.length - 1 + }, + del () { + this.cValue.splice(this.selectedId, 1) + this.selectedId = this.cValue.length === 0 ? undefined : this.selectedId - 1 + }, + moveUp () { + const movable = this.cValue.splice(this.selectedId, 1)[0] + this.cValue.splice(this.selectedId - 1, 0, movable) + this.selectedId -= 1 + }, + moveDn () { + const movable = this.cValue.splice(this.selectedId, 1)[0] + this.cValue.splice(this.selectedId + 1, 0, movable) + this.selectedId += 1 + } + }, + beforeUpdate () { + this.cValue = this.value || this.fallback + }, + computed: { + selected () { + if (this.ready && this.cValue.length > 0) { + return this.cValue[this.selectedId] + } else { + return { + x: 0, + y: 0, + blur: 0, + spread: 0, + inset: false, + color: '#000000', + alpha: 1 + } + } + }, + moveUpValid () { + return this.ready && this.selectedId > 0 + }, + moveDnValid () { + return this.ready && this.selectedId < this.cValue.length - 1 + }, + present () { + return this.ready && + typeof this.cValue[this.selectedId] !== 'undefined' && + !this.usingFallback + }, + usingFallback () { + return typeof this.value === 'undefined' + }, + rgb () { + return hex2rgb(this.selected.color) + }, + style () { + return this.ready ? { + boxShadow: getCssShadow(this.cValue) + } : {} + } + } +} diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue new file mode 100644 index 00000000..744925d4 --- /dev/null +++ b/src/components/shadow_control/shadow_control.vue @@ -0,0 +1,243 @@ +<template> +<div class="shadow-control" :class="{ disabled: !present }"> + <div class="shadow-preview-container"> + <div :disabled="!present" class="y-shift-control"> + <input + v-model="selected.y" + :disabled="!present" + class="input-number" + type="number"> + <div class="wrap"> + <input + v-model="selected.y" + :disabled="!present" + class="input-range" + type="range" + max="20" + min="-20"> + </div> + </div> + <div class="preview-window"> + <div class="preview-block" :style="style"></div> + </div> + <div :disabled="!present" class="x-shift-control"> + <input + v-model="selected.x" + :disabled="!present" + class="input-number" + type="number"> + <div class="wrap"> + <input + v-model="selected.x" + :disabled="!present" + class="input-range" + type="range" + max="20" + min="-20"> + </div> + </div> + </div> + + <div class="shadow-tweak"> + <div :disabled="usingFallback" class="id-control style-control"> + <label for="shadow-switcher" class="select" :disabled="!ready || usingFallback"> + <select + v-model="selectedId" class="shadow-switcher" + :disabled="!ready || usingFallback" + id="shadow-switcher"> + <option v-for="(shadow, index) in cValue" :value="index"> + {{$t('settings.style.shadows.shadow_id', { value: index })}} + </option> + </select> + <i class="icon-down-open"/> + </label> + <button class="btn btn-default" :disabled="!ready || !present" @click="del"> + <i class="icon-cancel"/> + </button> + <button class="btn btn-default" :disabled="!moveUpValid" @click="moveUp"> + <i class="icon-up-open"/> + </button> + <button class="btn btn-default" :disabled="!moveDnValid" @click="moveDn"> + <i class="icon-down-open"/> + </button> + <button class="btn btn-default" :disabled="usingFallback" @click="add"> + <i class="icon-plus"/> + </button> + </div> + <div :disabled="!present" class="inset-control style-control"> + <label for="inset" class="label"> + {{$t('settings.style.shadows.inset')}} + </label> + <input + v-model="selected.inset" + :disabled="!present" + name="inset" + id="inset" + class="input-inset" + type="checkbox"> + <label class="checkbox-label" for="inset"></label> + </div> + <div :disabled="!present" class="blur-control style-control"> + <label for="spread" class="label"> + {{$t('settings.style.shadows.blur')}} + </label> + <input + v-model="selected.blur" + :disabled="!present" + name="blur" + id="blur" + class="input-range" + type="range" + max="20" + min="0"> + <input + v-model="selected.blur" + :disabled="!present" + class="input-number" + type="number" + min="0"> + </div> + <div :disabled="!present" class="spread-control style-control"> + <label for="spread" class="label"> + {{$t('settings.style.shadows.spread')}} + </label> + <input + v-model="selected.spread" + :disabled="!present" + name="spread" + id="spread" + class="input-range" + type="range" + max="20" + min="-20"> + <input + v-model="selected.spread" + :disabled="!present" + class="input-number" + type="number"> + </div> + <ColorInput + v-model="selected.color" + :disabled="!present" + :label="$t('settings.style.common.color')" + name="shadow"/> + <OpacityInput + v-model="selected.alpha" + :disabled="!present"/> + <p> + {{$t('settings.style.shadows.hint')}} + </p> + </div> +</div> +</template> + +<script src="./shadow_control.js" ></script> + +<style lang="scss"> +@import '../../_variables.scss'; +.shadow-control { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 1em; + + .shadow-preview-container, + .shadow-tweak { + margin: 5px 6px 0 0; + } + .shadow-preview-container { + flex: 0; + display: flex; + flex-wrap: wrap; + + $side: 15em; + + input[type=number] { + width: 5em; + min-width: 2em; + } + .x-shift-control, + .y-shift-control { + display: flex; + flex: 0; + + &[disabled=disabled] *{ + opacity: .5 + } + + } + + .x-shift-control { + align-items: flex-start; + } + + .x-shift-control .wrap, + input[type=range] { + margin: 0; + width: $side; + height: 2em; + } + .y-shift-control { + flex-direction: column; + align-items: flex-end; + .wrap { + width: 2em; + height: $side; + } + input[type=range] { + transform-origin: 1em 1em; + transform: rotate(90deg); + } + } + .preview-window { + flex: 1; + background-color: #999999; + display: flex; + align-items: center; + justify-content: center; + background-image: + linear-gradient(45deg, #666666 25%, transparent 25%), + linear-gradient(-45deg, #666666 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #666666 75%), + linear-gradient(-45deg, transparent 75%, #666666 75%); + background-size: 20px 20px; + background-position:0 0, 0 10px, 10px -10px, -10px 0; + + border-radius: $fallback--inputRadius; + border-radius: var(--inputRadius, $fallback--inputRadius); + + .preview-block { + width: 33%; + height: 33%; + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + } + } + } + + .shadow-tweak { + flex: 1; + min-width: 280px; + + .id-control { + align-items: stretch; + .select, .btn { + min-width: 1px; + margin-right: 5px; + } + .btn { + padding: 0 .4em; + margin: 0 .1em; + } + .select { + flex: 1; + select { + align-self: initial; + } + } + } + } +} +</style> diff --git a/src/components/status/status.js b/src/components/status/status.js index 73f4a7aa..9a63d047 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -6,6 +6,7 @@ import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCardContent from '../user_card_content/user_card_content.vue' import StillImage from '../still-image/still-image.vue' import { filter, find } from 'lodash' +import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' const Status = { name: 'Status', @@ -19,27 +20,62 @@ const Status = { 'replies', 'noReplyLinks', 'noHeading', - 'inlineExpanded' + 'inlineExpanded', + 'activatePanel' ], - data: () => ({ - replying: false, - expanded: false, - unmuted: false, - userExpanded: false, - preview: null, - showPreview: false, - showingTall: false - }), + data () { + return { + replying: false, + expanded: false, + unmuted: false, + userExpanded: false, + preview: null, + showPreview: false, + showingTall: false, + expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' + ? !this.$store.state.instance.collapseMessageWithSubject + : !this.$store.state.config.collapseMessageWithSubject, + betterShadow: this.$store.state.interface.browserSupport.cssFilter + } + }, computed: { + localCollapseSubjectDefault () { + return typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' + ? this.$store.state.instance.collapseMessageWithSubject + : this.$store.state.config.collapseMessageWithSubject + }, muteWords () { return this.$store.state.config.muteWords }, + repeaterClass () { + const user = this.statusoid.user + return highlightClass(user) + }, + userClass () { + const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user + return highlightClass(user) + }, + deleted () { + return this.statusoid.deleted + }, + repeaterStyle () { + const user = this.statusoid.user + const highlight = this.$store.state.config.highlight + return highlightStyle(highlight[user.screen_name]) + }, + userStyle () { + if (this.noHeading) return + const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user + const highlight = this.$store.state.config.highlight + return highlightStyle(highlight[user.screen_name]) + }, hideAttachments () { return (this.$store.state.config.hideAttachments && !this.inConversation) || (this.$store.state.config.hideAttachmentsInConv && this.inConversation) }, retweet () { return !!this.statusoid.retweeted_status }, retweeter () { return this.statusoid.user.name }, + retweeterHtml () { return this.statusoid.user.name_html }, status () { if (this.retweet) { return this.statusoid.retweeted_status @@ -59,7 +95,6 @@ const Status = { return hits }, muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) }, - isReply () { return !!this.status.in_reply_to_status_id }, isFocused () { // retweet or root of an expanded conversation if (this.focused) { @@ -77,12 +112,90 @@ const Status = { // // Using max-height + overflow: auto for status components resulted in false positives // very often with japanese characters, and it was very annoying. + tallStatus () { + const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 + return lengthScore > 20 + }, + isReply () { + if (this.status.in_reply_to_status_id) { + return true + } + // For private replies where we can't see the OP, in_reply_to_status_id will be null. + // So instead, check that the post starts with a @mention. + if (this.status.visibility === 'private') { + var textBody = this.status.text + if (this.status.summary !== null) { + textBody = textBody.substring(this.status.summary.length, textBody.length) + } + return textBody.startsWith('@') + } + return false + }, + hideReply () { + if (this.$store.state.config.replyVisibility === 'all') { + return false + } + if (this.inlineExpanded || this.expanded || this.inConversation || !this.isReply) { + return false + } + if (this.status.user.id === this.$store.state.users.currentUser.id) { + return false + } + if (this.status.activity_type === 'repeat') { + return false + } + var checkFollowing = this.$store.state.config.replyVisibility === 'following' + for (var i = 0; i < this.status.attentions.length; ++i) { + if (this.status.user.id === this.status.attentions[i].id) { + continue + } + if (checkFollowing && this.status.attentions[i].following) { + return false + } + if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) { + return false + } + } + return this.status.attentions.length > 0 + }, + hideSubjectStatus () { + if (this.tallStatus && !this.localCollapseSubjectDefault) { + return false + } + return !this.expandingSubject && this.status.summary + }, hideTallStatus () { + if (this.status.summary && this.localCollapseSubjectDefault) { + return false + } if (this.showingTall) { return false } - const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 - return lengthScore > 20 + return this.tallStatus + }, + showingMore () { + return this.showingTall || (this.status.summary && this.expandingSubject) + }, + nsfwClickthrough () { + if (!this.status.nsfw) { + return false + } + if (this.status.summary && this.localCollapseSubjectDefault) { + return false + } + return true + }, + replySubject () { + if (!this.status.summary) return '' + const behavior = this.$store.state.config.subjectLineBehavior + const startsWithRe = this.status.summary.match(/^re[: ]/i) + if (behavior !== 'noop' && startsWithRe || behavior === 'masto') { + return this.status.summary + } else if (behavior === 'email') { + return 're: '.concat(this.status.summary) + } else if (behavior === 'noop') { + return '' + } }, attachmentSize () { if ((this.$store.state.config.hideAttachments && !this.inConversation) || @@ -104,6 +217,18 @@ const Status = { StillImage }, methods: { + visibilityIcon (visibility) { + switch (visibility) { + case 'private': + return 'icon-lock' + case 'unlisted': + return 'icon-lock-open-alt' + case 'direct': + return 'icon-mail-alt' + default: + return 'icon-globe' + } + }, linkClicked ({target}) { if (target.tagName === 'SPAN') { target = target.parentNode @@ -130,8 +255,16 @@ const Status = { toggleUserExpanded () { this.userExpanded = !this.userExpanded }, - toggleShowTall () { - this.showingTall = !this.showingTall + toggleShowMore () { + if (this.showingTall) { + this.showingTall = false + } else if (this.expandingSubject) { + this.expandingSubject = false + } else if (this.hideTallStatus) { + this.showingTall = true + } else if (this.hideSubjectStatus) { + this.expandingSubject = true + } }, replyEnter (id, event) { this.showPreview = true @@ -167,6 +300,11 @@ const Status = { } } } + }, + filters: { + capitalize: function (str) { + return str.charAt(0).toUpperCase() + str.slice(1) + } } } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index f1163fd9..067980ac 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,26 +1,27 @@ <template> - <div class="status-el" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> + <div class="status-el" v-if="!hideReply && !deleted" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> <template v-if="muted && !noReplyLinks"> <div class="media status container muted"> - <small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small> + <small><router-link @click.native="activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small> <small class="muteWords">{{muteWordHits.join(', ')}}</small> <a href="#" class="unmute" @click.prevent="toggleMute"><i class="icon-eye-off"></i></a> </div> </template> <template v-else> - <div v-if="retweet && !noHeading" class="media container retweet-info"> - <StillImage v-if="retweet" class='avatar' :src="statusoid.user.profile_image_url_original"/> + <div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> + <StillImage v-if="retweet" class='avatar' :class='{ "better-shadow": betterShadow }' :src="statusoid.user.profile_image_url_original"/> <div class="media-body faint"> - <a :href="statusoid.user.statusnet_profile_url" style="font-weight: bold;" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a> - <i class='fa icon-retweet retweeted'></i> + <a v-if="retweeterHtml" :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name" v-html="retweeterHtml"></a> + <a v-else :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a> + <i class='fa icon-retweet retweeted' :title="$t('tool_tip.repeat')"></i> {{$t('timeline.repeated')}} </div> </div> - <div class="media status"> + <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status"> <div v-if="!noHeading" class="media-left"> <a :href="status.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> - <StillImage class='avatar' :class="{'avatar-compact': compact}" :src="status.user.profile_image_url_original"/> + <StillImage class='avatar' :class="{'avatar-compact': compact, 'better-shadow': betterShadow}" :src="status.user.profile_image_url_original"/> </a> </div> <div class="status-body"> @@ -30,16 +31,17 @@ <div v-if="!noHeading" class="media-body container media-heading"> <div class="media-heading-left"> <div class="name-and-links"> - <h4 class="user-name">{{status.user.name}}</h4> + <h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4> + <h4 class="user-name" v-else>{{status.user.name}}</h4> <span class="links"> - <router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link> + <router-link @click.native="activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link> <span v-if="status.in_reply_to_screen_name" class="faint reply-info"> <i class="icon-right-open"></i> - <router-link :to="{ name: 'user-profile', params: { id: status.in_reply_to_user_id } }"> + <router-link @click.native="activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: status.in_reply_to_user_id } }"> {{status.in_reply_to_screen_name}} </router-link> </span> - <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"> + <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" :title="$t('tool_tip.reply')"> <i class="icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i> </a> </span> @@ -52,42 +54,51 @@ </h4> </div> <div class="media-heading-right"> - <router-link class="timeago" :to="{ name: 'conversation', params: { id: status.id } }"> + <router-link class="timeago" @click.native="activatePanel('timeline')" :to="{ name: 'conversation', params: { id: status.id } }"> <timeago :since="status.created_at" :auto-update="60"></timeago> </router-link> - <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url"><i class="icon-link-ext"></i></a> + <div class="visibility-icon" v-if="status.visibility"> + <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i> + </div> + <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url" title="Source"> + <i class="icon-link-ext-alt"></i> + </a> <template v-if="expandable"> - <a href="#" @click.prevent="toggleExpanded"><i class="icon-plus-squared"></i></a> + <a href="#" @click.prevent="toggleExpanded" title="Expand"> + <i class="icon-plus-squared"></i> + </a> </template> <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="icon-eye-off"></i></a> </div> </div> <div v-if="showPreview" class="status-preview-container"> - <status class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status> + <status :activatePanel="activatePanel" class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status> <div class="status-preview status-preview-loading" v-else> <i class="icon-spin4 animate-spin"></i> </div> </div> <div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper"> - <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowTall">Show more</a> - <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html"></div> - <a v-if="showingTall" href="#" class="tall-status-unhider" @click.prevent="toggleShowTall">Show less</a> + <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a> + <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div> + <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary" v-else></div> + <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">Show more</a> + <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a> </div> - <div v-if='status.attachments' class='attachments media-body'> - <attachment :size="attachmentSize" :status-id="status.id" :nsfw="status.nsfw" :attachment="attachment" v-for="attachment in status.attachments" :key="attachment.id"> + <div v-if='status.attachments && !hideSubjectStatus' class='attachments media-body'> + <attachment :size="attachmentSize" :status-id="status.id" :nsfw="nsfwClickthrough" :attachment="attachment" v-for="attachment in status.attachments" :key="attachment.id"> </attachment> </div> <div v-if="!noHeading && !noReplyLinks" class='status-actions media-body'> <div v-if="loggedIn"> - <a href="#" v-on:click.prevent="toggleReplying"> + <a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')"> <i class="icon-reply" :class="{'icon-reply-active': replying}"></i> </a> </div> - <retweet-button :loggedIn='loggedIn' :status='status'></retweet-button> + <retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button> <favorite-button :loggedIn='loggedIn' :status='status'></favorite-button> <delete-button :status='status'></delete-button> </div> @@ -95,7 +106,7 @@ </div> <div class="container" v-if="replying"> <div class="reply-left"/> - <post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" v-on:posted="toggleReplying"/> + <post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :copy-message-scope="status.visibility" :subject="replySubject" v-on:posted="toggleReplying"/> </div> </template> </div> @@ -135,9 +146,11 @@ border-radius: $fallback--tooltipRadius; border-radius: var(--tooltipRadius, $fallback--tooltipRadius); box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); + box-shadow: var(--popupShadow); margin-top: 0.25em; margin-left: 0.5em; z-index: 50; + .status { flex: 1; border: 0; @@ -152,6 +165,7 @@ text-align: center; border-width: 1px; border-style: solid; + i { font-size: 2em; } @@ -165,8 +179,6 @@ border-left-width: 0px; line-height: 18px; min-width: 0; - background-color: $fallback--bg; - background-color: var(--bg, $fallback--bg); border-color: $fallback--border; border-color: var(--border, $fallback--border); @@ -189,8 +201,13 @@ margin: 0 0 0.25em 0.8em; } + .usercard { + margin-bottom: .7em + } + .media-heading { flex-wrap: nowrap; + line-height: 18px; } .media-heading-left { @@ -213,12 +230,22 @@ flex: 1 0; display: flex; flex-wrap: wrap; - align-content: center; + align-items: baseline; + + .user-name { + margin-right: .45em; + + img { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } + } } + .links { display: flex; - padding-top: 1px; - margin-left: 0.2em; font-size: 12px; color: $fallback--link; color: var(--link, $fallback--link); @@ -242,19 +269,25 @@ } .media-heading-right { + display: inline-flex; flex-shrink: 0; - display: flex; flex-wrap: nowrap; - max-height: 1.5em; - margin-left: 0.25em; + margin-left: .25em; + align-self: baseline; + .timeago { margin-right: 0.2em; font-size: 12px; - padding-top: 1px; + align-self: last baseline; } - i { + + > * { margin-left: 0.2em; } + a:hover i { + color: $fallback--text; + color: var(--text, $fallback--text); + } } a { @@ -284,13 +317,15 @@ } } - .tall-status-unhider { + .status-unhider, .cw-status-hider { width: 100%; text-align: center; } .status-content { margin-right: 0.5em; + font-family: var(--postFont, sans-serif); + img, video { max-width: 100%; max-height: 400px; @@ -303,16 +338,45 @@ font-style: italic; } + pre { + overflow: auto; + } + + code, samp, kbd, var, pre { + font-family: var(--postCodeFont, monospace); + } + p { margin: 0; margin-top: 0.2em; margin-bottom: 0.5em; } + + h1 { + font-size: 1.1em; + line-height: 1.2em; + margin: 1.4em 0; + } + + h2 { + font-size: 1.1em; + margin: 1.0em 0; + } + + h3 { + font-size: 1em; + margin: 1.2em 0; + } + + h4 { + margin: 1.1em 0; + } } .retweet-info { padding: 0.4em 0.6em 0 0.6em; - margin: 0 0 -0.5em 0; + margin: 0; + .avatar { border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); @@ -328,9 +392,22 @@ display: flex; align-content: center; flex-wrap: wrap; + + .user-name { + font-weight: bold; + + img { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } + } + i { padding: 0 0.2em; } + a { max-width: 100%; overflow: hidden; @@ -387,18 +464,30 @@ .status .avatar-compact { width: 32px; height: 32px; + box-shadow: var(--avatarStatusShadow); border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + + &.better-shadow { + box-shadow: var(--avatarStatusShadowInset); + filter: var(--avatarStatusShadowFilter) + } } .avatar { width: 48px; height: 48px; + box-shadow: var(--avatarStatusShadow); border-radius: $fallback--avatarRadius; border-radius: var(--avatarRadius, $fallback--avatarRadius); overflow: hidden; position: relative; + &.better-shadow { + box-shadow: var(--avatarStatusShadowInset); + filter: var(--avatarStatusShadowFilter) + } + img { width: 100%; height: 100%; @@ -424,6 +513,9 @@ .status { display: flex; padding: 0.6em; + &.is-retweet { + padding-top: 0.1em; + } } .status-conversation:last-child { @@ -459,6 +551,7 @@ a.unmute { .status-el:last-child { border-bottom-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;; border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); + border-bottom: none; } } diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index 0839aca5..5ad06dc2 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -18,7 +18,11 @@ const StillImage = { onLoad () { const canvas = this.$refs.canvas if (!canvas) return - canvas.getContext('2d').drawImage(this.$refs.src, 1, 1, canvas.width, canvas.height) + const width = this.$refs.src.naturalWidth + const height = this.$refs.src.naturalHeight + canvas.width = width + canvas.height = height + canvas.getContext('2d').drawImage(this.$refs.src, 0, 0, width, height) } } } diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index a37c678d..1dcb7ce6 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -23,6 +23,7 @@ img { width: 100%; height: 100%; + object-fit: contain; } &.animated { @@ -60,6 +61,7 @@ right: 0; width: 100%; height: 100%; + object-fit: contain; } } </style> diff --git a/src/components/style_switcher/preview.vue b/src/components/style_switcher/preview.vue new file mode 100644 index 00000000..09a136e9 --- /dev/null +++ b/src/components/style_switcher/preview.vue @@ -0,0 +1,78 @@ +<template> +<div class="panel dummy"> + <div class="panel-heading"> + <div class="title"> + {{$t('settings.style.preview.header')}} + <span class="badge badge-notification"> + 99 + </span> + </div> + <span class="faint"> + {{$t('settings.style.preview.header_faint')}} + </span> + <span class="alert error"> + {{$t('settings.style.preview.error')}} + </span> + <button class="btn"> + {{$t('settings.style.preview.button')}} + </button> + </div> + <div class="panel-body theme-preview-content"> + <div class="post"> + <div class="avatar"> + ( ͡° ͜ʖ ͡°) + </div> + <div class="content"> + <h4> + {{$t('settings.style.preview.content')}} + </h4> + + <i18n path="settings.style.preview.text"> + <code style="font-family: var(--postCodeFont)"> + {{$t('settings.style.preview.mono')}} + </code> + <a style="color: var(--link)"> + {{$t('settings.style.preview.link')}} + </a> + </i18n> + + <div class="icons"> + <i style="color: var(--cBlue)" class="icon-reply"/> + <i style="color: var(--cGreen)" class="icon-retweet"/> + <i style="color: var(--cOrange)" class="icon-star"/> + <i style="color: var(--cRed)" class="icon-cancel"/> + </div> + </div> + </div> + + <div class="after-post"> + <div class="avatar-alt"> + :^) + </div> + <div class="content"> + <i18n path="settings.style.preview.fine_print" tag="span" class="faint"> + <a style="color: var(--faintLink)"> + {{$t('settings.style.preview.faint_link')}} + </a> + </i18n> + </div> + </div> + <div class="separator"></div> + + <span class="alert error"> + {{$t('settings.style.preview.error')}} + </span> + <input :value="$t('settings.style.preview.input')" type="text"> + + <div class="actions"> + <span class="checkbox"> + <input checked="very yes" type="checkbox" id="preview_checkbox"> + <label for="preview_checkbox">{{$t('settings.style.preview.checkbox')}}</label> + </span> + <button class="btn"> + {{$t('settings.style.preview.button')}} + </button> + </div> + </div> +</div> +</template> diff --git a/src/components/style_switcher/style_switcher.js b/src/components/style_switcher/style_switcher.js index 08bc7113..6a4e1cba 100644 --- a/src/components/style_switcher/style_switcher.js +++ b/src/components/style_switcher/style_switcher.js @@ -1,19 +1,101 @@ -import { rgbstr2hex } from '../../services/color_convert/color_convert.js' +import { rgb2hex, hex2rgb, getContrastRatio, alphaBlend } from '../../services/color_convert/color_convert.js' +import { set, delete as del } from 'vue' +import { generateColors, generateShadows, generateRadii, generateFonts, composePreset, getThemes } from '../../services/style_setter/style_setter.js' +import ColorInput from '../color_input/color_input.vue' +import RangeInput from '../range_input/range_input.vue' +import OpacityInput from '../opacity_input/opacity_input.vue' +import ShadowControl from '../shadow_control/shadow_control.vue' +import FontControl from '../font_control/font_control.vue' +import ContrastRatio from '../contrast_ratio/contrast_ratio.vue' +import TabSwitcher from '../tab_switcher/tab_switcher.jsx' +import Preview from './preview.vue' +import ExportImport from '../export_import/export_import.vue' + +// List of color values used in v1 +const v1OnlyNames = [ + 'bg', + 'fg', + 'text', + 'link', + 'cRed', + 'cGreen', + 'cBlue', + 'cOrange' +].map(_ => _ + 'ColorLocal') export default { data () { return { availableStyles: [], selected: this.$store.state.config.theme, - bgColorLocal: '', - btnColorLocal: '', + + previewShadows: {}, + previewColors: {}, + previewRadii: {}, + previewFonts: {}, + + shadowsInvalid: true, + colorsInvalid: true, + radiiInvalid: true, + + keepColor: false, + keepShadows: false, + keepOpacity: false, + keepRoundness: false, + keepFonts: false, + textColorLocal: '', linkColorLocal: '', - redColorLocal: '', - blueColorLocal: '', - greenColorLocal: '', - orangeColorLocal: '', + + bgColorLocal: '', + bgOpacityLocal: undefined, + + fgColorLocal: '', + fgTextColorLocal: undefined, + fgLinkColorLocal: undefined, + + btnColorLocal: undefined, + btnTextColorLocal: undefined, + btnOpacityLocal: undefined, + + inputColorLocal: undefined, + inputTextColorLocal: undefined, + inputOpacityLocal: undefined, + + panelColorLocal: undefined, + panelTextColorLocal: undefined, + panelLinkColorLocal: undefined, + panelFaintColorLocal: undefined, + panelOpacityLocal: undefined, + + topBarColorLocal: undefined, + topBarTextColorLocal: undefined, + topBarLinkColorLocal: undefined, + + alertErrorColorLocal: undefined, + + badgeOpacityLocal: undefined, + badgeNotificationColorLocal: undefined, + + borderColorLocal: undefined, + borderOpacityLocal: undefined, + + faintColorLocal: undefined, + faintOpacityLocal: undefined, + faintLinkColorLocal: undefined, + + cRedColorLocal: '', + cBlueColorLocal: '', + cGreenColorLocal: '', + cOrangeColorLocal: '', + + shadowSelected: undefined, + shadowsLocal: {}, + fontsLocal: {}, + btnRadiusLocal: '', + inputRadiusLocal: '', + checkboxRadiusLocal: '', panelRadiusLocal: '', avatarRadiusLocal: '', avatarAltRadiusLocal: '', @@ -24,86 +106,470 @@ export default { created () { const self = this - window.fetch('/static/styles.json') - .then((data) => data.json()) - .then((themes) => { - self.availableStyles = themes - }) + getThemes().then((themesComplete) => { + self.availableStyles = themesComplete + }) }, mounted () { - this.bgColorLocal = rgbstr2hex(this.$store.state.config.colors.bg) - this.btnColorLocal = rgbstr2hex(this.$store.state.config.colors.btn) - this.textColorLocal = rgbstr2hex(this.$store.state.config.colors.fg) - this.linkColorLocal = rgbstr2hex(this.$store.state.config.colors.link) - - this.redColorLocal = rgbstr2hex(this.$store.state.config.colors.cRed) - this.blueColorLocal = rgbstr2hex(this.$store.state.config.colors.cBlue) - this.greenColorLocal = rgbstr2hex(this.$store.state.config.colors.cGreen) - this.orangeColorLocal = rgbstr2hex(this.$store.state.config.colors.cOrange) - - this.btnRadiusLocal = this.$store.state.config.radii.btnRadius || 4 - this.panelRadiusLocal = this.$store.state.config.radii.panelRadius || 10 - this.avatarRadiusLocal = this.$store.state.config.radii.avatarRadius || 5 - this.avatarAltRadiusLocal = this.$store.state.config.radii.avatarAltRadius || 50 - this.tooltipRadiusLocal = this.$store.state.config.radii.tooltipRadius || 2 - this.attachmentRadiusLocal = this.$store.state.config.radii.attachmentRadius || 5 + this.normalizeLocalState(this.$store.state.config.customTheme) + if (typeof this.shadowSelected === 'undefined') { + this.shadowSelected = this.shadowsAvailable[0] + } + }, + computed: { + selectedVersion () { + return Array.isArray(this.selected) ? 1 : 2 + }, + currentColors () { + return { + bg: this.bgColorLocal, + text: this.textColorLocal, + link: this.linkColorLocal, + + fg: this.fgColorLocal, + fgText: this.fgTextColorLocal, + fgLink: this.fgLinkColorLocal, + + panel: this.panelColorLocal, + panelText: this.panelTextColorLocal, + panelLink: this.panelLinkColorLocal, + panelFaint: this.panelFaintColorLocal, + + input: this.inputColorLocal, + inputText: this.inputTextColorLocal, + + topBar: this.topBarColorLocal, + topBarText: this.topBarTextColorLocal, + topBarLink: this.topBarLinkColorLocal, + + btn: this.btnColorLocal, + btnText: this.btnTextColorLocal, + + alertError: this.alertErrorColorLocal, + badgeNotification: this.badgeNotificationColorLocal, + + faint: this.faintColorLocal, + faintLink: this.faintLinkColorLocal, + border: this.borderColorLocal, + + cRed: this.cRedColorLocal, + cBlue: this.cBlueColorLocal, + cGreen: this.cGreenColorLocal, + cOrange: this.cOrangeColorLocal + } + }, + currentOpacity () { + return { + bg: this.bgOpacityLocal, + btn: this.btnOpacityLocal, + input: this.inputOpacityLocal, + panel: this.panelOpacityLocal, + topBar: this.topBarOpacityLocal, + border: this.borderOpacityLocal, + faint: this.faintOpacityLocal + } + }, + currentRadii () { + return { + btn: this.btnRadiusLocal, + input: this.inputRadiusLocal, + checkbox: this.checkboxRadiusLocal, + panel: this.panelRadiusLocal, + avatar: this.avatarRadiusLocal, + avatarAlt: this.avatarAltRadiusLocal, + tooltip: this.tooltipRadiusLocal, + attachment: this.attachmentRadiusLocal + } + }, + preview () { + return composePreset(this.previewColors, this.previewRadii, this.previewShadows, this.previewFonts) + }, + previewTheme () { + if (!this.preview.theme.colors) return { colors: {}, opacity: {}, radii: {}, shadows: {}, fonts: {} } + return this.preview.theme + }, + // This needs optimization maybe + previewContrast () { + if (!this.previewTheme.colors.bg) return {} + const colors = this.previewTheme.colors + const opacity = this.previewTheme.opacity + if (!colors.bg) return {} + const hints = (ratio) => ({ + text: ratio.toPrecision(3) + ':1', + // AA level, AAA level + aa: ratio >= 4.5, + aaa: ratio >= 7, + // same but for 18pt+ texts + laa: ratio >= 3, + laaa: ratio >= 4.5 + }) + + // fgsfds :DDDD + const fgs = { + text: hex2rgb(colors.text), + panelText: hex2rgb(colors.panelText), + panelLink: hex2rgb(colors.panelLink), + btnText: hex2rgb(colors.btnText), + topBarText: hex2rgb(colors.topBarText), + inputText: hex2rgb(colors.inputText), + + link: hex2rgb(colors.link), + topBarLink: hex2rgb(colors.topBarLink), + + red: hex2rgb(colors.cRed), + green: hex2rgb(colors.cGreen), + blue: hex2rgb(colors.cBlue), + orange: hex2rgb(colors.cOrange) + } + + const bgs = { + bg: hex2rgb(colors.bg), + btn: hex2rgb(colors.btn), + panel: hex2rgb(colors.panel), + topBar: hex2rgb(colors.topBar), + input: hex2rgb(colors.input), + alertError: hex2rgb(colors.alertError), + badgeNotification: hex2rgb(colors.badgeNotification) + } + + /* This is a bit confusing because "bottom layer" used is text color + * This is done to get worst case scenario when background below transparent + * layer matches text color, making it harder to read the lower alpha is. + */ + const ratios = { + bgText: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.text), fgs.text), + bgLink: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.link), fgs.link), + bgRed: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.red), fgs.red), + bgGreen: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.green), fgs.green), + bgBlue: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.blue), fgs.blue), + bgOrange: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.orange), fgs.orange), + + tintText: getContrastRatio(alphaBlend(bgs.bg, 0.5, fgs.panelText), fgs.text), + + panelText: getContrastRatio(alphaBlend(bgs.panel, opacity.panel, fgs.panelText), fgs.panelText), + panelLink: getContrastRatio(alphaBlend(bgs.panel, opacity.panel, fgs.panelLink), fgs.panelLink), + + btnText: getContrastRatio(alphaBlend(bgs.btn, opacity.btn, fgs.btnText), fgs.btnText), + + inputText: getContrastRatio(alphaBlend(bgs.input, opacity.input, fgs.inputText), fgs.inputText), + + topBarText: getContrastRatio(alphaBlend(bgs.topBar, opacity.topBar, fgs.topBarText), fgs.topBarText), + topBarLink: getContrastRatio(alphaBlend(bgs.topBar, opacity.topBar, fgs.topBarLink), fgs.topBarLink) + } + + return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {}) + }, + previewRules () { + if (!this.preview.rules) return '' + return [ + ...Object.values(this.preview.rules), + 'color: var(--text)', + 'font-family: var(--interfaceFont, sans-serif)' + ].join(';') + }, + shadowsAvailable () { + return Object.keys(this.previewTheme.shadows).sort() + }, + currentShadowOverriden: { + get () { + return !!this.currentShadow + }, + set (val) { + if (val) { + set(this.shadowsLocal, this.shadowSelected, this.currentShadowFallback.map(_ => Object.assign({}, _))) + } else { + del(this.shadowsLocal, this.shadowSelected) + } + } + }, + currentShadowFallback () { + return this.previewTheme.shadows[this.shadowSelected] + }, + currentShadow: { + get () { + return this.shadowsLocal[this.shadowSelected] + }, + set (v) { + set(this.shadowsLocal, this.shadowSelected, v) + } + }, + themeValid () { + return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid + }, + exportedTheme () { + const saveEverything = ( + !this.keepFonts && + !this.keepShadows && + !this.keepOpacity && + !this.keepRoundness && + !this.keepColor + ) + + const theme = {} + + if (this.keepFonts || saveEverything) { + theme.fonts = this.fontsLocal + } + if (this.keepShadows || saveEverything) { + theme.shadows = this.shadowsLocal + } + if (this.keepOpacity || saveEverything) { + theme.opacity = this.currentOpacity + } + if (this.keepColor || saveEverything) { + theme.colors = this.currentColors + } + if (this.keepRoundness || saveEverything) { + theme.radii = this.currentRadii + } + + return { + // To separate from other random JSON files and possible future theme formats + _pleroma_theme_version: 2, theme + } + } + }, + components: { + ColorInput, + OpacityInput, + RangeInput, + ContrastRatio, + ShadowControl, + FontControl, + TabSwitcher, + Preview, + ExportImport }, methods: { setCustomTheme () { - if (!this.bgColorLocal && !this.btnColorLocal && !this.linkColorLocal) { - // reset to picked themes - } - - const rgb = (hex) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : null - } - const bgRgb = rgb(this.bgColorLocal) - const btnRgb = rgb(this.btnColorLocal) - const textRgb = rgb(this.textColorLocal) - const linkRgb = rgb(this.linkColorLocal) - - const redRgb = rgb(this.redColorLocal) - const blueRgb = rgb(this.blueColorLocal) - const greenRgb = rgb(this.greenColorLocal) - const orangeRgb = rgb(this.orangeColorLocal) - - if (bgRgb && btnRgb && linkRgb) { - this.$store.dispatch('setOption', { - name: 'customTheme', - value: { - fg: btnRgb, - bg: bgRgb, - text: textRgb, - link: linkRgb, - cRed: redRgb, - cBlue: blueRgb, - cGreen: greenRgb, - cOrange: orangeRgb, - btnRadius: this.btnRadiusLocal, - panelRadius: this.panelRadiusLocal, - avatarRadius: this.avatarRadiusLocal, - avatarAltRadius: this.avatarAltRadiusLocal, - tooltipRadius: this.tooltipRadiusLocal, - attachmentRadius: this.attachmentRadiusLocal - }}) + this.$store.dispatch('setOption', { + name: 'customTheme', + value: { + shadows: this.shadowsLocal, + fonts: this.fontsLocal, + opacity: this.currentOpacity, + colors: this.currentColors, + radii: this.currentRadii + } + }) + }, + onImport (parsed) { + if (parsed._pleroma_theme_version === 1) { + this.normalizeLocalState(parsed, 1) + } else if (parsed._pleroma_theme_version === 2) { + this.normalizeLocalState(parsed.theme, 2) + } + }, + importValidator (parsed) { + const version = parsed._pleroma_theme_version + return version >= 1 || version <= 2 + }, + clearAll () { + const state = this.$store.state.config.customTheme + const version = state.colors ? 2 : 'l1' + this.normalizeLocalState(this.$store.state.config.customTheme, version) + }, + + // Clears all the extra stuff when loading V1 theme + clearV1 () { + Object.keys(this.$data) + .filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal')) + .filter(_ => !v1OnlyNames.includes(_)) + .forEach(key => { + set(this.$data, key, undefined) + }) + }, + + clearRoundness () { + Object.keys(this.$data) + .filter(_ => _.endsWith('RadiusLocal')) + .forEach(key => { + set(this.$data, key, undefined) + }) + }, + + clearOpacity () { + Object.keys(this.$data) + .filter(_ => _.endsWith('OpacityLocal')) + .forEach(key => { + set(this.$data, key, undefined) + }) + }, + + clearShadows () { + this.shadowsLocal = {} + }, + + clearFonts () { + this.fontsLocal = {} + }, + + /** + * This applies stored theme data onto form. Supports three versions of data: + * v2 (version = 2) - newer version of themes. + * v1 (version = 1) - older version of themes (import from file) + * v1l (version = l1) - older version of theme (load from local storage) + * v1 and v1l differ because of way themes were stored/exported. + * @param {Object} input - input data + * @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type + */ + normalizeLocalState (input, version = 0) { + const colors = input.colors || input + const radii = input.radii || input + const opacity = input.opacity + const shadows = input.shadows || {} + const fonts = input.fonts || {} + + if (version === 0) { + if (input.version) version = input.version + // Old v1 naming: fg is text, btn is foreground + if (typeof colors.text === 'undefined' && typeof colors.fg !== 'undefined') { + version = 1 + } + // New v2 naming: text is text, fg is foreground + if (typeof colors.text !== 'undefined' && typeof colors.fg !== 'undefined') { + version = 2 + } + } + + // Stuff that differs between V1 and V2 + if (version === 1) { + this.fgColorLocal = rgb2hex(colors.btn) + this.textColorLocal = rgb2hex(colors.fg) + } + + if (!this.keepColor) { + this.clearV1() + const keys = new Set(version !== 1 ? Object.keys(colors) : []) + if (version === 1 || version === 'l1') { + keys + .add('bg') + .add('link') + .add('cRed') + .add('cBlue') + .add('cGreen') + .add('cOrange') + } + + keys.forEach(key => { + this[key + 'ColorLocal'] = rgb2hex(colors[key]) + }) + } + + if (!this.keepRoundness) { + this.clearRoundness() + Object.entries(radii).forEach(([k, v]) => { + // 'Radius' is kept mostly for v1->v2 localstorage transition + const key = k.endsWith('Radius') ? k.split('Radius')[0] : k + this[key + 'RadiusLocal'] = v + }) + } + + if (!this.keepShadows) { + this.clearShadows() + this.shadowsLocal = shadows + this.shadowSelected = this.shadowsAvailable[0] + } + + if (!this.keepFonts) { + this.clearFonts() + this.fontsLocal = fonts + } + + if (opacity && !this.keepOpacity) { + this.clearOpacity() + Object.entries(opacity).forEach(([k, v]) => { + if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return + this[k + 'OpacityLocal'] = v + }) } } }, watch: { + currentRadii () { + try { + this.previewRadii = generateRadii({ radii: this.currentRadii }) + this.radiiInvalid = false + } catch (e) { + this.radiiInvalid = true + console.warn(e) + } + }, + shadowsLocal: { + handler () { + try { + this.previewShadows = generateShadows({ shadows: this.shadowsLocal }) + this.shadowsInvalid = false + } catch (e) { + this.shadowsInvalid = true + console.warn(e) + } + }, + deep: true + }, + fontsLocal: { + handler () { + try { + this.previewFonts = generateFonts({ fonts: this.fontsLocal }) + this.fontsInvalid = false + } catch (e) { + this.fontsInvalid = true + console.warn(e) + } + }, + deep: true + }, + currentColors () { + try { + this.previewColors = generateColors({ + opacity: this.currentOpacity, + colors: this.currentColors + }) + this.colorsInvalid = false + } catch (e) { + this.colorsInvalid = true + console.warn(e) + } + }, + currentOpacity () { + try { + this.previewColors = generateColors({ + opacity: this.currentOpacity, + colors: this.currentColors + }) + } catch (e) { + console.warn(e) + } + }, selected () { - this.bgColorLocal = this.selected[1] - this.btnColorLocal = this.selected[2] - this.textColorLocal = this.selected[3] - this.linkColorLocal = this.selected[4] - this.redColorLocal = this.selected[5] - this.greenColorLocal = this.selected[6] - this.blueColorLocal = this.selected[7] - this.orangeColorLocal = this.selected[8] + if (this.selectedVersion === 1) { + if (!this.keepRoundness) { + this.clearRoundness() + } + + if (!this.keepShadows) { + this.clearShadows() + } + + if (!this.keepOpacity) { + this.clearOpacity() + } + + if (!this.keepColor) { + this.clearV1() + + this.bgColorLocal = this.selected[1] + this.fgColorLocal = this.selected[2] + this.textColorLocal = this.selected[3] + this.linkColorLocal = this.selected[4] + this.cRedColorLocal = this.selected[5] + this.cGreenColorLocal = this.selected[6] + this.cBlueColorLocal = this.selected[7] + this.cOrangeColorLocal = this.selected[8] + } + } else if (this.selectedVersion >= 2) { + this.normalizeLocalState(this.selected.theme, 2) + } } } } diff --git a/src/components/style_switcher/style_switcher.scss b/src/components/style_switcher/style_switcher.scss new file mode 100644 index 00000000..135c113a --- /dev/null +++ b/src/components/style_switcher/style_switcher.scss @@ -0,0 +1,335 @@ +@import '../../_variables.scss'; +.style-switcher { + .preset-switcher { + margin-right: 1em; + } + + .style-control { + display: flex; + align-items: baseline; + margin-bottom: 5px; + + .label { + flex: 1; + } + + &.disabled { + input, select { + &:not(.exclude-disabled) { + opacity: .5 + } + } + } + + input, select { + min-width: 3em; + margin: 0; + flex: 0; + + &[type=color] { + padding: 1px; + cursor: pointer; + height: 29px; + min-width: 2em; + border: none; + align-self: stretch; + } + + &[type=number] { + min-width: 5em; + } + + &[type=range] { + flex: 1; + min-width: 3em; + } + + &[type=checkbox] + label { + margin: 6px 0; + } + + &:not([type=number]):not([type=text]) { + align-self: flex-start; + } + } + } + + .tab-switcher { + margin: 0 -1em; + } + + .reset-container { + flex-wrap: wrap; + } + + .fonts-container, + .reset-container, + .apply-container, + .radius-container, + .color-container, + { + display: flex; + } + + .fonts-container, + .radius-container { + flex-direction: column; + } + + .color-container{ + > h4 { + width: 99%; + } + flex-wrap: wrap; + justify-content: space-between; + } + + .fonts-container, + .color-container, + .shadow-container, + .radius-container, + .presets-container { + margin: 1em 1em 0; + } + + .tab-header { + display: flex; + justify-content: space-between; + align-items: baseline; + width: 100%; + min-height: 30px; + + .btn { + min-width: 1px; + flex: 0 auto; + padding: 0 1em; + } + + p { + flex: 1; + margin: 0; + margin-right: .5em; + } + + margin-bottom: 1em; + } + + .shadow-selector { + .override { + flex: 1; + margin-left: .5em; + } + .select-container { + margin-top: -4px; + margin-bottom: -3px; + } + } + + .save-load, + .save-load-options { + display: flex; + justify-content: center; + align-items: baseline; + flex-wrap: wrap; + + .presets, + .import-export { + margin-bottom: .5em; + } + + .import-export { + display: flex; + } + + .override { + margin-left: .5em; + } + } + + .save-load-options { + flex-wrap: wrap; + margin-top: .5em; + justify-content: center; + .keep-option { + margin: 0 .5em .5em; + min-width: 25%; + } + } + + .preview-container { + border-top: 1px dashed; + border-bottom: 1px dashed; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + margin: 1em -1em 0; + padding: 1em; + background: var(--body-background-image); + background-size: cover; + background-position: 50% 50%; + + .dummy { + .post { + font-family: var(--postFont); + display: flex; + + .content { + flex: 1; + + h4 { + margin-bottom: .25em; + } + + .icons { + margin-top: .5em; + display: flex; + + i { + margin-right: 1em; + } + } + } + } + + .after-post { + margin-top: 1em; + display: flex; + align-items: center; + } + + .avatar, .avatar-alt{ + background: linear-gradient(135deg, #b8e1fc 0%,#a9d2f3 10%,#90bae4 25%,#90bcea 37%,#90bff0 50%,#6ba8e5 51%,#a2daf5 83%,#bdf3fd 100%); + color: black; + font-family: sans-serif; + text-align: center; + margin-right: 1em; + } + + .avatar-alt { + flex: 0 auto; + margin-left: 28px; + font-size: 12px; + min-width: 20px; + min-height: 20px; + line-height: 20px; + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } + + .avatar { + flex: 0 auto; + width: 48px; + height: 48px; + font-size: 14px; + line-height: 48px; + } + + .actions { + display: flex; + align-items: baseline; + + .checkbox { + display: inline-flex; + align-items: baseline; + margin-right: 1em; + flex: 1; + } + } + + .separator { + margin: 1em; + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + } + + .panel-heading { + .badge, .alert, .btn, .faint { + margin-left: 1em; + white-space: nowrap; + } + .faint { + text-overflow: ellipsis; + min-width: 2em; + overflow-x: hidden; + } + .flex-spacer { + flex: 1; + } + } + .btn { + margin-left: 0; + padding: 0 1em; + min-width: 3em; + min-height: 30px; + } + } + } + + .apply-container { + justify-content: center; + } + + .radius-item, + .color-item { + min-width: 20em; + margin: 5px 6px 0 0; + display:flex; + flex-direction: column; + flex: 1 1 0; + + &.wide { + min-width: 60% + } + + &:not(.wide):nth-child(2n+1) { + margin-right: 7px; + + } + + .color, .opacity { + display:flex; + align-items: baseline; + } + } + + .radius-item { + flex-basis: auto; + } + + .theme-radius-rn, + .theme-color-cl { + border: 0; + box-shadow: none; + background: transparent; + color: var(--faint, $fallback--faint); + align-self: stretch; + } + + .theme-color-cl, + .theme-radius-in, + .theme-color-in { + margin-left: 4px; + } + + .theme-radius-in { + min-width: 1em; + } + + .theme-radius-in { + max-width: 7em; + flex: 1; + } + + .theme-radius-lb{ + max-width: 50em; + } + + .theme-preview-content { + padding: 20px; + } + + .btn { + margin-left: .25em; + margin-right: .25em; + } +} diff --git a/src/components/style_switcher/style_switcher.vue b/src/components/style_switcher/style_switcher.vue index 9c39b245..84963c81 100644 --- a/src/components/style_switcher/style_switcher.vue +++ b/src/components/style_switcher/style_switcher.vue @@ -1,231 +1,276 @@ <template> - <div> - <div>{{$t('settings.presets')}} - <label for="style-switcher" class='select'> - <select id="style-switcher" v-model="selected" class="style-switcher"> - <option v-for="style in availableStyles" :value="style">{{style[0]}}</option> - </select> - <i class="icon-down-open"/> - </label> +<div class="style-switcher"> + <div class="presets-container"> + <div class="save-load"> + <export-import + :exportObject='exportedTheme' + :exportLabel='$t("settings.export_theme")' + :importLabel='$t("settings.import_theme")' + :importFailedText='$t("settings.invalid_theme_imported")' + :onImport='onImport' + :validator='importValidator'> + <template slot="before"> + <div class="presets"> + {{$t('settings.presets')}} + <label for="preset-switcher" class='select'> + <select id="preset-switcher" v-model="selected" class="preset-switcher"> + <option v-for="style in availableStyles" + :value="style" + :style="{ + backgroundColor: style[1] || style.theme.colors.bg, + color: style[3] || style.theme.colors.text + }"> + {{style[0] || style.name}} + </option> + </select> + <i class="icon-down-open"/> + </label> + </div> + </template> + </export-import> </div> - <div class="color-container"> - <p>{{$t('settings.theme_help')}}</p> - <div class="color-item"> - <label for="bgcolor" class="theme-color-lb">{{$t('settings.background')}}</label> - <input id="bgcolor" class="theme-color-cl" type="color" v-model="bgColorLocal"> - <input id="bgcolor-t" class="theme-color-in" type="text" v-model="bgColorLocal"> - </div> - <div class="color-item"> - <label for="fgcolor" class="theme-color-lb">{{$t('settings.foreground')}}</label> - <input id="fgcolor" class="theme-color-cl" type="color" v-model="btnColorLocal"> - <input id="fgcolor-t" class="theme-color-in" type="text" v-model="btnColorLocal"> - </div> - <div class="color-item"> - <label for="textcolor" class="theme-color-lb">{{$t('settings.text')}}</label> - <input id="textcolor" class="theme-color-cl" type="color" v-model="textColorLocal"> - <input id="textcolor-t" class="theme-color-in" type="text" v-model="textColorLocal"> - </div> - <div class="color-item"> - <label for="linkcolor" class="theme-color-lb">{{$t('settings.links')}}</label> - <input id="linkcolor" class="theme-color-cl" type="color" v-model="linkColorLocal"> - <input id="linkcolor-t" class="theme-color-in" type="text" v-model="linkColorLocal"> - </div> - <div class="color-item"> - <label for="redcolor" class="theme-color-lb">{{$t('settings.cRed')}}</label> - <input id="redcolor" class="theme-color-cl" type="color" v-model="redColorLocal"> - <input id="redcolor-t" class="theme-color-in" type="text" v-model="redColorLocal"> - </div> - <div class="color-item"> - <label for="bluecolor" class="theme-color-lb">{{$t('settings.cBlue')}}</label> - <input id="bluecolor" class="theme-color-cl" type="color" v-model="blueColorLocal"> - <input id="bluecolor-t" class="theme-color-in" type="text" v-model="blueColorLocal"> - </div> - <div class="color-item"> - <label for="greencolor" class="theme-color-lb">{{$t('settings.cGreen')}}</label> - <input id="greencolor" class="theme-color-cl" type="color" v-model="greenColorLocal"> - <input id="greencolor-t" class="theme-color-in" type="green" v-model="greenColorLocal"> - </div> - <div class="color-item"> - <label for="orangecolor" class="theme-color-lb">{{$t('settings.cOrange')}}</label> - <input id="orangecolor" class="theme-color-cl" type="color" v-model="orangeColorLocal"> - <input id="orangecolor-t" class="theme-color-in" type="text" v-model="orangeColorLocal"> - </div> + <div class="save-load-options"> + <span class="keep-option"> + <input + id="keep-color" + type="checkbox" + v-model="keepColor"> + <label for="keep-color">{{$t('settings.style.switcher.keep_color')}}</label> + </span> + <span class="keep-option"> + <input + id="keep-shadows" + type="checkbox" + v-model="keepShadows"> + <label for="keep-shadows">{{$t('settings.style.switcher.keep_shadows')}}</label> + </span> + <span class="keep-option"> + <input + id="keep-opacity" + type="checkbox" + v-model="keepOpacity"> + <label for="keep-opacity">{{$t('settings.style.switcher.keep_opacity')}}</label> + </span> + <span class="keep-option"> + <input + id="keep-roundness" + type="checkbox" + v-model="keepRoundness"> + <label for="keep-roundness">{{$t('settings.style.switcher.keep_roundness')}}</label> + </span> + <span class="keep-option"> + <input + id="keep-fonts" + type="checkbox" + v-model="keepFonts"> + <label for="keep-fonts">{{$t('settings.style.switcher.keep_fonts')}}</label> + </span> + <p>{{$t('settings.style.switcher.save_load_hint')}}</p> </div> - <div class="radius-container"> - <p>{{$t('settings.radii_help')}}</p> - <div class="radius-item"> - <label for="btnradius" class="theme-radius-lb">{{$t('settings.btnRadius')}}</label> - <input id="btnradius" class="theme-radius-rn" type="range" v-model="btnRadiusLocal" max="16"> - <input id="btnradius-t" class="theme-radius-in" type="text" v-model="btnRadiusLocal"> - </div> - <div class="radius-item"> - <label for="panelradius" class="theme-radius-lb">{{$t('settings.panelRadius')}}</label> - <input id="panelradius" class="theme-radius-rn" type="range" v-model="panelRadiusLocal" max="50"> - <input id="panelradius-t" class="theme-radius-in" type="text" v-model="panelRadiusLocal"> - </div> - <div class="radius-item"> - <label for="avatarradius" class="theme-radius-lb">{{$t('settings.avatarRadius')}}</label> - <input id="avatarradius" class="theme-radius-rn" type="range" v-model="avatarRadiusLocal" max="28"> - <input id="avatarradius-t" class="theme-radius-in" type="green" v-model="avatarRadiusLocal"> - </div> - <div class="radius-item"> - <label for="avataraltradius" class="theme-radius-lb">{{$t('settings.avatarAltRadius')}}</label> - <input id="avataraltradius" class="theme-radius-rn" type="range" v-model="avatarAltRadiusLocal" max="28"> - <input id="avataraltradius-t" class="theme-radius-in" type="text" v-model="avatarAltRadiusLocal"> + </div> + + <div class="preview-container"> + <preview :style="previewRules"/> + </div> + + <keep-alive> + <tab-switcher key="style-tweak"> + <div :label="$t('settings.style.common_colors._tab_label')" class="color-container"> + <div class="tab-header"> + <p>{{$t('settings.theme_help')}}</p> + <button class="btn" @click="clearOpacity">{{$t('settings.style.switcher.clear_opacity')}}</button> + <button class="btn" @click="clearV1">{{$t('settings.style.switcher.clear_all')}}</button> + </div> + <p>{{$t('settings.theme_help_v2_1')}}</p> + <h4>{{ $t('settings.style.common_colors.main') }}</h4> + <div class="color-item"> + <ColorInput name="bgColor" v-model="bgColorLocal" :label="$t('settings.background')"/> + <OpacityInput name="bgOpacity" v-model="bgOpacityLocal" :fallback="previewTheme.opacity.bg || 1"/> + <ColorInput name="textColor" v-model="textColorLocal" :label="$t('settings.text')"/> + <ContrastRatio :contrast="previewContrast.bgText"/> + <ColorInput name="linkColor" v-model="linkColorLocal" :label="$t('settings.links')"/> + <ContrastRatio :contrast="previewContrast.bgLink"/> + </div> + <div class="color-item"> + <ColorInput name="fgColor" v-model="fgColorLocal" :label="$t('settings.foreground')"/> + <ColorInput name="fgTextColor" v-model="fgTextColorLocal" :label="$t('settings.text')" :fallback="previewTheme.colors.fgText"/> + <ColorInput name="fgLinkColor" v-model="fgLinkColorLocal" :label="$t('settings.links')" :fallback="previewTheme.colors.fgLink"/> + <p>{{ $t('settings.style.common_colors.foreground_hint') }}</p> + </div> + <h4>{{ $t('settings.style.common_colors.rgbo') }}</h4> + <div class="color-item"> + <ColorInput name="cRedColor" v-model="cRedColorLocal" :label="$t('settings.cRed')"/> + <ContrastRatio :contrast="previewContrast.bgRed"/> + <ColorInput name="cBlueColor" v-model="cBlueColorLocal" :label="$t('settings.cBlue')"/> + <ContrastRatio :contrast="previewContrast.bgBlue"/> + </div> + <div class="color-item"> + <ColorInput name="cGreenColor" v-model="cGreenColorLocal" :label="$t('settings.cGreen')"/> + <ContrastRatio :contrast="previewContrast.bgGreen"/> + <ColorInput name="cOrangeColor" v-model="cOrangeColorLocal" :label="$t('settings.cOrange')"/> + <ContrastRatio :contrast="previewContrast.bgOrange"/> + </div> + <p>{{$t('settings.theme_help_v2_2')}}</p> </div> - <div class="radius-item"> - <label for="attachmentradius" class="theme-radius-lb">{{$t('settings.attachmentRadius')}}</label> - <input id="attachmentrradius" class="theme-radius-rn" type="range" v-model="attachmentRadiusLocal" max="50"> - <input id="attachmentradius-t" class="theme-radius-in" type="text" v-model="attachmentRadiusLocal"> + + <div :label="$t('settings.style.advanced_colors._tab_label')" class="color-container"> + <div class="tab-header"> + <p>{{$t('settings.theme_help')}}</p> + <button class="btn" @click="clearOpacity">{{$t('settings.style.switcher.clear_opacity')}}</button> + <button class="btn" @click="clearV1">{{$t('settings.style.switcher.clear_all')}}</button> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.alert') }}</h4> + <ColorInput name="alertError" v-model="alertErrorColorLocal" :label="$t('settings.style.advanced_colors.alert_error')" :fallback="previewTheme.colors.alertError"/> + <ContrastRatio :contrast="previewContrast.alertError"/> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.badge') }}</h4> + <ColorInput name="badgeNotification" v-model="badgeNotificationColorLocal" :label="$t('settings.style.advanced_colors.badge_notification')" :fallback="previewTheme.colors.badgeNotification"/> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.panel_header') }}</h4> + <ColorInput name="panelColor" v-model="panelColorLocal" :fallback="fgColorLocal" :label="$t('settings.background')"/> + <OpacityInput name="panelOpacity" v-model="panelOpacityLocal" :fallback="previewTheme.opacity.panel || 1"/> + <ColorInput name="panelTextColor" v-model="panelTextColorLocal" :fallback="previewTheme.colors.panelText" :label="$t('settings.text')"/> + <ContrastRatio :contrast="previewContrast.panelText" large="1"/> + <ColorInput name="panelLinkColor" v-model="panelLinkColorLocal" :fallback="previewTheme.colors.panelLink" :label="$t('settings.links')"/> + <ContrastRatio :contrast="previewContrast.panelLink" large="1"/> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.top_bar') }}</h4> + <ColorInput name="topBarColor" v-model="topBarColorLocal" :fallback="fgColorLocal" :label="$t('settings.background')"/> + <ColorInput name="topBarTextColor" v-model="topBarTextColorLocal" :fallback="previewTheme.colors.topBarText" :label="$t('settings.text')"/> + <ContrastRatio :contrast="previewContrast.topBarText"/> + <ColorInput name="topBarLinkColor" v-model="topBarLinkColorLocal" :fallback="previewTheme.colors.topBarLink" :label="$t('settings.links')"/> + <ContrastRatio :contrast="previewContrast.topBarLink"/> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.inputs') }}</h4> + <ColorInput name="inputColor" v-model="inputColorLocal" :fallback="fgColorLocal" :label="$t('settings.background')"/> + <OpacityInput name="inputOpacity" v-model="inputOpacityLocal" :fallback="previewTheme.opacity.input || 1"/> + <ColorInput name="inputTextColor" v-model="inputTextColorLocal" :fallback="previewTheme.colors.inputText" :label="$t('settings.text')"/> + <ContrastRatio :contrast="previewContrast.inputText"/> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.buttons') }}</h4> + <ColorInput name="btnColor" v-model="btnColorLocal" :fallback="fgColorLocal" :label="$t('settings.background')"/> + <OpacityInput name="btnOpacity" v-model="btnOpacityLocal" :fallback="previewTheme.opacity.btn || 1"/> + <ColorInput name="btnTextColor" v-model="btnTextColorLocal" :fallback="previewTheme.colors.btnText" :label="$t('settings.text')"/> + <ContrastRatio :contrast="previewContrast.btnText"/> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.borders') }}</h4> + <ColorInput name="borderColor" v-model="borderColorLocal" :fallback="previewTheme.colors.border" :label="$t('settings.style.common.color')"/> + <OpacityInput name="borderOpacity" v-model="borderOpacityLocal" :fallback="previewTheme.opacity.border || 1"/> + </div> + <div class="color-item"> + <h4>{{ $t('settings.style.advanced_colors.faint_text') }}</h4> + <ColorInput name="faintColor" v-model="faintColorLocal" :fallback="previewTheme.colors.faint || 1" :label="$t('settings.text')"/> + <ColorInput name="faintLinkColor" v-model="faintLinkColorLocal" :fallback="previewTheme.colors.faintLink" :label="$t('settings.links')"/> + <ColorInput name="panelFaintColor" v-model="panelFaintColorLocal" :fallback="previewTheme.colors.panelFaint" :label="$t('settings.style.advanced_colors.panel_header')"/> + <OpacityInput name="faintOpacity" v-model="faintOpacityLocal" :fallback="previewTheme.opacity.faint || 0.5"/> + </div> </div> - <div class="radius-item"> - <label for="tooltipradius" class="theme-radius-lb">{{$t('settings.tooltipRadius')}}</label> - <input id="tooltipradius" class="theme-radius-rn" type="range" v-model="tooltipRadiusLocal" max="20"> - <input id="tooltipradius-t" class="theme-radius-in" type="text" v-model="tooltipRadiusLocal"> + + <div :label="$t('settings.style.radii._tab_label')" class="radius-container"> + <div class="tab-header"> + <p>{{$t('settings.radii_help')}}</p> + <button class="btn" @click="clearRoundness">{{$t('settings.style.switcher.clear_all')}}</button> + </div> + <RangeInput name="btnRadius" :label="$t('settings.btnRadius')" v-model="btnRadiusLocal" :fallback="previewTheme.radii.btn" max="16" hardMin="0"/> + <RangeInput name="inputRadius" :label="$t('settings.inputRadius')" v-model="inputRadiusLocal" :fallback="previewTheme.radii.input" max="9" hardMin="0"/> + <RangeInput name="checkboxRadius" :label="$t('settings.checkboxRadius')" v-model="checkboxRadiusLocal" :fallback="previewTheme.radii.checkbox" max="16" hardMin="0"/> + <RangeInput name="panelRadius" :label="$t('settings.panelRadius')" v-model="panelRadiusLocal" :fallback="previewTheme.radii.panel" max="50" hardMin="0"/> + <RangeInput name="avatarRadius" :label="$t('settings.avatarRadius')" v-model="avatarRadiusLocal" :fallback="previewTheme.radii.avatar" max="28" hardMin="0"/> + <RangeInput name="avatarAltRadius" :label="$t('settings.avatarAltRadius')" v-model="avatarAltRadiusLocal" :fallback="previewTheme.radii.avatarAlt" max="28" hardMin="0"/> + <RangeInput name="attachmentRadius" :label="$t('settings.attachmentRadius')" v-model="attachmentRadiusLocal" :fallback="previewTheme.radii.attachment" max="50" hardMin="0"/> + <RangeInput name="tooltipRadius" :label="$t('settings.tooltipRadius')" v-model="tooltipRadiusLocal" :fallback="previewTheme.radii.tooltip" max="50" hardMin="0"/> </div> - </div> - <div :style="{ - '--btnRadius': btnRadiusLocal + 'px', - '--panelRadius': panelRadiusLocal + 'px', - '--avatarRadius': avatarRadiusLocal + 'px', - '--avatarAltRadius': avatarAltRadiusLocal + 'px', - '--tooltipRadius': tooltipRadiusLocal + 'px', - '--attachmentRadius': attachmentRadiusLocal + 'px' - }"> - <div class="panel dummy"> - <div class="panel-heading" :style="{ 'background-color': btnColorLocal, 'color': textColorLocal }">Preview</div> - <div class="panel-body theme-preview-content" :style="{ 'background-color': bgColorLocal, 'color': textColorLocal }"> - <div class="avatar" :style="{ - 'border-radius': avatarRadiusLocal + 'px' - }"> - ( ͡° ͜ʖ ͡°) + + <div :label="$t('settings.style.shadows._tab_label')" class="shadow-container"> + <div class="tab-header shadow-selector"> + <div class="select-container"> + {{$t('settings.style.shadows.component')}} + <label for="shadow-switcher" class="select"> + <select id="shadow-switcher" v-model="shadowSelected" class="shadow-switcher"> + <option v-for="shadow in shadowsAvailable" + :value="shadow"> + {{$t('settings.style.shadows.components.' + shadow)}} + </option> + </select> + <i class="icon-down-open"/> + </label> + </div> + <div class="override"> + <label for="override" class="label"> + {{$t('settings.style.shadows.override')}} + </label> + <input + v-model="currentShadowOverriden" + name="override" + id="override" + class="input-override" + type="checkbox"> + <label class="checkbox-label" for="override"></label> </div> - <h4>Content</h4> - <br> - A bunch of more content and - <a :style="{ color: linkColorLocal }">a nice lil' link</a> - <i :style="{ color: blueColorLocal }" class="icon-reply"/> - <i :style="{ color: greenColorLocal }" class="icon-retweet"/> - <i :style="{ color: redColorLocal }" class="icon-cancel"/> - <i :style="{ color: orangeColorLocal }" class="icon-star"/> - <br> - <button class="btn" :style="{ 'background-color': btnColorLocal, 'color': textColorLocal }">Button</button> + <button class="btn" @click="clearShadows">{{$t('settings.style.switcher.clear_all')}}</button> + </div> + <shadow-control :ready="!!currentShadowFallback" :fallback="currentShadowFallback" v-model="currentShadow"/> + <div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"> + <i18n path="settings.style.shadows.filter_hint.always_drop_shadow" tag="p"> + <code>filter: drop-shadow()</code> + </i18n> + <p>{{$t('settings.style.shadows.filter_hint.avatar_inset')}}</p> + <i18n path="settings.style.shadows.filter_hint.drop_shadow_syntax" tag="p"> + <code>drop-shadow</code> + <code>spread-radius</code> + <code>inset</code> + </i18n> + <i18n path="settings.style.shadows.filter_hint.inset_classic" tag="p"> + <code>box-shadow</code> + </i18n> + <p>{{$t('settings.style.shadows.filter_hint.spread_zero')}}</p> </div> </div> - </div> - <button class="btn" @click="setCustomTheme">{{$t('general.apply')}}</button> + + <div :label="$t('settings.style.fonts._tab_label')" class="fonts-container"> + <div class="tab-header"> + <p>{{$t('settings.style.fonts.help')}}</p> + <button class="btn" @click="clearFonts">{{$t('settings.style.switcher.clear_all')}}</button> + </div> + <FontControl + name="ui" + v-model="fontsLocal.interface" + :label="$t('settings.style.fonts.components.interface')" + :fallback="previewTheme.fonts.interface" + no-inherit="1"/> + <FontControl + name="input" + v-model="fontsLocal.input" + :label="$t('settings.style.fonts.components.input')" + :fallback="previewTheme.fonts.input"/> + <FontControl + name="post" + v-model="fontsLocal.post" + :label="$t('settings.style.fonts.components.post')" + :fallback="previewTheme.fonts.post"/> + <FontControl + name="postCode" + v-model="fontsLocal.postCode" + :label="$t('settings.style.fonts.components.postCode')" + :fallback="previewTheme.fonts.postCode"/> + </div> + </tab-switcher> + </keep-alive> + + <div class="apply-container"> + <button class="btn submit" :disabled="!themeValid" @click="setCustomTheme">{{$t('general.apply')}}</button> + <button class="btn" @click="clearAll">{{$t('settings.style.switcher.reset')}}</button> </div> +</div> </template> <script src="./style_switcher.js"></script> -<style lang="scss"> -@import '../../_variables.scss'; -.style-switcher { - margin-right: 1em; -} - -.radius-container, -.color-container { - display: flex; - - p { - margin-top: 2em; - margin-bottom: .5em; - } -} -.radius-container { - flex-direction: column; -} - -.color-container { - flex-wrap: wrap; - justify-content: space-between; -} - -.radius-item, -.color-item { - min-width: 20em; - display:flex; - flex: 1 1 0; - align-items: baseline; - margin: 5px 6px 5px 0; - - label { - color: var(--faint, $fallback--faint); - } -} - -.radius-item { - flex-basis: auto; -} - -.theme-radius-rn, -.theme-color-cl { - border: 0; - box-shadow: none; - background: transparent; - color: var(--faint, $fallback--faint); - align-self: stretch; -} - -.theme-color-cl, -.theme-radius-in, -.theme-color-in { - margin-left: 4px; -} - -.theme-color-in { - min-width: 4em; -} - -.theme-radius-in { - min-width: 1em; -} - -.theme-radius-in, -.theme-color-in { - max-width: 7em; - flex: 1; -} - -.theme-radius-lb, -.theme-color-lb { - flex: 2; - min-width: 7em; -} - -.theme-radius-lb{ - max-width: 50em; -} - -.theme-color-lb { - max-width: 10em; -} - -.theme-color-cl { - padding: 1px; - max-width: 8em; - height: 100%; - flex: 0; - min-width: 2em; - cursor: pointer; -} - -.theme-preview-content { - padding: 20px; -} - -.dummy { - .avatar { - background: linear-gradient(135deg, #b8e1fc 0%,#a9d2f3 10%,#90bae4 25%,#90bcea 37%,#90bff0 50%,#6ba8e5 51%,#a2daf5 83%,#bdf3fd 100%); - color: black; - text-align: center; - height: 48px; - line-height: 48px; - width: 48px; - float: left; - margin-right: 1em; - } -} -</style> +<style src="./style_switcher.scss" lang="scss"></style> diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx new file mode 100644 index 00000000..9e3dee04 --- /dev/null +++ b/src/components/tab_switcher/tab_switcher.jsx @@ -0,0 +1,47 @@ +import Vue from 'vue' + +import './tab_switcher.scss' + +export default Vue.component('tab-switcher', { + name: 'TabSwitcher', + data () { + return { + active: 0 + } + }, + methods: { + activateTab(index) { + return () => this.active = index; + } + }, + render(h) { + const tabs = this.$slots.default + .filter(slot => slot.data) + .map((slot, index) => { + const classes = ['tab'] + + if (index === this.active) { + classes.push('active') + } + return (<button onClick={this.activateTab(index)} class={ classes.join(' ') }>{slot.data.attrs.label}</button>) + }); + const contents = this.$slots.default.filter(_=>_.data).map(( slot, index ) => { + const active = index === this.active + return ( + <div class={active ? 'active' : 'hidden'}> + {slot} + </div> + ) + }); + return ( + <div class="tab-switcher"> + <div class="tabs"> + {tabs} + </div> + <div class="contents"> + {contents} + </div> + </div> + ) + } +}) diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss new file mode 100644 index 00000000..fbd3321b --- /dev/null +++ b/src/components/tab_switcher/tab_switcher.scss @@ -0,0 +1,65 @@ +@import '../../_variables.scss'; + +.tab-switcher { + .contents { + .hidden { + display: none; + } + } + .tabs { + display: flex; + position: relative; + justify-content: center; + width: 100%; + overflow-y: hidden; + overflow-x: auto; + padding-top: 5px; + height: 32px; + box-sizing: border-box; + + &::after, &::before { + display: block; + content: ''; + flex: 1 1 auto; + } + + .tab, &::after, &::before { + border-bottom: 1px solid; + border-bottom-color: $fallback--border; + border-bottom-color: var(--border, $fallback--border); + } + + .tab { + position: relative; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + padding: 5px 1em 99px; + white-space: nowrap; + + &:not(.active) { + z-index: 4; + + &:hover { + z-index: 6; + } + + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 26px; + border-bottom: 1px solid; + border-bottom-color: $fallback--border; + border-bottom-color: var(--border, $fallback--border); + } + } + + &.active { + background: transparent; + border-bottom: none; + z-index: 5; + } + } + } +} diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 74ab85d3..f28b85bd 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -2,6 +2,7 @@ import Status from '../status/status.vue' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue' import UserCard from '../user_card/user_card.vue' +import { throttle } from 'lodash' const Timeline = { props: [ @@ -13,7 +14,8 @@ const Timeline = { ], data () { return { - paused: false + paused: false, + unfocused: false } }, computed: { @@ -65,8 +67,15 @@ const Timeline = { this.fetchFollowers() } }, + mounted () { + if (typeof document.hidden !== 'undefined') { + document.addEventListener('visibilitychange', this.handleVisibilityChange, false) + this.unfocused = document.hidden + } + }, destroyed () { window.removeEventListener('scroll', this.scrollLoad) + if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) }, methods: { @@ -80,7 +89,7 @@ const Timeline = { this.paused = false } }, - fetchOlderStatuses () { + fetchOlderStatuses: throttle(function () { const store = this.$store const credentials = store.state.users.currentUser.credentials store.commit('setLoading', { timeline: this.timelineName, value: true }) @@ -93,7 +102,7 @@ const Timeline = { userId: this.userId, tag: this.tag }).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false })) - }, + }, 1000, this), fetchFollowers () { const id = this.userId this.$store.state.api.backendInteractor.fetchFollowers({ id }) @@ -105,13 +114,17 @@ const Timeline = { .then((friends) => this.$store.dispatch('addFriends', { friends })) }, scrollLoad (e) { - const height = Math.max(document.body.offsetHeight, document.body.scrollHeight) + const bodyBRect = document.body.getBoundingClientRect() + const height = Math.max(bodyBRect.height, -(bodyBRect.y)) if (this.timeline.loading === false && this.$store.state.config.autoLoad && this.$el.offsetHeight > 0 && (window.innerHeight + window.pageYOffset) >= (height - 750)) { this.fetchOlderStatuses() } + }, + handleVisibilityChange () { + this.unfocused = document.hidden } }, watch: { @@ -121,7 +134,10 @@ const Timeline = { } if (count > 0) { // only 'stream' them when you're scrolled to the top - if (window.pageYOffset < 15 && !this.paused) { + if (window.pageYOffset < 15 && + !this.paused && + !(this.unfocused && this.$store.state.config.pauseOnUnfocused) + ) { this.showNewStatuses() } else { this.paused = true diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index c4e0fbce..bc7f74c2 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -4,13 +4,13 @@ <div class="title"> {{title}} </div> - <button @click.prevent="showNewStatuses" class="loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError"> - {{$t('timeline.show_new')}}{{newStatusCountStr}} - </button> <div @click.prevent class="loadmore-error alert error" v-if="timelineError"> {{$t('timeline.error_fetching')}} </div> - <div @click.prevent class="loadmore-text" v-if="!timeline.newStatusCount > 0 && !timelineError"> + <button @click.prevent="showNewStatuses" class="loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError"> + {{$t('timeline.show_new')}}{{newStatusCountStr}} + </button> + <div @click.prevent class="loadmore-text faint" v-if="!timeline.newStatusCount > 0 && !timelineError"> {{$t('timeline.up_to_date')}} </div> </div> @@ -57,53 +57,8 @@ @import '../../_variables.scss'; .timeline { - .timeline-heading { - position: relative; - display: flex; - } - - .title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 70%; - } - - .loadmore-button { - position: absolute; - right: 0.6em; - font-size: 14px; - - min-width: 6em; - height: 1.8em; - line-height: 100%; - } - .loadmore-text { - position: absolute; - right: 0.6em; - font-size: 14px; - min-width: 6em; - font-family: sans-serif; - text-align: center; - padding: 0 0.5em 0 0.5em; - opacity: 0.8; - background-color: transparent; - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - - .loadmore-error { - position: absolute; - right: 0.6em; - font-size: 14px; - min-width: 6em; - font-family: sans-serif; - text-align: center; - padding: 0 0.25em 0 0.25em; - margin: 0; - color: $fallback--fg; - color: var(--fg, $fallback--fg); + opacity: 1; } } @@ -116,7 +71,7 @@ border-color: var(--border, $fallback--border); padding: 10px; z-index: 1; - background-color: $fallback--btn; - background-color: var(--btn, $fallback--btn); + background-color: $fallback--fg; + background-color: var(--panel, $fallback--fg); } </style> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index a7a871c3..a019627a 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -3,7 +3,8 @@ import UserCardContent from '../user_card_content/user_card_content.vue' const UserCard = { props: [ 'user', - 'showFollows' + 'showFollows', + 'showApproval' ], data () { return { @@ -16,6 +17,14 @@ const UserCard = { methods: { toggleUserExpanded () { this.userExpanded = !this.userExpanded + }, + approveUser () { + this.$store.state.api.backendInteractor.approveUser(this.user.id) + this.$store.dispatch('removeFollowRequest', this.user) + }, + denyUser () { + this.$store.state.api.backendInteractor.denyUser(this.user.id) + this.$store.dispatch('removeFollowRequest', this.user) } } } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 51d6965f..5a8e5531 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -7,13 +7,25 @@ <user-card-content :user="user" :switcher="false"></user-card-content> </div> <div class="name-and-screen-name" v-else> - <div :title="user.name" class="user-name"> + <div :title="user.name" v-if="user.name_html" class="user-name"> + <span v-html="user.name_html"></span> + <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> + {{ $t('user_card.follows_you') }} + </span> + </div> + <div :title="user.name" v-else class="user-name"> {{ user.name }} <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> - {{ $t('user_card.follows_you') }} + {{ $t('user_card.follows_you') }} </span> </div> - <a :href="user.statusnet_profile_url" target="blank"><div class="user-screen-name">@{{ user.screen_name }}</div></a> + <router-link class='user-screen-name' :to="{ name: 'user-profile', params: { id: user.id } }"> + @{{user.screen_name}} + </router-link> + </div> + <div class="approval" v-if="showApproval"> + <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> + <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> </div> </div> </template> @@ -63,16 +75,25 @@ border-radius: var(--panelRadius, $fallback--panelRadius); border-style: solid; border-color: $fallback--border; - border-color: var(--border, $fallback--border); + border-color: var(--border, $fallback--border); border-width: 1px; overflow: hidden; .panel-heading { background: transparent; + flex-direction: column; + align-items: stretch; } p { margin-bottom: 0; } } + +.approval { + button { + width: 100%; + margin-bottom: 0.5em; + } +} </style> diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js index 1e8c91de..e7f19953 100644 --- a/src/components/user_card_content/user_card_content.js +++ b/src/components/user_card_content/user_card_content.js @@ -2,16 +2,30 @@ import StillImage from '../still-image/still-image.vue' import { hex2rgb } from '../../services/color_convert/color_convert.js' export default { - props: [ 'user', 'switcher', 'hideBio' ], + props: [ 'user', 'switcher', 'selected', 'hideBio', 'activatePanel' ], + data () { + return { + hideUserStatsLocal: typeof this.$store.state.config.hideUserStats === 'undefined' + ? this.$store.state.instance.hideUserStats + : this.$store.state.config.hideUserStats, + betterShadow: this.$store.state.interface.browserSupport.cssFilter + } + }, computed: { headingStyle () { - const color = this.$store.state.config.colors.bg + const color = this.$store.state.config.customTheme.colors + ? this.$store.state.config.customTheme.colors.bg // v2 + : this.$store.state.config.colors.bg // v1 + if (color) { - const rgb = hex2rgb(color) - console.log(rgb) + const rgb = (typeof color === 'string') ? hex2rgb(color) : color + const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)` return { - backgroundColor: `rgb(${Math.floor(rgb[0] * 0.53)}, ${Math.floor(rgb[1] * 0.56)}, ${Math.floor(rgb[2] * 0.59)})`, - backgroundImage: `url(${this.user.cover_photo})` + backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`, + backgroundImage: [ + `linear-gradient(to bottom, ${tintColor}, ${tintColor})`, + `url(${this.user.cover_photo})` + ].join(', ') } } }, @@ -29,6 +43,29 @@ export default { dailyAvg () { const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000)) return Math.round(this.user.statuses_count / days) + }, + userHighlightType: { + get () { + const data = this.$store.state.config.highlight[this.user.screen_name] + return data && data.type || 'disabled' + }, + set (type) { + const data = this.$store.state.config.highlight[this.user.screen_name] + if (type !== 'disabled') { + this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: data && data.color || '#FFFFFF', type }) + } else { + this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: undefined }) + } + } + }, + userHighlightColor: { + get () { + const data = this.$store.state.config.highlight[this.user.screen_name] + return data && data.color + }, + set (color) { + this.$store.dispatch('setHighlight', { user: this.user.screen_name, color }) + } } }, components: { @@ -61,8 +98,18 @@ export default { store.state.api.backendInteractor.setUserMute(this.user) }, setProfileView (v) { - const store = this.$store - store.commit('setProfileView', { v }) + if (this.switcher) { + const store = this.$store + store.commit('setProfileView', { v }) + } + }, + linkClicked ({target}) { + if (target.tagName === 'SPAN') { + target = target.parentNode + } + if (target.tagName === 'A') { + window.open(target.href, '_blank') + } } } } diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index ca8428ca..18504277 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -1,98 +1,114 @@ <template> - <div id="heading" class="profile-panel-background" :style="headingStyle"> - <div class="panel-heading text-center"> - <div class='user-info'> - <router-link to='/user-settings' style="float: right; margin-top:16px;" v-if="!isOtherUser"> - <i class="icon-cog usersettings"></i> +<div id="heading" class="profile-panel-background" :style="headingStyle"> + <div class="panel-heading text-center"> + <div class='user-info'> + <router-link @click.native="activatePanel && activatePanel('timeline')" to='/user-settings' style="float: right; margin-top:16px;" v-if="!isOtherUser"> + <i class="icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i> + </router-link> + <a :href="user.statusnet_profile_url" target="_blank" class="floater" v-if="isOtherUser"> + <i class="icon-link-ext usersettings"></i> + </a> + <div class='container'> + <router-link @click.native="activatePanel && activatePanel('timeline')" :to="{ name: 'user-profile', params: { id: user.id } }"> + <StillImage class="avatar" :class='{ "better-shadow": betterShadow }' :src="user.profile_image_url_original"/> </router-link> - <a :href="user.statusnet_profile_url" target="_blank" style="float: right; margin-top:16px;" v-if="isOtherUser"> - <i class="icon-link-ext usersettings"></i> - </a> - <div class='container'> - <router-link :to="{ name: 'user-profile', params: { id: user.id } }"> - <StillImage class="avatar" :src="user.profile_image_url_original"/> + <div class="name-and-screen-name"> + <div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div> + <div :title="user.name" class='user-name' v-else>{{user.name}}</div> + <router-link @click.native="activatePanel && activatePanel('timeline')" class='user-screen-name':to="{ name: 'user-profile', params: { id: user.id } }"> + <span>@{{user.screen_name}}</span><span v-if="user.locked"><i class="icon icon-lock"></i></span> + <span v-if="!hideUserStatsLocal" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span> </router-link> - <div class="name-and-screen-name"> - <div :title="user.name" class='user-name'>{{user.name}}</div> - <router-link :to="{ name: 'user-profile', params: { id: user.id } }"> - <div class='user-screen-name'>@{{user.screen_name}}</div> - </router-link> - </div> </div> - <div v-if="isOtherUser" class="user-interactions"> - <div v-if="user.follows_you && loggedIn" class="following"> - {{ $t('user_card.follows_you') }} - </div> - <div class="follow" v-if="loggedIn"> - <span v-if="user.following"> - <!--Following them!--> - <button @click="unfollowUser" class="pressed"> - {{ $t('user_card.following') }} - </button> - </span> - <span v-if="!user.following"> - <button @click="followUser"> - {{ $t('user_card.follow') }} - </button> - </span> - </div> - <div class='mute' v-if='isOtherUser'> - <span v-if='user.muted'> - <button @click="toggleMute" class="pressed"> - {{ $t('user_card.muted') }} - </button> - </span> - <span v-if='!user.muted'> - <button @click="toggleMute"> - {{ $t('user_card.mute') }} - </button> - </span> - </div> - <div class="remote-follow" v-if='!loggedIn && user.is_local'> - <form method="POST" :action='subscribeUrl'> - <input type="hidden" name="nickname" :value="user.screen_name"> - <input type="hidden" name="profile" value=""> - <button click="submit" class="remote-button"> - {{ $t('user_card.remote_follow') }} - </button> - </form> - </div> - <div class='block' v-if='isOtherUser && loggedIn'> - <span v-if='user.statusnet_blocking'> - <button @click="unblockUser" class="pressed"> - {{ $t('user_card.blocked') }} - </button> - </span> - <span v-if='!user.statusnet_blocking'> - <button @click="blockUser"> - {{ $t('user_card.block') }} - </button> - </span> - </div> + </div> + <div class="user-meta"> + <div v-if="user.follows_you && loggedIn && isOtherUser" class="following"> + {{ $t('user_card.follows_you') }} + </div> + <div class="floater" v-if="switcher || isOtherUser"> + <!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to --> + <input class="userHighlightText" type="text" :id="'userHighlightColorTx'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/> + <input class="userHighlightCl" type="color" :id="'userHighlightColor'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/> + <label for="style-switcher" class='userHighlightSel select'> + <select class="userHighlightSel" :id="'userHighlightSel'+user.id" v-model="userHighlightType"> + <option value="disabled">No highlight</option> + <option value="solid">Solid bg</option> + <option value="striped">Striped bg</option> + <option value="side">Side stripe</option> + </select> + <i class="icon-down-open"/> + </label> </div> </div> - </div> - <div class="panel-body profile-panel-body"> - <div class="user-counts"> - <div class="user-count"> - <a href="#" v-on:click.prevent="setProfileView('statuses')" v-if="switcher"><h5>{{ $t('user_card.statuses') }}</h5></a> - <h5 v-else>{{ $t('user_card.statuses') }}</h5> - <span>{{user.statuses_count}} <br><span class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span></span> + <div v-if="isOtherUser" class="user-interactions"> + <div class="follow" v-if="loggedIn"> + <span v-if="user.following"> + <!--Following them!--> + <button @click="unfollowUser" class="pressed"> + {{ $t('user_card.following') }} + </button> + </span> + <span v-if="!user.following"> + <button @click="followUser"> + {{ $t('user_card.follow') }} + </button> + </span> + </div> + <div class='mute' v-if='isOtherUser'> + <span v-if='user.muted'> + <button @click="toggleMute" class="pressed"> + {{ $t('user_card.muted') }} + </button> + </span> + <span v-if='!user.muted'> + <button @click="toggleMute"> + {{ $t('user_card.mute') }} + </button> + </span> </div> - <div class="user-count"> - <a href="#" v-on:click.prevent="setProfileView('friends')" v-if="switcher"><h5>{{ $t('user_card.followees') }}</h5></a> - <h5 v-else>{{ $t('user_card.followees') }}</h5> - <span>{{user.friends_count}}</span> + <div class="remote-follow" v-if='!loggedIn && user.is_local'> + <form method="POST" :action='subscribeUrl'> + <input type="hidden" name="nickname" :value="user.screen_name"> + <input type="hidden" name="profile" value=""> + <button click="submit" class="remote-button"> + {{ $t('user_card.remote_follow') }} + </button> + </form> </div> - <div class="user-count"> - <a href="#" v-on:click.prevent="setProfileView('followers')" v-if="switcher"><h5>{{ $t('user_card.followers') }}</h5></a> - <h5 v-else>{{ $t('user_card.followers') }}</h5> - <span>{{user.followers_count}}</span> + <div class='block' v-if='isOtherUser && loggedIn'> + <span v-if='user.statusnet_blocking'> + <button @click="unblockUser" class="pressed"> + {{ $t('user_card.blocked') }} + </button> + </span> + <span v-if='!user.statusnet_blocking'> + <button @click="blockUser"> + {{ $t('user_card.block') }} + </button> + </span> </div> </div> - <p v-if="!hideBio">{{user.description}}</p> </div> </div> + <div class="panel-body profile-panel-body" v-if="switcher"> + <div v-if="!hideUserStatsLocal || switcher" class="user-counts" :class="{clickable: switcher}"> + <div class="user-count" v-on:click.prevent="setProfileView('statuses')" :class="{selected: selected === 'statuses'}"> + <h5>{{ $t('user_card.statuses') }}</h5> + <span v-if="!hideUserStatsLocal">{{user.statuses_count}} <br></span> + </div> + <div class="user-count" v-on:click.prevent="setProfileView('friends')" :class="{selected: selected === 'friends'}"> + <h5>{{ $t('user_card.followees') }}</h5> + <span v-if="!hideUserStatsLocal">{{user.friends_count}}</span> + </div> + <div class="user-count" v-on:click.prevent="setProfileView('followers')" :class="{selected: selected === 'followers'}"> + <h5>{{ $t('user_card.followers') }}</h5> + <span v-if="!hideUserStatsLocal">{{user.followers_count}}</span> + </div> + </div> + <p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="profile-bio" v-html="user.description_html"></p> + <p v-else-if="!hideBio" class="profile-bio">{{ user.description }}</p> + </div> +</div> </template> <script src="./user_card_content.js"></script> @@ -104,31 +120,34 @@ background-size: cover; border-radius: $fallback--panelRadius; border-radius: var(--panelRadius, $fallback--panelRadius); + overflow: hidden; .panel-heading { padding: 0.6em 0em; text-align: center; + box-shadow: none; } } .profile-panel-body { - top: -0em; - padding-top: 4em; word-wrap: break-word; background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%); - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%) + background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%); + + .profile-bio { + text-align: center; + } } .user-info { - color: white; - padding: 0 16px 16px 16px; - margin-bottom: -4em; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + padding: 0 16px; .container { - padding: 16px 10px 4px 10px; + padding: 16px 10px 6px 10px; display: flex; max-height: 56px; - overflow: hidden; .avatar { border-radius: $fallback--avatarRadius; @@ -137,8 +156,14 @@ width: 56px; height: 56px; box-shadow: 0px 1px 8px rgba(0,0,0,0.75); + box-shadow: var(--avatarShadow); object-fit: cover; + &.better-shadow { + box-shadow: var(--avatarShadowInset); + filter: var(--avatarShadowFilter) + } + &.animated::before { display: none; } @@ -154,10 +179,9 @@ } } - text-shadow: 0px 1px 1.5px rgba(0, 0, 0, 1.0); - .usersettings { - color: #fff; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); opacity: .8; } @@ -168,21 +192,53 @@ text-overflow: ellipsis; white-space: nowrap; flex: 1 1 0; + // This is so that text doesn't get overlapped by avatar's shadow if it has + // big one + z-index: 1; + + img { + width: 26px; + height: 26px; + vertical-align: middle; + object-fit: contain + } } .user-name{ - color: white; text-overflow: ellipsis; overflow: hidden; } .user-screen-name { - color: white; - font-weight: lighter; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + display: inline-block; + font-weight: light; font-size: 15px; padding-right: 0.1em; } + .user-meta { + margin-bottom: .4em; + + .following { + font-size: 14px; + flex: 0 0 100%; + margin: 0; + padding-left: 16px; + text-align: left; + float: left; + } + .floater { + margin: 0; + } + + &::after { + display: block; + content: ''; + clear: both; + } + } .user-interactions { display: flex; flex-flow: row wrap; @@ -191,17 +247,6 @@ div { flex: 1; } - margin-top: 0.7em; - margin-bottom: -1.0em; - - .following { - color: white; - font-size: 14px; - flex: 0 0 100%; - margin: -0.7em 0.0em 0.3em 0.0em; - padding-left: 16px; - text-align: left; - } .mute { max-width: 220px; @@ -238,12 +283,37 @@ .user-counts { display: flex; line-height:16px; - padding: 1em 1.5em 0em 1em; + padding: .5em 1.5em 0em 1.5em; text-align: center; + justify-content: space-between; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + + &.clickable { + .user-count { + cursor: pointer; + + &:hover:not(.selected) { + transition: border-bottom 100ms; + border-bottom: 3px solid $fallback--link; + border-bottom: 3px solid var(--link, $fallback--link); + } + } + } } .user-count { flex: 1; + padding: .5em 0 .5em 0; + margin: 0 .5em; + + &.selected { + transition: none; + border-bottom: 5px solid $fallback--link; + border-bottom: 5px solid var(--link, $fallback--link); + border-radius: $fallback--btnRadius; + border-radius: var(--btnRadius, $fallback--btnRadius); + } h5 { font-size:1em; @@ -256,7 +326,37 @@ } .dailyAvg { - font-size: 0.8em; - opacity: 0.5; + margin-left: 1em; + font-size: 0.7em; + color: #CCC; +} +.floater { + float: right; + margin-top: 16px; + + .userHighlightCl { + padding: 2px 10px; + } + .userHighlightSel, + .userHighlightSel.select { + padding-top: 0; + padding-bottom: 0; + } + .userHighlightSel.select i { + line-height: 22px; + } + + .userHighlightText { + width: 70px; + } + + .userHighlightCl, + .userHighlightText, + .userHighlightSel, + .userHighlightSel.select { + height: 22px; + vertical-align: top; + margin-right: 0 + } } </style> diff --git a/src/components/user_finder/user_finder.js b/src/components/user_finder/user_finder.js index a743b5f6..74f79d1b 100644 --- a/src/components/user_finder/user_finder.js +++ b/src/components/user_finder/user_finder.js @@ -7,25 +7,10 @@ const UserFinder = { }), methods: { findUser (username) { - username = username[0] === '@' ? username.slice(1) : username - this.loading = true - this.$store.state.api.backendInteractor.externalProfile(username) - .then((user) => { - this.loading = false - this.hidden = true - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$router.push({name: 'user-profile', params: {id: user.id}}) - } else { - this.error = true - } - }) + this.$router.push({ name: 'user-search', query: { query: username } }) }, toggleHidden () { this.hidden = !this.hidden - }, - dismissError () { - this.error = false } } } diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue index 69bd1d21..8786f6c7 100644 --- a/src/components/user_finder/user_finder.vue +++ b/src/components/user_finder/user_finder.vue @@ -1,11 +1,7 @@ <template> <span class="user-finder-container"> - <span class="alert error" v-if="error"> - <i class="icon-cancel user-finder-icon" @click="dismissError"/> - {{$t('finder.error_fetching_user')}} - </span> <i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" /> - <a href="#" v-if="hidden"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden"/></a> + <a href="#" v-if="hidden" :title="$t('finder.find_user')" ><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a> <span v-else> <input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/> <i class="icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/> diff --git a/src/components/user_panel/user_panel.js b/src/components/user_panel/user_panel.js index 15804b88..eb7cb09c 100644 --- a/src/components/user_panel/user_panel.js +++ b/src/components/user_panel/user_panel.js @@ -3,6 +3,7 @@ import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCardContent from '../user_card_content/user_card_content.vue' const UserPanel = { + props: [ 'activatePanel' ], computed: { user () { return this.$store.state.users.currentUser } }, diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 3d4f873d..83eb099f 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -1,7 +1,7 @@ <template> <div class="user-panel"> <div v-if='user' class="panel panel-default" style="overflow: visible;"> - <user-card-content :user="user" :switcher="false" :hideBio="true"></user-card-content> + <user-card-content :activatePanel="activatePanel" :user="user" :switcher="false" :hideBio="true"></user-card-content> <div class="panel-footer"> <post-status-form v-if='user'></post-status-form> </div> @@ -14,8 +14,10 @@ <style lang="scss"> .user-panel { - .profile-panel-background .panel-heading { - background: transparent; - } + .profile-panel-background .panel-heading { + background: transparent; + flex-direction: column; + align-items: stretch; + } } </style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 26be1801..1d79713d 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -27,6 +27,7 @@ const UserProfile = { }, watch: { userId () { + this.$store.dispatch('stopFetching', 'user') this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.dispatch('startFetching', ['user', this.userId]) } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 838a43ab..4d2853a6 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -1,7 +1,17 @@ <template> <div> <div v-if="user" class="user-profile panel panel-default"> - <user-card-content :user="user" :switcher="true"></user-card-content> + <user-card-content :user="user" :switcher="true" :selected="timeline.viewing"></user-card-content> + </div> + <div v-else class="panel user-profile-placeholder"> + <div class="panel-heading"> + <div class="title"> + {{ $t('settings.profile_tab') }} + </div> + </div> + <div class="panel-body"> + <i class="icon-spin3 animate-spin"></i> + </div> </div> <Timeline :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" :user-id="userId"/> </div> @@ -17,6 +27,16 @@ padding-bottom: 10px; .panel-heading { background: transparent; + flex-direction: column; + align-items: stretch; + } +} +.user-profile-placeholder { + .panel-body { + display: flex; + justify-content: center; + align-items: middle; + padding: 7em; } } </style> diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js new file mode 100644 index 00000000..1e488f0c --- /dev/null +++ b/src/components/user_search/user_search.js @@ -0,0 +1,33 @@ +import UserCard from '../user_card/user_card.vue' +import userSearchApi from '../../services/new_api/user_search.js' +const userSearch = { + components: { + UserCard + }, + props: [ + 'query' + ], + data () { + return { + users: [] + } + }, + mounted () { + this.search(this.query) + }, + watch: { + query (newV) { + this.search(newV) + } + }, + methods: { + search (query) { + userSearchApi.search({query, store: this.$store}) + .then((res) => { + this.users = res + }) + } + } +} + +export default userSearch diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue new file mode 100644 index 00000000..20ae84fc --- /dev/null +++ b/src/components/user_search/user_search.vue @@ -0,0 +1,12 @@ +<template> + <div class="user-search panel panel-default"> + <div class="panel-heading"> + {{$t('nav.user_search')}} + </div> + <div class="panel-body"> + <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card> + </div> + </div> +</template> + +<script src="./user_search.js"></script> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index 25ee1f35..8e57894c 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,38 +1,85 @@ +import TabSwitcher from '../tab_switcher/tab_switcher.jsx' import StyleSwitcher from '../style_switcher/style_switcher.vue' const UserSettings = { data () { return { - newname: this.$store.state.users.currentUser.name, - newbio: this.$store.state.users.currentUser.description, + newName: this.$store.state.users.currentUser.name, + newBio: this.$store.state.users.currentUser.description, + newLocked: this.$store.state.users.currentUser.locked, + newNoRichText: this.$store.state.users.currentUser.no_rich_text, + newDefaultScope: this.$store.state.users.currentUser.default_scope, + newHideNetwork: this.$store.state.users.currentUser.hide_network, followList: null, followImportError: false, followsImported: false, + enableFollowsExport: true, uploading: [ false, false, false, false ], - previews: [ null, null, null ] + previews: [ null, null, null ], + deletingAccount: false, + deleteAccountConfirmPasswordInput: '', + deleteAccountError: false, + changePasswordInputs: [ '', '', '' ], + changedPassword: false, + changePasswordError: false, + activeTab: 'profile' } }, components: { - StyleSwitcher + StyleSwitcher, + TabSwitcher }, computed: { user () { return this.$store.state.users.currentUser }, pleromaBackend () { - return this.$store.state.config.pleromaBackend + return this.$store.state.instance.pleromaBackend + }, + scopeOptionsEnabled () { + return this.$store.state.instance.scopeOptionsEnabled + }, + vis () { + return { + public: { selected: this.newDefaultScope === 'public' }, + unlisted: { selected: this.newDefaultScope === 'unlisted' }, + private: { selected: this.newDefaultScope === 'private' }, + direct: { selected: this.newDefaultScope === 'direct' } + } } }, methods: { updateProfile () { const name = this.newname - const description = this.newbio - this.$store.state.api.backendInteractor.updateProfile({params: {name, description}}).then((user) => { - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) - } - }) + const description = this.newBio + const locked = this.newLocked + // Backend notation. + /* eslint-disable camelcase */ + const default_scope = this.newDefaultScope + const no_rich_text = this.newNoRichText + const hide_network = this.newHideNetwork + /* eslint-enable camelcase */ + this.$store.state.api.backendInteractor + .updateProfile({ + params: { + name, + description, + locked, + // Backend notation. + /* eslint-disable camelcase */ + default_scope, + no_rich_text, + hide_network + /* eslint-enable camelcase */ + }}).then((user) => { + if (!user.error) { + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) + } + }) + }, + changeVis (visibility) { + this.newDefaultScope = visibility }, uploadFile (slot, e) { const file = e.target.files[0] @@ -137,6 +184,37 @@ const UserSettings = { this.uploading[3] = false }) }, + /* This function takes an Array of Users + * and outputs a file with all the addresses for the user to download + */ + exportPeople (users, filename) { + // Get all the friends addresses + var UserAddresses = users.map(function (user) { + // check is it's a local user + if (user && user.is_local) { + // append the instance address + // eslint-disable-next-line no-undef + user.screen_name += '@' + location.hostname + } + return user.screen_name + }).join('\n') + // Make the user download the file + var fileToDownload = document.createElement('a') + fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(UserAddresses)) + fileToDownload.setAttribute('download', filename) + fileToDownload.style.display = 'none' + document.body.appendChild(fileToDownload) + fileToDownload.click() + document.body.removeChild(fileToDownload) + }, + exportFollows () { + this.enableFollowsExport = false + this.$store.state.api.backendInteractor + .fetchFriends({id: this.$store.state.users.currentUser.id}) + .then((friendList) => { + this.exportPeople(friendList, 'friends.csv') + }) + }, followListChange () { // eslint-disable-next-line no-undef let formData = new FormData() @@ -146,6 +224,45 @@ const UserSettings = { dismissImported () { this.followsImported = false this.followImportError = false + }, + confirmDelete () { + this.deletingAccount = true + }, + deleteAccount () { + this.$store.state.api.backendInteractor.deleteAccount({password: this.deleteAccountConfirmPasswordInput}) + .then((res) => { + if (res.status === 'success') { + this.$store.dispatch('logout') + this.$router.push('/main/all') + } else { + this.deleteAccountError = res.error + } + }) + }, + changePassword () { + const params = { + password: this.changePasswordInputs[0], + newPassword: this.changePasswordInputs[1], + newPasswordConfirmation: this.changePasswordInputs[2] + } + this.$store.state.api.backendInteractor.changePassword(params) + .then((res) => { + if (res.status === 'success') { + this.changedPassword = true + this.changePasswordError = false + this.logout() + } else { + this.changedPassword = false + this.changePasswordError = res.error + } + }) + }, + activateTab (tabName) { + this.activeTab = tabName + }, + logout () { + this.$store.dispatch('logout') + this.$router.replace('/') } } } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index ed1864cc..11629440 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -4,68 +4,139 @@ {{$t('settings.user_settings')}} </div> <div class="panel-body profile-edit"> - <div class="setting-item"> - <h3>{{$t('settings.name_bio')}}</h3> - <p>{{$t('settings.name')}}</p> - <input class='name-changer' id='username' v-model="newname"></input> - <p>{{$t('settings.bio')}}</p> - <textarea class="bio" v-model="newbio"></textarea> - <button :disabled='newname.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> - </div> - <div class="setting-item"> - <h3>{{$t('settings.avatar')}}</h3> - <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="old-avatar"></img> - <p>{{$t('settings.set_new_avatar')}}</p> - <img class="new-avatar" v-bind:src="previews[0]" v-if="previews[0]"> - </img> - <div> - <input type="file" @change="uploadFile(0, $event)" ></input> + <tab-switcher> + <div :label="$t('settings.profile_tab')"> + <div class="setting-item" > + <h2>{{$t('settings.name_bio')}}</h2> + <p>{{$t('settings.name')}}</p> + <input class='name-changer' id='username' v-model="newName"></input> + <p>{{$t('settings.bio')}}</p> + <textarea class="bio" v-model="newBio"></textarea> + <p> + <input type="checkbox" v-model="newLocked" id="account-locked"> + <label for="account-locked">{{$t('settings.lock_account_description')}}</label> + </p> + <div v-if="scopeOptionsEnabled"> + <label for="default-vis">{{$t('settings.default_vis')}}</label> + <div id="default-vis" class="visibility-tray"> + <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')" ></i> + <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i> + <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i> + <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i> + </div> + </div> + <p> + <input type="checkbox" v-model="newNoRichText" id="account-no-rich-text"> + <label for="account-no-rich-text">{{$t('settings.no_rich_text_description')}}</label> + </p> + <p> + <input type="checkbox" v-model="newHideNetwork" id="account-hide-network"> + <label for="account-no-rich-text">{{$t('settings.hide_network_description')}}</label> + </p> + <button :disabled='newName.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> + </div> + <div class="setting-item"> + <h2>{{$t('settings.avatar')}}</h2> + <p>{{$t('settings.current_avatar')}}</p> + <img :src="user.profile_image_url_original" class="old-avatar"></img> + <p>{{$t('settings.set_new_avatar')}}</p> + <img class="new-avatar" v-bind:src="previews[0]" v-if="previews[0]"> + </img> + <div> + <input type="file" @change="uploadFile(0, $event)" ></input> + </div> + <i class="icon-spin4 animate-spin" v-if="uploading[0]"></i> + <button class="btn btn-default" v-else-if="previews[0]" @click="submitAvatar">{{$t('general.submit')}}</button> + </div> + <div class="setting-item"> + <h2>{{$t('settings.profile_banner')}}</h2> + <p>{{$t('settings.current_profile_banner')}}</p> + <img :src="user.cover_photo" class="banner"></img> + <p>{{$t('settings.set_new_profile_banner')}}</p> + <img class="banner" v-bind:src="previews[1]" v-if="previews[1]"> + </img> + <div> + <input type="file" @change="uploadFile(1, $event)" ></input> + </div> + <i class=" icon-spin4 animate-spin uploading" v-if="uploading[1]"></i> + <button class="btn btn-default" v-else-if="previews[1]" @click="submitBanner">{{$t('general.submit')}}</button> + </div> + <div class="setting-item"> + <h2>{{$t('settings.profile_background')}}</h2> + <p>{{$t('settings.set_new_profile_background')}}</p> + <img class="bg" v-bind:src="previews[2]" v-if="previews[2]"> + </img> + <div> + <input type="file" @change="uploadFile(2, $event)" ></input> + </div> + <i class=" icon-spin4 animate-spin uploading" v-if="uploading[2]"></i> + <button class="btn btn-default" v-else-if="previews[2]" @click="submitBg">{{$t('general.submit')}}</button> + </div> </div> - <i class="icon-spin4 animate-spin" v-if="uploading[0]"></i> - <button class="btn btn-default" v-else-if="previews[0]" @click="submitAvatar">{{$t('general.submit')}}</button> - </div> - <div class="setting-item"> - <h3>{{$t('settings.profile_banner')}}</h3> - <p>{{$t('settings.current_profile_banner')}}</p> - <img :src="user.cover_photo" class="banner"></img> - <p>{{$t('settings.set_new_profile_banner')}}</p> - <img class="banner" v-bind:src="previews[1]" v-if="previews[1]"> - </img> - <div> - <input type="file" @change="uploadFile(1, $event)" ></input> - </div> - <i class=" icon-spin4 animate-spin uploading" v-if="uploading[1]"></i> - <button class="btn btn-default" v-else-if="previews[1]" @click="submitBanner">{{$t('general.submit')}}</button> - </div> - <div class="setting-item"> - <h3>{{$t('settings.profile_background')}}</h3> - <p>{{$t('settings.set_new_profile_background')}}</p> - <img class="bg" v-bind:src="previews[2]" v-if="previews[2]"> - </img> - <div> - <input type="file" @change="uploadFile(2, $event)" ></input> - </div> - <i class=" icon-spin4 animate-spin uploading" v-if="uploading[2]"></i> - <button class="btn btn-default" v-else-if="previews[2]" @click="submitBg">{{$t('general.submit')}}</button> - </div> - <div class="setting-item" v-if="pleromaBackend"> - <h3>{{$t('settings.follow_import')}}</h3> - <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> - <form v-model="followImportForm"> - <input type="file" ref="followlist" v-on:change="followListChange"></input> - </form> - <i class=" icon-spin4 animate-spin uploading" v-if="uploading[3]"></i> - <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> - <div v-if="followsImported"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follows_imported')}}</p> + + <div :label="$t('settings.security_tab')"> + <div class="setting-item"> + <h2>{{$t('settings.change_password')}}</h2> + <div> + <p>{{$t('settings.current_password')}}</p> + <input type="password" v-model="changePasswordInputs[0]"> + </div> + <div> + <p>{{$t('settings.new_password')}}</p> + <input type="password" v-model="changePasswordInputs[1]"> + </div> + <div> + <p>{{$t('settings.confirm_new_password')}}</p> + <input type="password" v-model="changePasswordInputs[2]"> + </div> + <button class="btn btn-default" @click="changePassword">{{$t('general.submit')}}</button> + <p v-if="changedPassword">{{$t('settings.changed_password')}}</p> + <p v-else-if="changePasswordError !== false">{{$t('settings.change_password_error')}}</p> + <p v-if="changePasswordError">{{changePasswordError}}</p> + </div> + + <div class="setting-item"> + <h2>{{$t('settings.delete_account')}}</h2> + <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> + <div v-if="deletingAccount"> + <p>{{$t('settings.delete_account_instructions')}}</p> + <p>{{$t('login.password')}}</p> + <input type="password" v-model="deleteAccountConfirmPasswordInput"> + <button class="btn btn-default" @click="deleteAccount">{{$t('settings.delete_account')}}</button> + </div> + <p v-if="deleteAccountError !== false">{{$t('settings.delete_account_error')}}</p> + <p v-if="deleteAccountError">{{deleteAccountError}}</p> + <button class="btn btn-default" v-if="!deletingAccount" @click="confirmDelete">{{$t('general.submit')}}</button> + </div> </div> - <div v-else-if="followImportError"> - <i class="icon-cross" @click="dismissImported"</i> - <p>{{$t('settings.follow_import_error')}}</p> + + <div :label="$t('settings.data_import_export_tab')" v-if="pleromaBackend"> + <div class="setting-item"> + <h2>{{$t('settings.follow_import')}}</h2> + <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> + <form v-model="followImportForm"> + <input type="file" ref="followlist" v-on:change="followListChange"></input> + </form> + <i class=" icon-spin4 animate-spin uploading" v-if="uploading[3]"></i> + <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> + <div v-if="followsImported"> + <i class="icon-cross" @click="dismissImported"></i> + <p>{{$t('settings.follows_imported')}}</p> + </div> + <div v-else-if="followImportError"> + <i class="icon-cross" @click="dismissImported"></i> + <p>{{$t('settings.follow_import_error')}}</p> + </div> + </div> + <div class="setting-item" v-if="enableFollowsExport"> + <h2>{{$t('settings.follow_export')}}</h2> + <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button> + </div> + <div class="setting-item" v-else> + <h2>{{$t('settings.follow_export_processing')}}</h2> + </div> </div> - </div> + </tab-switcher> </div> </div> </template> @@ -81,6 +152,7 @@ input[type=file] { padding: 5px; + height: auto; } .banner { diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js new file mode 100644 index 00000000..49b8f5b6 --- /dev/null +++ b/src/components/who_to_follow_panel/who_to_follow_panel.js @@ -0,0 +1,111 @@ +import apiService from '../../services/api/api.service.js' + +function showWhoToFollow (panel, reply) { + var users = reply + var cn + var index + var step = 7 + cn = Math.floor(Math.random() * step) + for (index = 0; index < 3; index++) { + var user + user = users[cn] + var img + if (user.avatar) { + img = user.avatar + } else { + img = '/images/avi.png' + } + var name = user.acct + if (index === 0) { + panel.img1 = img + panel.name1 = name + panel.$store.state.api.backendInteractor.externalProfile(name) + .then((externalUser) => { + if (!externalUser.error) { + panel.$store.commit('addNewUsers', [externalUser]) + panel.id1 = externalUser.id + } + }) + } else if (index === 1) { + panel.img2 = img + panel.name2 = name + panel.$store.state.api.backendInteractor.externalProfile(name) + .then((externalUser) => { + if (!externalUser.error) { + panel.$store.commit('addNewUsers', [externalUser]) + panel.id2 = externalUser.id + } + }) + } else if (index === 2) { + panel.img3 = img + panel.name3 = name + panel.$store.state.api.backendInteractor.externalProfile(name) + .then((externalUser) => { + if (!externalUser.error) { + panel.$store.commit('addNewUsers', [externalUser]) + panel.id3 = externalUser.id + } + }) + } + cn = (cn + step) % users.length + } +} + +function getWhoToFollow (panel) { + var credentials = panel.$store.state.users.currentUser.credentials + if (credentials) { + panel.name1 = 'Loading...' + panel.name2 = 'Loading...' + panel.name3 = 'Loading...' + apiService.suggestions({credentials: credentials}) + .then((reply) => { + showWhoToFollow(panel, reply) + }) + } +} + +const WhoToFollowPanel = { + data: () => ({ + img1: '/images/avi.png', + name1: '', + id1: 0, + img2: '/images/avi.png', + name2: '', + id2: 0, + img3: '/images/avi.png', + name3: '', + id3: 0 + }), + computed: { + user: function () { + return this.$store.state.users.currentUser.screen_name + }, + moreUrl: function () { + var host = window.location.hostname + var user = this.user + var suggestionsWeb = this.$store.state.instance.suggestionsWeb + var url + url = suggestionsWeb.replace(/{{host}}/g, encodeURIComponent(host)) + url = url.replace(/{{user}}/g, encodeURIComponent(user)) + return url + }, + suggestionsEnabled () { + return this.$store.state.instance.suggestionsEnabled + } + }, + watch: { + user: function (user, oldUser) { + if (this.suggestionsEnabled) { + getWhoToFollow(this) + } + } + }, + mounted: + function () { + if (this.suggestionsEnabled) { + getWhoToFollow(this) + } + } +} + +export default WhoToFollowPanel diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue new file mode 100644 index 00000000..d031318d --- /dev/null +++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue @@ -0,0 +1,37 @@ +<template> + <div class="who-to-follow-panel"> + <div class="panel panel-default base01-background"> + <div class="panel-heading timeline-heading base02-background base04"> + <div class="title"> + {{$t('who_to_follow.who_to_follow')}} + </div> + </div> + <div class="panel-body who-to-follow"> + <p> + <img v-bind:src="img1"/> <router-link :to="{ name: 'user-profile', params: { id: id1 } }">{{ name1 }}</router-link><br> + <img v-bind:src="img2"/> <router-link :to="{ name: 'user-profile', params: { id: id2 } }">{{ name2 }}</router-link><br> + <img v-bind:src="img3"/> <router-link :to="{ name: 'user-profile', params: { id: id3 } }">{{ name3 }}</router-link><br> + <img v-bind:src="$store.state.instance.logo"> <a v-bind:href="moreUrl" target="_blank">{{$t('who_to_follow.more')}}</a> + </p> + </div> + </div> + </div> +</template> + +<script src="./who_to_follow_panel.js" ></script> + +<style lang="scss"> + .who-to-follow * { + vertical-align: middle; + } + .who-to-follow img { + width: 32px; + height: 32px; + } + .who-to-follow p { + line-height: 40px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +</style> diff --git a/src/i18n/ar.json b/src/i18n/ar.json new file mode 100644 index 00000000..ac7d0f1a --- /dev/null +++ b/src/i18n/ar.json @@ -0,0 +1,201 @@ +{ + "chat": { + "title": "الدردشة" + }, + "features_panel": { + "chat": "الدردشة", + "gopher": "غوفر", + "media_proxy": "بروكسي الوسائط", + "scope_options": "", + "text_limit": "الحد الأقصى للنص", + "title": "الميّزات", + "who_to_follow": "للمتابعة" + }, + "finder": { + "error_fetching_user": "خطأ أثناء جلب صفحة المستخدم", + "find_user": "البحث عن مستخدِم" + }, + "general": { + "apply": "تطبيق", + "submit": "إرسال" + }, + "login": { + "login": "تسجيل الدخول", + "logout": "الخروج", + "password": "الكلمة السرية", + "placeholder": "مثال lain", + "register": "انشاء حساب", + "username": "إسم المستخدم" + }, + "nav": { + "chat": "الدردشة المحلية", + "friend_requests": "طلبات المتابَعة", + "mentions": "الإشارات", + "public_tl": "الخيط الزمني العام", + "timeline": "الخيط الزمني", + "twkn": "كافة الشبكة المعروفة" + }, + "notifications": { + "broken_favorite": "منشور مجهول، جارٍ البحث عنه…", + "favorited_you": "أعجِب بمنشورك", + "followed_you": "يُتابعك", + "load_older": "تحميل الإشعارات الأقدم", + "notifications": "الإخطارات", + "read": "مقروء!", + "repeated_you": "شارَك منشورك" + }, + "post_status": { + "account_not_locked_warning": "", + "account_not_locked_warning_link": "مقفل", + "attachments_sensitive": "اعتبر المرفقات كلها كمحتوى حساس", + "content_type": { + "plain_text": "نص صافٍ" + }, + "content_warning": "الموضوع (اختياري)", + "default": "وصلت للتوّ إلى لوس أنجلس.", + "direct_warning": "", + "posting": "النشر", + "scope": { + "direct": "", + "private": "", + "public": "علني - يُنشر على الخيوط الزمنية العمومية", + "unlisted": "غير مُدرَج - لا يُنشَر على الخيوط الزمنية العمومية" + } + }, + "registration": { + "bio": "السيرة الذاتية", + "email": "عنوان البريد الإلكتروني", + "fullname": "الإسم المعروض", + "password_confirm": "تأكيد الكلمة السرية", + "registration": "التسجيل", + "token": "رمز الدعوة" + }, + "settings": { + "attachmentRadius": "المُرفَقات", + "attachments": "المُرفَقات", + "autoload": "", + "avatar": "الصورة الرمزية", + "avatarAltRadius": "الصور الرمزية (الإشعارات)", + "avatarRadius": "الصور الرمزية", + "background": "الخلفية", + "bio": "السيرة الذاتية", + "btnRadius": "الأزرار", + "cBlue": "أزرق (الرد، المتابَعة)", + "cGreen": "أخضر (إعادة النشر)", + "cOrange": "برتقالي (مفضلة)", + "cRed": "أحمر (إلغاء)", + "change_password": "تغيير كلمة السر", + "change_password_error": "وقع هناك خلل أثناء تعديل كلمتك السرية.", + "changed_password": "تم تغيير كلمة المرور بنجاح!", + "collapse_subject": "", + "confirm_new_password": "تأكيد كلمة السر الجديدة", + "current_avatar": "صورتك الرمزية الحالية", + "current_password": "كلمة السر الحالية", + "current_profile_banner": "الرأسية الحالية لصفحتك الشخصية", + "data_import_export_tab": "تصدير واستيراد البيانات", + "default_vis": "أسلوب العرض الافتراضي", + "delete_account": "حذف الحساب", + "delete_account_description": "حذف حسابك و كافة منشوراتك نهائيًا.", + "delete_account_error": "", + "delete_account_instructions": "يُرجى إدخال كلمتك السرية أدناه لتأكيد عملية حذف الحساب.", + "export_theme": "حفظ النموذج", + "filtering": "التصفية", + "filtering_explanation": "سيتم إخفاء كافة المنشورات التي تحتوي على هذه الكلمات، كلمة واحدة في كل سطر", + "follow_export": "تصدير الاشتراكات", + "follow_export_button": "تصدير الاشتراكات كملف csv", + "follow_export_processing": "التصدير جارٍ، سوف يُطلَب منك تنزيل ملفك بعد حين", + "follow_import": "استيراد الاشتراكات", + "follow_import_error": "خطأ أثناء استيراد المتابِعين", + "follows_imported": "", + "foreground": "الأمامية", + "general": "الإعدادات العامة", + "hide_attachments_in_convo": "إخفاء المرفقات على المحادثات", + "hide_attachments_in_tl": "إخفاء المرفقات على الخيط الزمني", + "hide_post_stats": "", + "hide_user_stats": "", + "import_followers_from_a_csv_file": "", + "import_theme": "تحميل نموذج", + "inputRadius": "", + "instance_default": "", + "interfaceLanguage": "لغة الواجهة", + "invalid_theme_imported": "", + "limited_availability": "غير متوفر على متصفحك", + "links": "الروابط", + "lock_account_description": "", + "loop_video": "", + "loop_video_silent_only": "", + "name": "الاسم", + "name_bio": "الاسم والسيرة الذاتية", + "new_password": "كلمة السر الجديدة", + "no_rich_text_description": "", + "notification_visibility": "نوع الإشعارات التي تريد عرضها", + "notification_visibility_follows": "يتابع", + "notification_visibility_likes": "الإعجابات", + "notification_visibility_mentions": "الإشارات", + "notification_visibility_repeats": "", + "nsfw_clickthrough": "", + "panelRadius": "", + "pause_on_unfocused": "", + "presets": "النماذج", + "profile_background": "خلفية الصفحة الشخصية", + "profile_banner": "رأسية الصفحة الشخصية", + "profile_tab": "الملف الشخصي", + "radii_help": "", + "replies_in_timeline": "الردود على الخيط الزمني", + "reply_link_preview": "", + "reply_visibility_all": "عرض كافة الردود", + "reply_visibility_following": "", + "reply_visibility_self": "", + "saving_err": "خطأ أثناء حفظ الإعدادات", + "saving_ok": "تم حفظ الإعدادات", + "security_tab": "الأمان", + "set_new_avatar": "اختيار صورة رمزية جديدة", + "set_new_profile_background": "اختيار خلفية جديدة للملف الشخصي", + "set_new_profile_banner": "اختيار رأسية جديدة للصفحة الشخصية", + "settings": "الإعدادات", + "stop_gifs": "", + "streaming": "", + "text": "النص", + "theme": "المظهر", + "theme_help": "", + "tooltipRadius": "", + "user_settings": "إعدادات المستخدم", + "values": { + "false": "لا", + "true": "نعم" + } + }, + "timeline": { + "collapse": "", + "conversation": "محادثة", + "error_fetching": "خطأ أثناء جلب التحديثات", + "load_older": "تحميل المنشورات القديمة", + "no_retweet_hint": "", + "repeated": "", + "show_new": "عرض الجديد", + "up_to_date": "تم تحديثه" + }, + "user_card": { + "approve": "قبول", + "block": "حظر", + "blocked": "تم حظره!", + "deny": "رفض", + "follow": "اتبع", + "followees": "", + "followers": "مُتابِعون", + "following": "", + "follows_you": "يتابعك!", + "mute": "كتم", + "muted": "تم كتمه", + "per_day": "في اليوم", + "remote_follow": "مُتابَعة عن بُعد", + "statuses": "المنشورات" + }, + "user_profile": { + "timeline_title": "الخيط الزمني للمستخدم" + }, + "who_to_follow": { + "more": "المزيد", + "who_to_follow": "للمتابعة" + } +}
\ No newline at end of file diff --git a/src/i18n/ca.json b/src/i18n/ca.json new file mode 100644 index 00000000..fa517e22 --- /dev/null +++ b/src/i18n/ca.json @@ -0,0 +1,199 @@ +{ + "chat": { + "title": "Xat" + }, + "features_panel": { + "chat": "Xat", + "gopher": "Gopher", + "media_proxy": "Proxy per multimèdia", + "scope_options": "Opcions d'abast i visibilitat", + "text_limit": "Límit de text", + "title": "Funcionalitats", + "who_to_follow": "A qui seguir" + }, + "finder": { + "error_fetching_user": "No s'ha pogut carregar l'usuari/a", + "find_user": "Find user" + }, + "general": { + "apply": "Aplica", + "submit": "Desa" + }, + "login": { + "login": "Inicia sessió", + "logout": "Tanca la sessió", + "password": "Contrasenya", + "placeholder": "p.ex.: Maria", + "register": "Registra't", + "username": "Nom d'usuari/a" + }, + "nav": { + "chat": "Xat local públic", + "friend_requests": "Soŀlicituds de connexió", + "mentions": "Mencions", + "public_tl": "Flux públic del node", + "timeline": "Flux personal", + "twkn": "Flux de la xarxa coneguda" + }, + "notifications": { + "broken_favorite": "No es coneix aquest estat. S'està cercant.", + "favorited_you": "ha marcat un estat teu", + "followed_you": "ha començat a seguir-te", + "load_older": "Carrega més notificacions", + "notifications": "Notificacions", + "read": "Read!", + "repeated_you": "ha repetit el teu estat" + }, + "post_status": { + "account_not_locked_warning": "El teu compte no està {0}. Qualsevol persona pot seguir-te per llegir les teves entrades reservades només a seguidores.", + "account_not_locked_warning_link": "bloquejat", + "attachments_sensitive": "Marca l'adjunt com a delicat", + "content_type": { + "plain_text": "Text pla" + }, + "content_warning": "Assumpte (opcional)", + "default": "Em sento…", + "direct_warning": "Aquesta entrada només serà visible per les usuràries que etiquetis", + "posting": "Publicació", + "scope": { + "direct": "Directa - Publica només per les usuàries etiquetades", + "private": "Només seguidors/es - Publica només per comptes que et segueixin", + "public": "Pública - Publica als fluxos públics", + "unlisted": "Silenciosa - No la mostris en fluxos públics" + } + }, + "registration": { + "bio": "Presentació", + "email": "Correu", + "fullname": "Nom per mostrar", + "password_confirm": "Confirma la contrasenya", + "registration": "Registra't", + "token": "Codi d'invitació" + }, + "settings": { + "attachmentRadius": "Adjunts", + "attachments": "Adjunts", + "autoload": "Recarrega automàticament en arribar a sota de tot.", + "avatar": "Avatar", + "avatarAltRadius": "Avatars en les notificacions", + "avatarRadius": "Avatars", + "background": "Fons de pantalla", + "bio": "Presentació", + "btnRadius": "Botons", + "cBlue": "Blau (respon, segueix)", + "cGreen": "Verd (republica)", + "cOrange": "Taronja (marca com a preferit)", + "cRed": "Vermell (canceŀla)", + "change_password": "Canvia la contrasenya", + "change_password_error": "No s'ha pogut canviar la contrasenya", + "changed_password": "S'ha canviat la contrasenya", + "collapse_subject": "Replega les entrades amb títol", + "confirm_new_password": "Confirma la nova contrasenya", + "current_avatar": "L'avatar actual", + "current_password": "La contrasenya actual", + "current_profile_banner": "El fons de perfil actual", + "data_import_export_tab": "Importa o exporta dades", + "default_vis": "Abast per defecte de les entrades", + "delete_account": "Esborra el compte", + "delete_account_description": "Esborra permanentment el teu compte i tots els missatges", + "delete_account_error": "No s'ha pogut esborrar el compte. Si continua el problema, contacta amb l'administració del node", + "delete_account_instructions": "Confirma que vols esborrar el compte escrivint la teva contrasenya aquí sota", + "export_theme": "Desa el tema", + "filtering": "Filtres", + "filtering_explanation": "Es silenciaran totes les entrades que continguin aquestes paraules. Separa-les per línies", + "follow_export": "Exporta la llista de contactes", + "follow_export_button": "Exporta tots els comptes que segueixes a un fitxer CSV", + "follow_export_processing": "S'està processant la petició. Aviat podràs descarregar el fitxer", + "follow_import": "Importa els contactes", + "follow_import_error": "No s'ha pogut importar els contactes", + "follows_imported": "S'han importat els contactes. Trigaran una estoneta en ser processats.", + "foreground": "Primer pla", + "general": "General", + "hide_attachments_in_convo": "Amaga els adjunts en les converses", + "hide_attachments_in_tl": "Amaga els adjunts en el flux d'entrades", + "import_followers_from_a_csv_file": "Importa els contactes des d'un fitxer CSV", + "import_theme": "Carrega un tema", + "inputRadius": "Caixes d'entrada de text", + "instance_default": "(default: {value})", + "interfaceLanguage": "Llengua de la interfície", + "invalid_theme_imported": "No s'ha entès l'arxiu carregat perquè no és un tema vàlid de Pleroma. No s'ha fet cap canvi als temes actuals.", + "limited_availability": "No està disponible en aquest navegador", + "links": "Enllaços", + "lock_account_description": "Restringeix el teu compte només a seguidores aprovades.", + "loop_video": "Reprodueix els vídeos en bucle", + "loop_video_silent_only": "Reprodueix en bucles només els vídeos sense so (com els \"GIF\" de Mastodon)", + "name": "Nom", + "name_bio": "Nom i presentació", + "new_password": "Contrasenya nova", + "notification_visibility": "Notifica'm quan algú", + "notification_visibility_follows": "Comença a seguir-me", + "notification_visibility_likes": "Marca com a preferida una entrada meva", + "notification_visibility_mentions": "Em menciona", + "notification_visibility_repeats": "Republica una entrada meva", + "no_rich_text_description": "Neteja el formatat de text de totes les entrades", + "nsfw_clickthrough": "Amaga el contingut NSFW darrer d'una imatge clicable", + "panelRadius": "Panells", + "pause_on_unfocused": "Pausa la reproducció en continu quan la pestanya perdi el focus", + "presets": "Temes", + "profile_background": "Fons de pantalla", + "profile_banner": "Fons de perfil", + "profile_tab": "Perfil", + "radii_help": "Configura l'arrodoniment de les vores (en píxels)", + "replies_in_timeline": "Replies in timeline", + "reply_link_preview": "Mostra el missatge citat en passar el ratolí per sobre de l'enllaç de resposta", + "reply_visibility_all": "Mostra totes les respostes", + "reply_visibility_following": "Mostra només les respostes a entrades meves o d'usuàries que jo segueixo", + "reply_visibility_self": "Mostra només les respostes a entrades meves", + "saving_err": "No s'ha pogut desar la configuració", + "saving_ok": "S'ha desat la configuració", + "security_tab": "Seguretat", + "set_new_avatar": "Canvia l'avatar", + "set_new_profile_background": "Canvia el fons de pantalla", + "set_new_profile_banner": "Canvia el fons del perfil", + "settings": "Configuració", + "stop_gifs": "Anima els GIF només en passar-hi el ratolí per sobre", + "streaming": "Carrega automàticament entrades noves quan estigui a dalt de tot", + "text": "Text", + "theme": "Tema", + "theme_help": "Personalitza els colors del tema. Escriu-los en format RGB hexadecimal (#rrggbb)", + "tooltipRadius": "Missatges sobreposats", + "user_settings": "Configuració personal", + "values": { + "false": "no", + "true": "sí" + } + }, + "timeline": { + "collapse": "Replega", + "conversation": "Conversa", + "error_fetching": "S'ha produït un error en carregar les entrades", + "load_older": "Carrega entrades anteriors", + "no_retweet_hint": "L'entrada és només per a seguidores o és \"directa\", i per tant no es pot republicar", + "repeated": "republicat", + "show_new": "Mostra els nous", + "up_to_date": "Actualitzat" + }, + "user_card": { + "approve": "Aprova", + "block": "Bloqueja", + "blocked": "Bloquejat!", + "deny": "Denega", + "follow": "Segueix", + "followees": "Segueixo", + "followers": "Seguidors/es", + "following": "Seguint!", + "follows_you": "Et segueix!", + "mute": "Silencia", + "muted": "Silenciat", + "per_day": "per dia", + "remote_follow": "Seguiment remot", + "statuses": "Estats" + }, + "user_profile": { + "timeline_title": "Flux personal" + }, + "who_to_follow": { + "more": "More", + "who_to_follow": "A qui seguir" + } +} diff --git a/src/i18n/compare.js b/src/i18n/compare.js new file mode 100755 index 00000000..e9314376 --- /dev/null +++ b/src/i18n/compare.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +const arg = process.argv[2] + +if (typeof arg === 'undefined') { + console.log('This is a very simple and tiny tool that checks en.json with any other language and') + console.log('outputs all the things present in english but missing in foreign language.') + console.log('') + console.log('Usage: ./compare.js <lang> ') + console.log(' or') + console.log(' node ./compare.js <lang>') + console.log('') + console.log('Where <lang> is name of .json file containing language. For ./fi.json it should be:') + console.log(' ./compare.js fi ') + console.log('') + console.log('Limitations: ') + console.log('* This program does not work with languages left over in messages.js') + console.log('* This program does not check for extra strings present in foreign language but missing') + console.log(' in english.js (for now)') + console.log('') + console.log('There are no other arguments or options. Make an issue if you encounter a bug or want') + console.log('some feature to be implemented. Merge requests are welcome as well.') + return +} + +const english = require('./en.json') +const foreign = require(`./${arg}.json`) + +function walker (a, b, path = []) { + Object.keys(a).forEach(k => { + const aVal = a[k] + const bVal = b[k] + const aType = typeof aVal + const bType = typeof bVal + const currentPath = [...path, k] + const article = aType[0] === 'o' ? 'an' : 'a' + + if (bType === 'undefined') { + console.log(`Foreign language is missing ${article} ${aType} at path ${currentPath.join('.')}`) + } else if (aType === 'object') { + if (bType !== 'object') { + console.log(`Type mismatch! English has ${aType} while foreign has ${bType} at path ${currentPath.join['.']}`) + } else { + walker(aVal, bVal, currentPath) + } + } + }) +} + +walker(english, foreign) diff --git a/src/i18n/de.json b/src/i18n/de.json new file mode 100644 index 00000000..ff6db67f --- /dev/null +++ b/src/i18n/de.json @@ -0,0 +1,202 @@ +{ + "chat": { + "title": "Chat" + }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Media Proxy", + "scope_options": "Scope options", + "text_limit": "Textlimit", + "title": "Features", + "who_to_follow": "Who to follow" + }, + "finder": { + "error_fetching_user": "Fehler beim Suchen des Benutzers", + "find_user": "Finde Benutzer" + }, + "general": { + "apply": "Anwenden", + "submit": "Absenden" + }, + "login": { + "login": "Anmelden", + "description": "Mit OAuth anmelden", + "logout": "Abmelden", + "password": "Passwort", + "placeholder": "z.B. lain", + "register": "Registrieren", + "username": "Benutzername" + }, + "nav": { + "chat": "Lokaler Chat", + "friend_requests": "Followanfragen", + "mentions": "Erwähnungen", + "public_tl": "Lokale Zeitleiste", + "timeline": "Zeitleiste", + "twkn": "Das gesamte bekannte Netzwerk" + }, + "notifications": { + "broken_favorite": "Unbekannte Nachricht, suche danach...", + "favorited_you": "favorisierte deine Nachricht", + "followed_you": "folgt dir", + "load_older": "Ältere Benachrichtigungen laden", + "notifications": "Benachrichtigungen", + "read": "Gelesen!", + "repeated_you": "wiederholte deine Nachricht" + }, + "post_status": { + "account_not_locked_warning": "Dein Profil ist nicht {0}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.", + "account_not_locked_warning_link": "gesperrt", + "attachments_sensitive": "Anhänge als heikel markieren", + "content_type": { + "plain_text": "Nur Text" + }, + "content_warning": "Betreff (optional)", + "default": "Sitze gerade im Hofbräuhaus.", + "direct_warning": "Dieser Beitrag wird nur für die erwähnten Nutzer sichtbar sein.", + "posting": "Veröffentlichen", + "scope": { + "direct": "Direkt - Beitrag nur an erwähnte Profile", + "private": "Nur Follower - Beitrag nur für Follower sichtbar", + "public": "Öffentlich - Beitrag an öffentliche Zeitleisten", + "unlisted": "Nicht gelistet - Nicht in öffentlichen Zeitleisten anzeigen" + } + }, + "registration": { + "bio": "Bio", + "email": "Email", + "fullname": "Angezeigter Name", + "password_confirm": "Passwort bestätigen", + "registration": "Registrierung", + "token": "Einladungsschlüssel" + }, + "settings": { + "attachmentRadius": "Anhänge", + "attachments": "Anhänge", + "autoload": "Aktiviere automatisches Laden von älteren Beiträgen beim scrollen", + "avatar": "Avatar", + "avatarAltRadius": "Avatare (Benachrichtigungen)", + "avatarRadius": "Avatare", + "background": "Hintergrund", + "bio": "Bio", + "btnRadius": "Buttons", + "cBlue": "Blau (Antworten, Folgt dir)", + "cGreen": "Grün (Retweet)", + "cOrange": "Orange (Favorisieren)", + "cRed": "Rot (Abbrechen)", + "change_password": "Passwort ändern", + "change_password_error": "Es gab ein Problem bei der Änderung des Passworts.", + "changed_password": "Passwort erfolgreich geändert!", + "collapse_subject": "Beiträge mit Betreff einklappen", + "confirm_new_password": "Neues Passwort bestätigen", + "current_avatar": "Dein derzeitiger Avatar", + "current_password": "Aktuelles Passwort", + "current_profile_banner": "Der derzeitige Banner deines Profils", + "data_import_export_tab": "Datenimport/-export", + "default_vis": "Standard-Sichtbarkeitsumfang", + "delete_account": "Account löschen", + "delete_account_description": "Lösche deinen Account und alle deine Nachrichten unwiderruflich.", + "delete_account_error": "Es ist ein Fehler beim Löschen deines Accounts aufgetreten. Tritt dies weiterhin auf, wende dich an den Administrator der Instanz.", + "delete_account_instructions": "Tippe dein Passwort unten in das Feld ein, um die Löschung deines Accounts zu bestätigen.", + "export_theme": "Farbschema speichern", + "filtering": "Filtern", + "filtering_explanation": "Alle Beiträge die diese Wörter enthalten werden ausgeblendet. Ein Wort pro Zeile.", + "follow_export": "Follower exportieren", + "follow_export_button": "Exportiere deine Follows in eine csv-Datei", + "follow_export_processing": "In Bearbeitung. Die Liste steht gleich zum herunterladen bereit.", + "follow_import": "Followers importieren", + "follow_import_error": "Fehler beim importieren der Follower", + "follows_imported": "Followers importiert! Die Bearbeitung kann eine Zeit lang dauern.", + "foreground": "Vordergrund", + "general": "Allgemein", + "hide_attachments_in_convo": "Anhänge in Unterhaltungen ausblenden", + "hide_attachments_in_tl": "Anhänge in der Zeitleiste ausblenden", + "hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)", + "hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)", + "import_followers_from_a_csv_file": "Importiere Follower, denen du folgen möchtest, aus einer CSV-Datei", + "import_theme": "Farbschema laden", + "inputRadius": "Eingabefelder", + "instance_default": "(Standard: {value})", + "interfaceLanguage": "Sprache der Oberfläche", + "invalid_theme_imported": "Die ausgewählte Datei ist kein unterstütztes Pleroma-Theme. Keine Änderungen wurden vorgenommen.", + "limited_availability": "In deinem Browser nicht verfügbar", + "links": "Links", + "lock_account_description": "Sperre deinen Account, um neue Follower zu genehmigen oder abzulehnen", + "loop_video": "Videos wiederholen", + "loop_video_silent_only": "Nur Videos ohne Ton wiederholen (z.B. Mastodons \"gifs\")", + "name": "Name", + "name_bio": "Name & Bio", + "new_password": "Neues Passwort", + "notification_visibility": "Benachrichtigungstypen, die angezeigt werden sollen", + "notification_visibility_follows": "Follows", + "notification_visibility_likes": "Favoriten", + "notification_visibility_mentions": "Erwähnungen", + "notification_visibility_repeats": "Wiederholungen", + "no_rich_text_description": "Rich-Text Formatierungen von allen Beiträgen entfernen", + "nsfw_clickthrough": "Aktiviere ausblendbares Overlay für Anhänge, die als NSFW markiert sind", + "panelRadius": "Panel", + "pause_on_unfocused": "Streaming pausieren, wenn das Tab nicht fokussiert ist", + "presets": "Voreinstellungen", + "profile_background": "Profilhintergrund", + "profile_banner": "Profilbanner", + "profile_tab": "Profil", + "radii_help": "Kantenrundung (in Pixel) der Oberfläche anpassen", + "replies_in_timeline": "Antworten in der Zeitleiste", + "reply_link_preview": "Antwortlink-Vorschau beim Überfahren mit der Maus aktivieren", + "reply_visibility_all": "Alle Antworten zeigen", + "reply_visibility_following": "Zeige nur Antworten an mich oder an Benutzer, denen ich folge", + "reply_visibility_self": "Nur Antworten an mich anzeigen", + "saving_err": "Fehler beim Speichern der Einstellungen", + "saving_ok": "Einstellungen gespeichert", + "security_tab": "Sicherheit", + "set_new_avatar": "Setze einen neuen Avatar", + "set_new_profile_background": "Setze einen neuen Hintergrund für dein Profil", + "set_new_profile_banner": "Setze einen neuen Banner für dein Profil", + "settings": "Einstellungen", + "stop_gifs": "Play-on-hover GIFs", + "streaming": "Aktiviere automatisches Laden (Streaming) von neuen Beiträgen", + "text": "Text", + "theme": "Farbschema", + "theme_help": "Benutze HTML-Farbcodes (#rrggbb) um dein Farbschema anzupassen", + "tooltipRadius": "Tooltips/Warnungen", + "user_settings": "Benutzereinstellungen", + "values": { + "false": "nein", + "true": "Ja" + } + }, + "timeline": { + "collapse": "Einklappen", + "conversation": "Unterhaltung", + "error_fetching": "Fehler beim Laden", + "load_older": "Lade ältere Beiträge", + "no_retweet_hint": "Der Beitrag ist als nur-für-Follower oder als Direktnachricht markiert und kann nicht wiederholt werden.", + "repeated": "wiederholte", + "show_new": "Zeige Neuere", + "up_to_date": "Aktuell" + }, + "user_card": { + "approve": "Genehmigen", + "block": "Blockieren", + "blocked": "Blockiert!", + "deny": "Ablehnen", + "follow": "Folgen", + "followees": "Folgt", + "followers": "Followers", + "following": "Folgst du!", + "follows_you": "Folgt dir!", + "mute": "Stummschalten", + "muted": "Stummgeschaltet", + "per_day": "pro Tag", + "remote_follow": "Folgen", + "statuses": "Beiträge" + }, + "user_profile": { + "timeline_title": "Beiträge" + }, + "who_to_follow": { + "more": "Mehr", + "who_to_follow": "Wem soll ich folgen" + } +} diff --git a/src/i18n/en.json b/src/i18n/en.json new file mode 100644 index 00000000..5b50b86d --- /dev/null +++ b/src/i18n/en.json @@ -0,0 +1,346 @@ +{ + "chat": { + "title": "Chat" + }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Media proxy", + "scope_options": "Scope options", + "text_limit": "Text limit", + "title": "Features", + "who_to_follow": "Who to follow" + }, + "finder": { + "error_fetching_user": "Error fetching user", + "find_user": "Find user" + }, + "general": { + "apply": "Apply", + "submit": "Submit" + }, + "login": { + "login": "Log in", + "description": "Log in with OAuth", + "logout": "Log out", + "password": "Password", + "placeholder": "e.g. lain", + "register": "Register", + "username": "Username" + }, + "nav": { + "chat": "Local Chat", + "friend_requests": "Follow Requests", + "mentions": "Mentions", + "dms": "Direct Messages", + "public_tl": "Public Timeline", + "timeline": "Timeline", + "twkn": "The Whole Known Network", + "user_search": "User Search", + "preferences": "Preferences" + }, + "notifications": { + "broken_favorite": "Unknown status, searching for it...", + "favorited_you": "favorited your status", + "followed_you": "followed you", + "load_older": "Load older notifications", + "notifications": "Notifications", + "read": "Read!", + "repeated_you": "repeated your status" + }, + "post_status": { + "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.", + "account_not_locked_warning_link": "locked", + "attachments_sensitive": "Mark attachments as sensitive", + "content_type": { + "plain_text": "Plain text" + }, + "content_warning": "Subject (optional)", + "default": "Just landed in L.A.", + "direct_warning": "This post will only be visible to all the mentioned users.", + "posting": "Posting", + "scope": { + "direct": "Direct - Post to mentioned users only", + "private": "Followers-only - Post to followers only", + "public": "Public - Post to public timelines", + "unlisted": "Unlisted - Do not post to public timelines" + } + }, + "registration": { + "bio": "Bio", + "email": "Email", + "fullname": "Display name", + "password_confirm": "Password confirmation", + "registration": "Registration", + "token": "Invite token", + "validations": { + "username_required": "cannot be left blank", + "fullname_required": "cannot be left blank", + "email_required": "cannot be left blank", + "password_required": "cannot be left blank", + "password_confirmation_required": "cannot be left blank", + "password_confirmation_match": "should be the same as password" + } + }, + "settings": { + "attachmentRadius": "Attachments", + "attachments": "Attachments", + "autoload": "Enable automatic loading when scrolled to the bottom", + "avatar": "Avatar", + "avatarAltRadius": "Avatars (Notifications)", + "avatarRadius": "Avatars", + "background": "Background", + "bio": "Bio", + "btnRadius": "Buttons", + "cBlue": "Blue (Reply, follow)", + "cGreen": "Green (Retweet)", + "cOrange": "Orange (Favorite)", + "cRed": "Red (Cancel)", + "change_password": "Change Password", + "change_password_error": "There was an issue changing your password.", + "changed_password": "Password changed successfully!", + "collapse_subject": "Collapse posts with subjects", + "composing": "Composing", + "confirm_new_password": "Confirm new password", + "current_avatar": "Your current avatar", + "current_password": "Current password", + "current_profile_banner": "Your current profile banner", + "data_import_export_tab": "Data Import / Export", + "default_vis": "Default visibility scope", + "delete_account": "Delete Account", + "delete_account_description": "Permanently delete your account and all your messages.", + "delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.", + "delete_account_instructions": "Type your password in the input below to confirm account deletion.", + "export_theme": "Save preset", + "filtering": "Filtering", + "filtering_explanation": "All statuses containing these words will be muted, one per line", + "follow_export": "Follow export", + "follow_export_button": "Export your follows to a csv file", + "follow_export_processing": "Processing, you'll soon be asked to download your file", + "follow_import": "Follow import", + "follow_import_error": "Error importing followers", + "follows_imported": "Follows imported! Processing them will take a while.", + "foreground": "Foreground", + "general": "General", + "hide_attachments_in_convo": "Hide attachments in conversations", + "hide_attachments_in_tl": "Hide attachments in timeline", + "hide_isp": "Hide instance-specific panel", + "preload_images": "Preload images", + "hide_post_stats": "Hide post statistics (e.g. the number of favorites)", + "hide_user_stats": "Hide user statistics (e.g. the number of followers)", + "import_followers_from_a_csv_file": "Import follows from a csv file", + "import_theme": "Load preset", + "inputRadius": "Input fields", + "checkboxRadius": "Checkboxes", + "instance_default": "(default: {value})", + "instance_default_simple": "(default)", + "interface": "Interface", + "interfaceLanguage": "Interface language", + "invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.", + "limited_availability": "Unavailable in your browser", + "links": "Links", + "lock_account_description": "Restrict your account to approved followers only", + "loop_video": "Loop videos", + "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", + "name": "Name", + "name_bio": "Name & Bio", + "new_password": "New password", + "notification_visibility": "Types of notifications to show", + "notification_visibility_follows": "Follows", + "notification_visibility_likes": "Likes", + "notification_visibility_mentions": "Mentions", + "notification_visibility_repeats": "Repeats", + "no_rich_text_description": "Strip rich text formatting from all posts", + "hide_network_description": "Don't show who I'm following and who's following me", + "nsfw_clickthrough": "Enable clickthrough NSFW attachment hiding", + "panelRadius": "Panels", + "pause_on_unfocused": "Pause streaming when tab is not focused", + "presets": "Presets", + "profile_background": "Profile Background", + "profile_banner": "Profile Banner", + "profile_tab": "Profile", + "radii_help": "Set up interface edge rounding (in pixels)", + "replies_in_timeline": "Replies in timeline", + "reply_link_preview": "Enable reply-link preview on mouse hover", + "reply_visibility_all": "Show all replies", + "reply_visibility_following": "Only show replies directed at me or users I'm following", + "reply_visibility_self": "Only show replies directed at me", + "saving_err": "Error saving settings", + "saving_ok": "Settings saved", + "security_tab": "Security", + "scope_copy": "Copy scope when replying (DMs are always copied)", + "set_new_avatar": "Set new avatar", + "set_new_profile_background": "Set new profile background", + "set_new_profile_banner": "Set new profile banner", + "settings": "Settings", + "subject_input_always_show": "Always show subject field", + "subject_line_behavior": "Copy subject when replying", + "subject_line_email": "Like email: \"re: subject\"", + "subject_line_mastodon": "Like mastodon: copy as is", + "subject_line_noop": "Do not copy", + "stop_gifs": "Play-on-hover GIFs", + "streaming": "Enable automatic streaming of new posts when scrolled to the top", + "text": "Text", + "theme": "Theme", + "theme_help": "Use hex color codes (#rrggbb) to customize your color theme.", + "theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.", + "theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.", + "tooltipRadius": "Tooltips/alerts", + "user_settings": "User Settings", + "values": { + "false": "no", + "true": "yes" + }, + "notifications": "Notifications", + "enable_web_push_notifications": "Enable web push notifications", + "style": { + "switcher": { + "keep_color": "Keep colors", + "keep_shadows": "Keep shadows", + "keep_opacity": "Keep opacity", + "keep_roundness": "Keep roundness", + "keep_fonts": "Keep fonts", + "save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.", + "reset": "Reset", + "clear_all": "Clear all", + "clear_opacity": "Clear opacity" + }, + "common": { + "color": "Color", + "opacity": "Opacity", + "contrast": { + "hint": "Contrast ratio is {ratio}, it {level} {context}", + "level": { + "aa": "meets Level AA guideline (minimal)", + "aaa": "meets Level AAA guideline (recommended)", + "bad": "doesn't meet any accessibility guidelines" + }, + "context": { + "18pt": "for large (18pt+) text", + "text": "for text" + } + } + }, + "common_colors": { + "_tab_label": "Common", + "main": "Common colors", + "foreground_hint": "See \"Advanced\" tab for more detailed control", + "rgbo": "Icons, accents, badges" + }, + "advanced_colors": { + "_tab_label": "Advanced", + "alert": "Alert background", + "alert_error": "Error", + "badge": "Badge background", + "badge_notification": "Notification", + "panel_header": "Panel header", + "top_bar": "Top bar", + "borders": "Borders", + "buttons": "Buttons", + "inputs": "Input fields", + "faint_text": "Faded text" + }, + "radii": { + "_tab_label": "Roundness" + }, + "shadows": { + "_tab_label": "Shadow and lighting", + "component": "Component", + "override": "Override", + "shadow_id": "Shadow #{value}", + "blur": "Blur", + "spread": "Spread", + "inset": "Inset", + "hint": "For shadows you can also use --variable as a color value to use CSS3 variables. Please note that setting opacity won't work in this case.", + "filter_hint": { + "always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.", + "drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.", + "avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.", + "spread_zero": "Shadows with spread > 0 will appear as if it was set to zero", + "inset_classic": "Inset shadows will be using {0}" + }, + "components": { + "panel": "Panel", + "panelHeader": "Panel header", + "topBar": "Top bar", + "avatar": "User avatar (in profile view)", + "avatarStatus": "User avatar (in post display)", + "popup": "Popups and tooltips", + "button": "Button", + "buttonHover": "Button (hover)", + "buttonPressed": "Button (pressed)", + "buttonPressedHover": "Button (pressed+hover)", + "input": "Input field" + } + }, + "fonts": { + "_tab_label": "Fonts", + "help": "Select font to use for elements of UI. For \"custom\" you have to enter exact font name as it appears in system.", + "components": { + "interface": "Interface", + "input": "Input fields", + "post": "Post text", + "postCode": "Monospaced text in a post (rich text)" + }, + "family": "Font name", + "size": "Size (in px)", + "weight": "Weight (boldness)", + "custom": "Custom" + }, + "preview": { + "header": "Preview", + "content": "Content", + "error": "Example error", + "button": "Button", + "text": "A bunch of more {0} and {1}", + "mono": "content", + "input": "Just landed in L.A.", + "faint_link": "helpful manual", + "fine_print": "Read our {0} to learn nothing useful!", + "header_faint": "This is fine", + "checkbox": "I have skimmed over terms and conditions", + "link": "a nice lil' link" + } + } + }, + "timeline": { + "collapse": "Collapse", + "conversation": "Conversation", + "error_fetching": "Error fetching updates", + "load_older": "Load older statuses", + "no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated", + "repeated": "repeated", + "show_new": "Show new", + "up_to_date": "Up-to-date" + }, + "user_card": { + "approve": "Approve", + "block": "Block", + "blocked": "Blocked!", + "deny": "Deny", + "follow": "Follow", + "followees": "Following", + "followers": "Followers", + "following": "Following!", + "follows_you": "Follows you!", + "mute": "Mute", + "muted": "Muted", + "per_day": "per day", + "remote_follow": "Remote follow", + "statuses": "Statuses" + }, + "user_profile": { + "timeline_title": "User Timeline" + }, + "who_to_follow": { + "more": "More", + "who_to_follow": "Who to follow" + }, + "tool_tip": { + "media_upload": "Upload Media", + "repeat": "Repeat", + "reply": "Reply", + "favorite": "Favorite", + "user_settings": "User Settings" + } +} diff --git a/src/i18n/eo.json b/src/i18n/eo.json new file mode 100644 index 00000000..ed4b50e3 --- /dev/null +++ b/src/i18n/eo.json @@ -0,0 +1,119 @@ +{ + "chat": { + "title": "Babilejo" + }, + "finder": { + "error_fetching_user": "Eraro alportante uzanton", + "find_user": "Trovi uzanton" + }, + "general": { + "apply": "Apliki", + "submit": "Sendi" + }, + "login": { + "login": "Ensaluti", + "logout": "Elsaluti", + "password": "Pasvorto", + "placeholder": "ekz. lain", + "register": "Registriĝi", + "username": "Salutnomo" + }, + "nav": { + "chat": "Loka babilejo", + "mentions": "Mencioj", + "public_tl": "Publika tempolinio", + "timeline": "Tempolinio", + "twkn": "La tuta konata reto" + }, + "notifications": { + "favorited_you": "ŝatis vian staton", + "followed_you": "ekabonis vin", + "notifications": "Sciigoj", + "read": "Legite!", + "repeated_you": "ripetis vian staton" + }, + "post_status": { + "default": "Ĵus alvenis al la Universala Kongreso!", + "posting": "Afiŝante" + }, + "registration": { + "bio": "Priskribo", + "email": "Retpoŝtadreso", + "fullname": "Vidiga nomo", + "password_confirm": "Konfirmo de pasvorto", + "registration": "Registriĝo" + }, + "settings": { + "attachmentRadius": "Kunsendaĵoj", + "attachments": "Kunsendaĵoj", + "autoload": "Ŝalti memfaran ŝarĝadon ĉe subo de paĝo", + "avatar": "Profilbildo", + "avatarAltRadius": "Profilbildoj (sciigoj)", + "avatarRadius": "Profilbildoj", + "background": "Fono", + "bio": "Priskribo", + "btnRadius": "Butonoj", + "cBlue": "Blua (Respondo, abono)", + "cGreen": "Verda (Kunhavigo)", + "cOrange": "Oranĝa (Ŝato)", + "cRed": "Ruĝa (Nuligo)", + "current_avatar": "Via nuna profilbildo", + "current_profile_banner": "Via nuna profila rubando", + "filtering": "Filtrado", + "filtering_explanation": "Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos, po unu linie", + "follow_import": "Abona enporto", + "follow_import_error": "Eraro enportante abonojn", + "follows_imported": "Abonoj enportiĝis! Traktado daŭros iom.", + "foreground": "Malfono", + "hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj", + "hide_attachments_in_tl": "Kaŝi kunsendaĵojn en tempolinio", + "import_followers_from_a_csv_file": "Enporti abonojn el CSV-dosiero", + "links": "Ligiloj", + "name": "Nomo", + "name_bio": "Nomo kaj priskribo", + "nsfw_clickthrough": "Ŝalti traklakan kaŝon de konsternaj kunsendaĵoj", + "panelRadius": "Paneloj", + "presets": "Antaŭagordoj", + "profile_background": "Profila fono", + "profile_banner": "Profila rubando", + "radii_help": "Agordi fasadan rondigon de randoj (rastrumere)", + "reply_link_preview": "Ŝalti respond-ligilan antaŭvidon dum ŝvebo", + "set_new_avatar": "Agordi novan profilbildon", + "set_new_profile_background": "Agordi novan profilan fonon", + "set_new_profile_banner": "Agordi novan profilan rubandon", + "settings": "Agordoj", + "stop_gifs": "Movi GIF-bildojn dum ŝvebo", + "streaming": "Ŝalti memfaran fluigon de novaj afiŝoj ĉe la supro de la paĝo", + "text": "Teksto", + "theme": "Etoso", + "theme_help": "Uzu deksesumajn kolorkodojn (#rrvvbb) por adapti vian koloran etoson.", + "tooltipRadius": "Ŝpruchelpiloj/avertoj", + "user_settings": "Uzantaj agordoj" + }, + "timeline": { + "collapse": "Maletendi", + "conversation": "Interparolo", + "error_fetching": "Eraro dum ĝisdatigo", + "load_older": "Montri pli malnovajn statojn", + "repeated": "ripetata", + "show_new": "Montri novajn", + "up_to_date": "Ĝisdata" + }, + "user_card": { + "block": "Bari", + "blocked": "Barita!", + "follow": "Aboni", + "followees": "Abonatoj", + "followers": "Abonantoj", + "following": "Abonanta!", + "follows_you": "Abonas vin!", + "mute": "Silentigi", + "muted": "Silentigitaj", + "per_day": "tage", + "remote_follow": "Fore aboni", + "statuses": "Statoj" + }, + "user_profile": { + "timeline_title": "Uzanta tempolinio" + } +} diff --git a/src/i18n/es.json b/src/i18n/es.json new file mode 100644 index 00000000..3391c6af --- /dev/null +++ b/src/i18n/es.json @@ -0,0 +1,100 @@ +{ + "chat": { + "title": "Chat" + }, + "finder": { + "error_fetching_user": "Error al buscar usuario", + "find_user": "Encontrar usuario" + }, + "general": { + "apply": "Aplicar", + "submit": "Enviar" + }, + "login": { + "login": "Identificación", + "logout": "Salir", + "password": "Contraseña", + "placeholder": "p.ej. lain", + "register": "Registrar", + "username": "Usuario" + }, + "nav": { + "chat": "Chat Local", + "mentions": "Menciones", + "public_tl": "Línea Temporal Pública", + "timeline": "Línea Temporal", + "twkn": "Toda La Red Conocida" + }, + "notifications": { + "followed_you": "empezó a seguirte", + "notifications": "Notificaciones", + "read": "¡Leído!" + }, + "post_status": { + "default": "Acabo de aterrizar en L.A.", + "posting": "Publicando" + }, + "registration": { + "bio": "Biografía", + "email": "Correo electrónico", + "fullname": "Nombre a mostrar", + "password_confirm": "Confirmación de contraseña", + "registration": "Registro" + }, + "settings": { + "attachments": "Adjuntos", + "autoload": "Activar carga automática al llegar al final de la página", + "avatar": "Avatar", + "background": "Segundo plano", + "bio": "Biografía", + "current_avatar": "Tu avatar actual", + "current_profile_banner": "Cabecera actual", + "filtering": "Filtros", + "filtering_explanation": "Todos los estados que contengan estas palabras serán silenciados, una por línea", + "follow_import": "Importar personas que tú sigues", + "follow_import_error": "Error al importal el archivo", + "follows_imported": "¡Importado! Procesarlos llevará tiempo.", + "foreground": "Primer plano", + "hide_attachments_in_convo": "Ocultar adjuntos en las conversaciones", + "hide_attachments_in_tl": "Ocultar adjuntos en la línea temporal", + "import_followers_from_a_csv_file": "Importar personas que tú sigues apartir de un archivo csv", + "links": "Links", + "name": "Nombre", + "name_bio": "Nombre y Biografía", + "nsfw_clickthrough": "Activar el clic para ocultar los adjuntos NSFW", + "presets": "Por defecto", + "profile_background": "Fondo del Perfil", + "profile_banner": "Cabecera del perfil", + "reply_link_preview": "Activar la previsualización del enlace de responder al pasar el ratón por encima", + "set_new_avatar": "Cambiar avatar", + "set_new_profile_background": "Cambiar fondo del perfil", + "set_new_profile_banner": "Cambiar cabecera", + "settings": "Ajustes", + "streaming": "Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior", + "text": "Texto", + "theme": "Tema", + "theme_help": "Use códigos de color hexadecimales (#rrggbb) para personalizar su tema de colores.", + "user_settings": "Ajustes de Usuario" + }, + "timeline": { + "conversation": "Conversación", + "error_fetching": "Error al cargar las actualizaciones", + "load_older": "Cargar actualizaciones anteriores", + "show_new": "Mostrar lo nuevo", + "up_to_date": "Actualizado" + }, + "user_card": { + "block": "Bloquear", + "blocked": "¡Bloqueado!", + "follow": "Seguir", + "followees": "Siguiendo", + "followers": "Seguidores", + "following": "¡Siguiendo!", + "follows_you": "¡Te sigue!", + "mute": "Silenciar", + "muted": "Silenciado", + "per_day": "por día", + "remote_follow": "Seguir", + "statuses": "Estados" + } +} diff --git a/src/i18n/et.json b/src/i18n/et.json new file mode 100644 index 00000000..5262b2a4 --- /dev/null +++ b/src/i18n/et.json @@ -0,0 +1,83 @@ +{ + "finder": { + "error_fetching_user": "Viga kasutaja leidmisel", + "find_user": "Otsi kasutajaid" + }, + "general": { + "submit": "Postita" + }, + "login": { + "login": "Logi sisse", + "logout": "Logi välja", + "password": "Parool", + "placeholder": "nt lain", + "register": "Registreeru", + "username": "Kasutajanimi" + }, + "nav": { + "mentions": "Mainimised", + "public_tl": "Avalik Ajajoon", + "timeline": "Ajajoon", + "twkn": "Kogu Teadaolev Võrgustik" + }, + "notifications": { + "followed_you": "alustas sinu jälgimist", + "notifications": "Teavitused", + "read": "Loe!" + }, + "post_status": { + "default": "Just sõitsin elektrirongiga Tallinnast Pääskülla.", + "posting": "Postitan" + }, + "registration": { + "bio": "Bio", + "email": "E-post", + "fullname": "Kuvatav nimi", + "password_confirm": "Parooli kinnitamine", + "registration": "Registreerimine" + }, + "settings": { + "attachments": "Manused", + "autoload": "Luba ajajoone automaatne uuendamine kui ajajoon on põhja keritud", + "avatar": "Profiilipilt", + "bio": "Bio", + "current_avatar": "Sinu praegune profiilipilt", + "current_profile_banner": "Praegune profiilibänner", + "filtering": "Sisu filtreerimine", + "filtering_explanation": "Kõiki staatuseid, mis sisaldavad neid sõnu, ei kuvata. Üks sõna reale.", + "hide_attachments_in_convo": "Peida manused vastlustes", + "hide_attachments_in_tl": "Peida manused ajajoonel", + "name": "Nimi", + "name_bio": "Nimi ja Bio", + "nsfw_clickthrough": "Peida tööks-mittesobivad(NSFW) manuste hiireklõpsu taha", + "profile_background": "Profiilitaust", + "profile_banner": "Profiilibänner", + "reply_link_preview": "Luba algpostituse kuvamine vastustes", + "set_new_avatar": "Vali uus profiilipilt", + "set_new_profile_background": "Vali uus profiilitaust", + "set_new_profile_banner": "Vali uus profiilibänner", + "settings": "Sätted", + "theme": "Teema", + "user_settings": "Kasutaja sätted" + }, + "timeline": { + "conversation": "Vestlus", + "error_fetching": "Viga uuenduste laadimisel", + "load_older": "Kuva vanemaid staatuseid", + "show_new": "Näita uusi", + "up_to_date": "Uuendatud" + }, + "user_card": { + "block": "Blokeeri", + "blocked": "Blokeeritud!", + "follow": "Jälgi", + "followees": "Jälgitavaid", + "followers": "Jälgijaid", + "following": "Jälgin!", + "follows_you": "Jälgib sind!", + "mute": "Vaigista", + "muted": "Vaigistatud", + "per_day": "päevas", + "statuses": "Staatuseid" + } +} diff --git a/src/i18n/fi.json b/src/i18n/fi.json new file mode 100644 index 00000000..08cfb617 --- /dev/null +++ b/src/i18n/fi.json @@ -0,0 +1,93 @@ +{ + "finder": { + "error_fetching_user": "Virhe hakiessa käyttäjää", + "find_user": "Hae käyttäjä" + }, + "general": { + "apply": "Aseta", + "submit": "Lähetä" + }, + "login": { + "login": "Kirjaudu sisään", + "logout": "Kirjaudu ulos", + "password": "Salasana", + "placeholder": "esim. lain", + "register": "Rekisteröidy", + "username": "Käyttäjänimi" + }, + "nav": { + "mentions": "Maininnat", + "public_tl": "Julkinen Aikajana", + "timeline": "Aikajana", + "twkn": "Koko Tunnettu Verkosto" + }, + "notifications": { + "favorited_you": "tykkäsi viestistäsi", + "followed_you": "seuraa sinua", + "notifications": "Ilmoitukset", + "read": "Lue!", + "repeated_you": "toisti viestisi" + }, + "post_status": { + "default": "Tulin juuri saunasta.", + "posting": "Lähetetään" + }, + "registration": { + "bio": "Kuvaus", + "email": "Sähköposti", + "fullname": "Koko nimi", + "password_confirm": "Salasanan vahvistaminen", + "registration": "Rekisteröityminen" + }, + "settings": { + "attachments": "Liitteet", + "autoload": "Lataa vanhempia viestejä automaattisesti ruudun pohjalla", + "avatar": "Profiilikuva", + "background": "Tausta", + "bio": "Kuvaus", + "current_avatar": "Nykyinen profiilikuvasi", + "current_profile_banner": "Nykyinen julisteesi", + "filtering": "Suodatus", + "filtering_explanation": "Kaikki viestit, jotka sisältävät näitä sanoja, suodatetaan. Yksi sana per rivi.", + "foreground": "Korostus", + "hide_attachments_in_convo": "Piilota liitteet keskusteluissa", + "hide_attachments_in_tl": "Piilota liitteet aikajanalla", + "links": "Linkit", + "name": "Nimi", + "name_bio": "Nimi ja kuvaus", + "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse.", + "presets": "Valmiit teemat", + "profile_background": "Taustakuva", + "profile_banner": "Juliste", + "reply_link_preview": "Keskusteluiden vastauslinkkien esikatselu", + "set_new_avatar": "Aseta uusi profiilikuva", + "set_new_profile_background": "Aseta uusi taustakuva", + "set_new_profile_banner": "Aseta uusi juliste", + "settings": "Asetukset", + "streaming": "Näytä uudet viestit automaattisesti ollessasi ruudun huipulla", + "text": "Teksti", + "theme": "Teema", + "theme_help": "Käytä heksadesimaalivärejä muokataksesi väriteemaasi.", + "user_settings": "Käyttäjän asetukset" + }, + "timeline": { + "collapse": "Sulje", + "conversation": "Keskustelu", + "error_fetching": "Virhe ladatessa viestejä", + "load_older": "Lataa vanhempia viestejä", + "repeated": "toisti", + "show_new": "Näytä uudet", + "up_to_date": "Ajantasalla" + }, + "user_card": { + "follow": "Seuraa", + "followees": "Seuraa", + "followers": "Seuraajat", + "following": "Seuraat!", + "follows_you": "Seuraa sinua!", + "mute": "Hiljennä", + "muted": "Hiljennetty", + "per_day": "päivässä", + "statuses": "Viestit" + } +} diff --git a/src/i18n/fr.json b/src/i18n/fr.json new file mode 100644 index 00000000..129b7d7c --- /dev/null +++ b/src/i18n/fr.json @@ -0,0 +1,204 @@ +{ + "chat": { + "title": "Chat" + }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Proxy média", + "scope_options": "Options de visibilité", + "text_limit": "Limite du texte", + "title": "Caractéristiques", + "who_to_follow": "Qui s'abonner" + }, + "finder": { + "error_fetching_user": "Erreur lors de la recherche de l'utilisateur", + "find_user": "Chercher un utilisateur" + }, + "general": { + "apply": "Appliquer", + "submit": "Envoyer" + }, + "login": { + "login": "Connexion", + "description": "Connexion avec OAuth", + "logout": "Déconnexion", + "password": "Mot de passe", + "placeholder": "p.e. lain", + "register": "S'inscrire", + "username": "Identifiant" + }, + "nav": { + "chat": "Chat local", + "friend_requests": "Demandes d'ami", + "dms": "Messages adressés", + "mentions": "Notifications", + "public_tl": "Statuts locaux", + "timeline": "Journal", + "twkn": "Le réseau connu" + }, + "notifications": { + "broken_favorite": "Chargement d'un message inconnu ...", + "favorited_you": "a aimé votre statut", + "followed_you": "a commencé à vous suivre", + "load_older": "Charger les notifications précédentes", + "notifications": "Notifications", + "read": "Lu !", + "repeated_you": "a partagé votre statut" + }, + "post_status": { + "account_not_locked_warning": "Votre compte n'est pas {0}. N'importe qui peut vous suivre pour voir vos billets en Abonné·e·s uniquement.", + "account_not_locked_warning_link": "verrouillé", + "attachments_sensitive": "Marquer le média comme sensible", + "content_type": { + "plain_text": "Texte brut" + }, + "content_warning": "Sujet (optionnel)", + "default": "Écrivez ici votre prochain statut.", + "direct_warning": "Ce message sera visible à toutes les personnes mentionnées.", + "posting": "Envoi en cours", + "scope": { + "direct": "Direct - N'envoyer qu'aux personnes mentionnées", + "private": "Abonné·e·s uniquement - Seul·e·s vos abonné·e·s verront vos billets", + "public": "Publique - Afficher dans les fils publics", + "unlisted": "Non-Listé - Ne pas afficher dans les fils publics" + } + }, + "registration": { + "bio": "Biographie", + "email": "Adresse email", + "fullname": "Pseudonyme", + "password_confirm": "Confirmation du mot de passe", + "registration": "Inscription", + "token": "Jeton d'invitation" + }, + "settings": { + "attachmentRadius": "Pièces jointes", + "attachments": "Pièces jointes", + "autoload": "Charger la suite automatiquement une fois le bas de la page atteint", + "avatar": "Avatar", + "avatarAltRadius": "Avatars (Notifications)", + "avatarRadius": "Avatars", + "background": "Arrière-plan", + "bio": "Biographie", + "btnRadius": "Boutons", + "cBlue": "Bleu (Répondre, suivre)", + "cGreen": "Vert (Partager)", + "cOrange": "Orange (Aimer)", + "cRed": "Rouge (Annuler)", + "change_password": "Changez votre mot de passe", + "change_password_error": "Il y a eu un problème pour changer votre mot de passe.", + "changed_password": "Mot de passe modifié avec succès !", + "collapse_subject": "Réduire les messages avec des sujets", + "confirm_new_password": "Confirmation du nouveau mot de passe", + "current_avatar": "Avatar actuel", + "current_password": "Mot de passe actuel", + "current_profile_banner": "Bannière de profil actuelle", + "data_import_export_tab": "Import / Export des Données", + "default_vis": "Portée de visibilité par défaut", + "delete_account": "Supprimer le compte", + "delete_account_description": "Supprimer définitivement votre compte et tous vos statuts.", + "delete_account_error": "Il y a eu un problème lors de la tentative de suppression de votre compte. Si le problème persiste, contactez l'administrateur de cette instance.", + "delete_account_instructions": "Indiquez votre mot de passe ci-dessous pour confirmer la suppression de votre compte.", + "export_theme": "Enregistrer le thème", + "filtering": "Filtre", + "filtering_explanation": "Tous les statuts contenant ces mots seront masqués. Un mot par ligne", + "follow_export": "Exporter les abonnements", + "follow_export_button": "Exporter les abonnements en csv", + "follow_export_processing": "Exportation en cours…", + "follow_import": "Importer des abonnements", + "follow_import_error": "Erreur lors de l'importation des abonnements", + "follows_imported": "Abonnements importés ! Le traitement peut prendre un moment.", + "foreground": "Premier plan", + "general": "Général", + "hide_attachments_in_convo": "Masquer les pièces jointes dans les conversations", + "hide_attachments_in_tl": "Masquer les pièces jointes dans le journal", + "hide_post_stats": "Masquer les statistiques de publication (le nombre de favoris)", + "hide_user_stats": "Masquer les statistiques de profil (le nombre d'amis)", + "import_followers_from_a_csv_file": "Importer des abonnements depuis un fichier csv", + "import_theme": "Charger le thème", + "inputRadius": "Champs de texte", + "instance_default": "(default: {value})", + "instance_default_simple" : "(default)", + "interfaceLanguage": "Langue de l'interface", + "invalid_theme_imported": "Le fichier sélectionné n'est pas un thème Pleroma pris en charge. Aucun changement n'a été apporté à votre thème.", + "limited_availability": "Non disponible dans votre navigateur", + "links": "Liens", + "lock_account_description": "Limitez votre compte aux abonnés acceptés uniquement", + "loop_video": "Vidéos en boucle", + "loop_video_silent_only": "Boucle uniquement les vidéos sans le son (les «gifs» de Mastodon)", + "name": "Nom", + "name_bio": "Nom & Bio", + "new_password": "Nouveau mot de passe", + "no_rich_text_description": "Ne formatez pas le texte", + "notification_visibility": "Types de notifications à afficher", + "notification_visibility_follows": "Abonnements", + "notification_visibility_likes": "J’aime", + "notification_visibility_mentions": "Mentionnés", + "notification_visibility_repeats": "Partages", + "nsfw_clickthrough": "Masquer les images marquées comme contenu adulte ou sensible", + "panelRadius": "Fenêtres", + "pause_on_unfocused": "Suspendre le streaming lorsque l'onglet n'est pas centré", + "presets": "Thèmes prédéfinis", + "profile_background": "Image de fond", + "profile_banner": "Bannière de profil", + "profile_tab": "Profil", + "radii_help": "Vous pouvez ici choisir le niveau d'arrondi des angles de l'interface (en pixels)", + "replies_in_timeline": "Réponses au journal", + "reply_link_preview": "Afficher un aperçu lors du survol de liens vers une réponse", + "reply_visibility_all": "Montrer toutes les réponses", + "reply_visibility_following": "Afficher uniquement les réponses adressées à moi ou aux utilisateurs que je suis", + "reply_visibility_self": "Afficher uniquement les réponses adressées à moi", + "saving_err": "Erreur lors de l'enregistrement des paramètres", + "saving_ok": "Paramètres enregistrés", + "security_tab": "Sécurité", + "set_new_avatar": "Changer d'avatar", + "set_new_profile_background": "Changer d'image de fond", + "set_new_profile_banner": "Changer de bannière", + "settings": "Paramètres", + "stop_gifs": "N'animer les GIFS que lors du survol du curseur de la souris", + "streaming": "Charger automatiquement les nouveaux statuts lorsque vous êtes au haut de la page", + "text": "Texte", + "theme": "Thème", + "theme_help": "Spécifiez des codes couleur hexadécimaux (#rrvvbb) pour personnaliser les couleurs du thème.", + "tooltipRadius": "Info-bulles/alertes", + "user_settings": "Paramètres utilisateur", + "values": { + "false": "non", + "true": "oui" + } + }, + "timeline": { + "collapse": "Fermer", + "conversation": "Conversation", + "error_fetching": "Erreur en cherchant les mises à jour", + "load_older": "Afficher plus", + "no_retweet_hint": "Le message est marqué en abonnés-seulement ou direct et ne peut pas être répété", + "repeated": "a partagé", + "show_new": "Afficher plus", + "up_to_date": "À jour" + }, + "user_card": { + "approve": "Accepter", + "block": "Bloquer", + "blocked": "Bloqué !", + "deny": "Rejeter", + "follow": "Suivre", + "followees": "Suivis", + "followers": "Vous suivent", + "following": "Suivi !", + "follows_you": "Vous suit !", + "mute": "Masquer", + "muted": "Masqué", + "per_day": "par jour", + "remote_follow": "Suivre d'une autre instance", + "statuses": "Statuts" + }, + "user_profile": { + "timeline_title": "Journal de l'utilisateur" + }, + "who_to_follow": { + "more": "Plus", + "who_to_follow": "Qui s'abonner" + } +} diff --git a/src/i18n/ga.json b/src/i18n/ga.json new file mode 100644 index 00000000..64461202 --- /dev/null +++ b/src/i18n/ga.json @@ -0,0 +1,201 @@ +{ + "chat": { + "title": "Comhrá" + }, + "features_panel": { + "chat": "Comhrá", + "gopher": "Gófar", + "media_proxy": "Seachfhreastalaí meáin", + "scope_options": "Rogha scóip", + "text_limit": "Teorainn Téacs", + "title": "Gnéithe", + "who_to_follow": "Daoine le leanúint" + }, + "finder": { + "error_fetching_user": "Earráid a aimsiú d'úsáideoir", + "find_user": "Aimsigh úsáideoir" + }, + "general": { + "apply": "Feidhmigh", + "submit": "Deimhnigh" + }, + "login": { + "login": "Logáil isteach", + "logout": "Logáil amach", + "password": "Pasfhocal", + "placeholder": "m.sh. Daire", + "register": "Clárú", + "username": "Ainm Úsáideora" + }, + "nav": { + "chat": "Comhrá Áitiúil", + "friend_requests": "Iarratas ar Cairdeas", + "mentions": "Tagairt", + "public_tl": "Amlíne Poiblí", + "timeline": "Amlíne", + "twkn": "An Líonra Iomlán" + }, + "notifications": { + "broken_favorite": "Post anaithnid. Cuardach dó...", + "favorited_you": "toghadh le do phost", + "followed_you": "lean tú", + "load_older": "Luchtaigh fógraí aosta", + "notifications": "Fógraí", + "read": "Léigh!", + "repeated_you": "athphostáil tú" + }, + "post_status": { + "account_not_locked_warning": "Níl do chuntas {0}. Is féidir le duine ar bith a leanúint leat chun do phoist leantacha amháin a fheiceáil.", + "account_not_locked_warning_link": "faoi glas", + "attachments_sensitive": "Marcáil ceangaltán mar íogair", + "content_type": { + "plain_text": "Gnáth-théacs" + }, + "content_warning": "Teideal (roghnach)", + "default": "Lá iontach anseo i nGaillimh", + "direct_warning": "Ní bheidh an post seo le feiceáil ach amháin do na húsáideoirí atá luaite.", + "posting": "Post nua", + "scope": { + "direct": "Díreach - Post chuig úsáideoirí luaite amháin", + "private": "Leanúna amháin - Post chuig lucht leanúna amháin", + "public": "Poiblí - Post chuig amlínte poiblí", + "unlisted": "Neamhliostaithe - Ná cuir post chuig amlínte poiblí" + } + }, + "registration": { + "bio": "Scéal saoil", + "email": "Ríomhphost", + "fullname": "Ainm taispeána'", + "password_confirm": "Deimhnigh do pasfhocal", + "registration": "Clárú", + "token": "Cód cuireadh" + }, + "settings": { + "attachmentRadius": "Ceangaltáin", + "attachments": "Ceangaltáin", + "autoload": "Cumasaigh luchtú uathoibríoch nuair a scrollaítear go bun", + "avatar": "Phictúir phrófíle", + "avatarAltRadius": "Phictúirí phrófíle (Fograí)", + "avatarRadius": "Phictúirí phrófíle", + "background": "Cúlra", + "bio": "Scéal saoil", + "btnRadius": "Cnaipí", + "cBlue": "Gorm (Freagra, lean)", + "cGreen": "Glas (Athphóstail)", + "cOrange": "Oráiste (Cosúil)", + "cRed": "Dearg (Cealaigh)", + "change_password": "Athraigh do pasfhocal", + "change_password_error": "Bhí fadhb ann ag athrú do pasfhocail", + "changed_password": "Athraigh an pasfhocal go rathúil!", + "collapse_subject": "Poist a chosc le teidil", + "confirm_new_password": "Deimhnigh do pasfhocal nua", + "current_avatar": "Phictúir phrófíle", + "current_password": "Pasfhocal reatha", + "current_profile_banner": "Phictúir ceanntáisc", + "data_import_export_tab": "Iompórtáil / Easpórtáil Sonraí", + "default_vis": "Scóip infheicthe réamhshocraithe", + "delete_account": "Scrios cuntas", + "delete_account_description": "Do chuntas agus do chuid teachtaireachtaí go léir a scriosadh go buan.", + "delete_account_error": "Bhí fadhb ann a scriosadh do chuntas. Má leanann sé seo, téigh i dteagmháil le do riarthóir.", + "delete_account_instructions": "Scríobh do phasfhocal san ionchur thíos chun deimhniú a scriosadh.", + "export_theme": "Sábháil Téama", + "filtering": "Scagadh", + "filtering_explanation": "Beidh gach post ina bhfuil na focail seo i bhfolach, ceann in aghaidh an líne", + "follow_export": "Easpórtáil do leanann", + "follow_export_button": "Easpórtáil do leanann chuig comhad csv", + "follow_export_processing": "Próiseáil. Iarrtar ort go luath an comhad a íoslódáil.", + "follow_import": "Iompórtáil do leanann", + "follow_import_error": "Earráid agus do leanann a iompórtáil", + "follows_imported": "Do leanann iompórtáil! Tógfaidh an próiseas iad le tamall.", + "foreground": "Tulra", + "general": "Ginearálta", + "hide_attachments_in_convo": "Folaigh ceangaltáin i comhráite", + "hide_attachments_in_tl": "Folaigh ceangaltáin sa amlíne", + "hide_post_stats": "Folaigh staitisticí na bpost (m.sh. líon na n-athrá)", + "hide_user_stats": "Folaigh na staitisticí úsáideora (m.sh. líon na leantóiri)", + "import_followers_from_a_csv_file": "Iompórtáil leanann ó chomhad csv", + "import_theme": "Luchtaigh Téama", + "inputRadius": "Limistéar iontrála", + "instance_default": "(Réamhshocrú: {value})", + "interfaceLanguage": "Teanga comhéadain", + "invalid_theme_imported": "Ní téama bailí é an comhad dícheangailte. Níor rinneadh aon athruithe.", + "limited_availability": "Níl sé ar fáil i do bhrabhsálaí", + "links": "Naisc", + "lock_account_description": "Srian a chur ar do chuntas le lucht leanúna ceadaithe amháin", + "loop_video": "Lúb físeáin", + "loop_video_silent_only": "Lúb físeáin amháin gan fuaim (i.e. Mastodon's \"gifs\")", + "name": "Ainm", + "name_bio": "Ainm ⁊ Scéal", + "new_password": "Pasfhocal nua'", + "notification_visibility": "Cineálacha fógraí a thaispeáint", + "notification_visibility_follows": "Leana", + "notification_visibility_likes": "Thaithin", + "notification_visibility_mentions": "Tagairt", + "notification_visibility_repeats": "Atphostáil", + "no_rich_text_description": "Bain formáidiú téacs saibhir ó gach post", + "nsfw_clickthrough": "Cumasaigh an ceangaltán NSFW cliceáil ar an gcnaipe", + "panelRadius": "Painéil", + "pause_on_unfocused": "Sruthú ar sos nuair a bhíonn an fócas caillte", + "presets": "Réamhshocruithe", + "profile_background": "Cúlra Próifíl", + "profile_banner": "Phictúir Ceanntáisc", + "profile_tab": "Próifíl", + "radii_help": "Cruinniú imeall comhéadan a chumrú (i bpicteilíní)", + "replies_in_timeline": "Freagraí sa amlíne", + "reply_link_preview": "Cumasaigh réamhamharc nasc freagartha ar chlár na luiche", + "reply_visibility_all": "Taispeáin gach freagra", + "reply_visibility_following": "Taispeáin freagraí amháin atá dírithe ar mise nó ar úsáideoirí atá mé ag leanúint", + "reply_visibility_self": "Taispeáin freagraí amháin atá dírithe ar mise", + "saving_err": "Earráid socruithe a shábháil", + "saving_ok": "Socruithe sábháilte", + "security_tab": "Slándáil", + "set_new_avatar": "Athraigh do phictúir phrófíle", + "set_new_profile_background": "Athraigh do cúlra próifíl", + "set_new_profile_banner": "Athraigh do phictúir ceanntáisc", + "settings": "Socruithe", + "stop_gifs": "Seinn GIFs ar an scáileán", + "streaming": "Cumasaigh post nua a shruthú uathoibríoch nuair a scrollaítear go barr an leathanaigh", + "text": "Téacs", + "theme": "Téama", + "theme_help": "Úsáid cód daith hex (#rrggbb) chun do schéim a saincheapadh", + "tooltipRadius": "Bileoga eolais", + "user_settings": "Socruithe úsáideora", + "values": { + "false": "níl", + "true": "tá" + } + }, + "timeline": { + "collapse": "Folaigh", + "conversation": "Cómhra", + "error_fetching": "Earráid a thabhairt cothrom le dáta", + "load_older": "Luchtaigh níos mó", + "no_retweet_hint": "Tá an post seo marcáilte mar lucht leanúna amháin nó díreach agus ní féidir é a athphostáil", + "repeated": "athphostáil", + "show_new": "Taispeáin nua", + "up_to_date": "Nuashonraithe" + }, + "user_card": { + "approve": "Údaraigh", + "block": "Cosc", + "blocked": "Cuireadh coisc!", + "deny": "Diúltaigh", + "follow": "Lean", + "followees": "Leantóirí", + "followers": "Á Leanúint", + "following": "Á Leanúint", + "follows_you": "Leanann tú", + "mute": "Cuir i mód ciúin", + "muted": "Mód ciúin", + "per_day": "laethúil", + "remote_follow": "Leaníunt iargúlta", + "statuses": "Poist" + }, + "user_profile": { + "timeline_title": "Amlíne úsáideora" + }, + "who_to_follow": { + "more": "Feach uile", + "who_to_follow": "Daoine le leanúint" + } +} diff --git a/src/i18n/he.json b/src/i18n/he.json new file mode 100644 index 00000000..99ae9551 --- /dev/null +++ b/src/i18n/he.json @@ -0,0 +1,190 @@ +{ + "chat": { + "title": "צ'אט" + }, + "features_panel": { + "chat": "צ'אט", + "gopher": "גופר", + "media_proxy": "מדיה פרוקסי", + "scope_options": "אפשרויות טווח", + "text_limit": "מגבלת טקסט", + "title": "מאפיינים", + "who_to_follow": "אחרי מי לעקוב" + }, + "finder": { + "error_fetching_user": "שגיאה במציאת משתמש", + "find_user": "מציאת משתמש" + }, + "general": { + "apply": "החל", + "submit": "שלח" + }, + "login": { + "login": "התחבר", + "logout": "התנתק", + "password": "סיסמה", + "placeholder": "למשל lain", + "register": "הירשם", + "username": "שם המשתמש" + }, + "nav": { + "chat": "צ'אט מקומי", + "friend_requests": "בקשות עקיבה", + "mentions": "אזכורים", + "public_tl": "ציר הזמן הציבורי", + "timeline": "ציר הזמן", + "twkn": "כל הרשת הידועה" + }, + "notifications": { + "broken_favorite": "סטאטוס לא ידוע, מחפש...", + "favorited_you": "אהב את הסטטוס שלך", + "followed_you": "עקב אחריך!", + "load_older": "טען התראות ישנות", + "notifications": "התראות", + "read": "קרא!", + "repeated_you": "חזר על הסטטוס שלך" + }, + "post_status": { + "account_not_locked_warning": "המשתמש שלך אינו {0}. כל אחד יכול לעקוב אחריך ולראות את ההודעות לעוקבים-בלבד שלך.", + "account_not_locked_warning_link": "נעול", + "attachments_sensitive": "סמן מסמכים מצורפים כלא בטוחים לצפייה", + "content_type": { + "plain_text": "טקסט פשוט" + }, + "content_warning": "נושא (נתון לבחירה)", + "default": "הרגע נחת ב-ל.א.", + "direct_warning": "הודעה זו תהיה זמינה רק לאנשים המוזכרים.", + "posting": "מפרסם", + "scope": { + "direct": "ישיר - שלח לאנשים המוזכרים בלבד", + "private": "עוקבים-בלבד - שלח לעוקבים בלבד", + "public": "ציבורי - שלח לציר הזמן הציבורי", + "unlisted": "מחוץ לרשימה - אל תשלח לציר הזמן הציבורי" + } + }, + "registration": { + "bio": "אודות", + "email": "אימייל", + "fullname": "שם תצוגה", + "password_confirm": "אישור סיסמה", + "registration": "הרשמה", + "token": "טוקן הזמנה" + }, + "settings": { + "attachmentRadius": "צירופים", + "attachments": "צירופים", + "autoload": "החל טעינה אוטומטית בגלילה לתחתית הדף", + "avatar": "תמונת פרופיל", + "avatarAltRadius": "תמונות פרופיל (התראות)", + "avatarRadius": "תמונות פרופיל", + "background": "רקע", + "bio": "אודות", + "btnRadius": "כפתורים", + "cBlue": "כחול (תגובה, עקיבה)", + "cGreen": "ירוק (חזרה)", + "cOrange": "כתום (לייק)", + "cRed": "אדום (ביטול)", + "change_password": "שנה סיסמה", + "change_password_error": "הייתה בעיה בשינוי סיסמתך.", + "changed_password": "סיסמה שונתה בהצלחה!", + "collapse_subject": "מזער הודעות עם נושאים", + "confirm_new_password": "אשר סיסמה", + "current_avatar": "תמונת הפרופיל הנוכחית שלך", + "current_password": "סיסמה נוכחית", + "current_profile_banner": "כרזת הפרופיל הנוכחית שלך", + "data_import_export_tab": "ייבוא או ייצוא מידע", + "default_vis": "ברירת מחדל לטווח הנראות", + "delete_account": "מחק משתמש", + "delete_account_description": "מחק לצמיתות את המשתמש שלך ואת כל הודעותיך.", + "delete_account_error": "הייתה בעיה במחיקת המשתמש. אם זה ממשיך, אנא עדכן את מנהל השרת שלך.", + "delete_account_instructions": "הכנס את סיסמתך בקלט למטה על מנת לאשר מחיקת משתמש.", + "export_theme": "שמור ערכים", + "filtering": "סינון", + "filtering_explanation": "כל הסטטוסים הכוללים את המילים הללו יושתקו, אחד לשורה", + "follow_export": "יצוא עקיבות", + "follow_export_button": "ייצא את הנעקבים שלך לקובץ csv", + "follow_export_processing": "טוען. בקרוב תתבקש להוריד את הקובץ את הקובץ שלך", + "follow_import": "יבוא עקיבות", + "follow_import_error": "שגיאה בייבוא נעקבים.", + "follows_imported": "נעקבים יובאו! ייקח זמן מה לעבד אותם.", + "foreground": "חזית", + "hide_attachments_in_convo": "החבא צירופים בשיחות", + "hide_attachments_in_tl": "החבא צירופים בציר הזמן", + "import_followers_from_a_csv_file": "ייבא את הנעקבים שלך מקובץ csv", + "import_theme": "טען ערכים", + "inputRadius": "שדות קלט", + "interfaceLanguage": "שפת הממשק", + "invalid_theme_imported": "הקובץ הנבחר אינו תמה הנתמכת ע\"י פלרומה. שום שינויים לא נעשו לתמה שלך.", + "limited_availability": "לא זמין בדפדפן שלך", + "links": "לינקים", + "lock_account_description": "הגבל את המשתמש לעוקבים מאושרים בלבד", + "loop_video": "נגן סרטונים ללא הפסקה", + "loop_video_silent_only": "נגן רק סרטונים חסרי קול ללא הפסקה", + "name": "שם", + "name_bio": "שם ואודות", + "new_password": "סיסמה חדשה", + "notification_visibility": "סוג ההתראות שתרצו לראות", + "notification_visibility_follows": "עקיבות", + "notification_visibility_likes": "לייקים", + "notification_visibility_mentions": "אזכורים", + "notification_visibility_repeats": "חזרות", + "nsfw_clickthrough": "החל החבאת צירופים לא בטוחים לצפיה בעת עבודה בעזרת לחיצת עכבר", + "panelRadius": "פאנלים", + "pause_on_unfocused": "השהה זרימת הודעות כשהחלון לא בפוקוס", + "presets": "ערכים קבועים מראש", + "profile_background": "רקע הפרופיל", + "profile_banner": "כרזת הפרופיל", + "profile_tab": "פרופיל", + "radii_help": "קבע מראש עיגול פינות לממשק (בפיקסלים)", + "replies_in_timeline": "תגובות בציר הזמן", + "reply_link_preview": "החל תצוגה מקדימה של לינק-תגובה בעת ריחוף עם העכבר", + "reply_visibility_all": "הראה את כל התגובות", + "reply_visibility_following": "הראה תגובות שמופנות אליי או לעקובים שלי בלבד", + "reply_visibility_self": "הראה תגובות שמופנות אליי בלבד", + "security_tab": "ביטחון", + "set_new_avatar": "קבע תמונת פרופיל חדשה", + "set_new_profile_background": "קבע רקע פרופיל חדש", + "set_new_profile_banner": "קבע כרזת פרופיל חדשה", + "settings": "הגדרות", + "stop_gifs": "נגן-בעת-ריחוף GIFs", + "streaming": "החל זרימת הודעות אוטומטית בעת גלילה למעלה הדף", + "text": "טקסט", + "theme": "תמה", + "theme_help": "השתמש בקודי צבע הקס (#אדום-אדום-ירוק-ירוק-כחול-כחול) על מנת להתאים אישית את תמת הצבע שלך.", + "tooltipRadius": "טולטיפ \\ התראות", + "user_settings": "הגדרות משתמש" + }, + "timeline": { + "collapse": "מוטט", + "conversation": "שיחה", + "error_fetching": "שגיאה בהבאת הודעות", + "load_older": "טען סטטוסים חדשים", + "no_retweet_hint": "ההודעה מסומנת כ\"לעוקבים-בלבד\" ולא ניתן לחזור עליה", + "repeated": "חזר", + "show_new": "הראה חדש", + "up_to_date": "עדכני" + }, + "user_card": { + "approve": "אשר", + "block": "חסימה", + "blocked": "חסום!", + "deny": "דחה", + "follow": "עקוב", + "followees": "נעקבים", + "followers": "עוקבים", + "following": "עוקב!", + "follows_you": "עוקב אחריך!", + "mute": "השתק", + "muted": "מושתק", + "per_day": "ליום", + "remote_follow": "עקיבה מרחוק", + "statuses": "סטטוסים" + }, + "user_profile": { + "timeline_title": "ציר זמן המשתמש" + }, + "who_to_follow": { + "more": "עוד", + "who_to_follow": "אחרי מי לעקוב" + } +} diff --git a/src/i18n/hu.json b/src/i18n/hu.json new file mode 100644 index 00000000..e98fdc44 --- /dev/null +++ b/src/i18n/hu.json @@ -0,0 +1,83 @@ +{ + "finder": { + "error_fetching_user": "Hiba felhasználó beszerzésével", + "find_user": "Felhasználó keresése" + }, + "general": { + "submit": "Elküld" + }, + "login": { + "login": "Bejelentkezés", + "logout": "Kijelentkezés", + "password": "Jelszó", + "placeholder": "e.g. lain", + "register": "Feliratkozás", + "username": "Felhasználó név" + }, + "nav": { + "mentions": "Említéseim", + "public_tl": "Publikus Idővonal", + "timeline": "Idővonal", + "twkn": "Az Egész Ismert Hálózat" + }, + "notifications": { + "followed_you": "követ téged", + "notifications": "Értesítések", + "read": "Olvasva!" + }, + "post_status": { + "default": "Most érkeztem L.A.-be", + "posting": "Küldés folyamatban" + }, + "registration": { + "bio": "Bio", + "email": "Email", + "fullname": "Teljes név", + "password_confirm": "Jelszó megerősítése", + "registration": "Feliratkozás" + }, + "settings": { + "attachments": "Csatolmányok", + "autoload": "Autoatikus betöltés engedélyezése lap aljára görgetéskor", + "avatar": "Avatár", + "bio": "Bio", + "current_avatar": "Jelenlegi avatár", + "current_profile_banner": "Jelenlegi profil banner", + "filtering": "Szűrés", + "filtering_explanation": "Minden tartalom mely ezen szavakat tartalmazza némítva lesz, soronként egy", + "hide_attachments_in_convo": "Csatolmányok elrejtése a társalgásokban", + "hide_attachments_in_tl": "Csatolmányok elrejtése az idővonalon", + "name": "Név", + "name_bio": "Név és Bio", + "nsfw_clickthrough": "NSFW átkattintási tartalom elrejtésének engedélyezése", + "profile_background": "Profil háttérkép", + "profile_banner": "Profil Banner", + "reply_link_preview": "Válasz-link előzetes mutatása egér rátételkor", + "set_new_avatar": "Új avatár", + "set_new_profile_background": "Új profil háttér beállítása", + "set_new_profile_banner": "Új profil banner", + "settings": "Beállítások", + "theme": "Téma", + "user_settings": "Felhasználói beállítások" + }, + "timeline": { + "conversation": "Társalgás", + "error_fetching": "Hiba a frissítések beszerzésénél", + "load_older": "Régebbi állapotok betöltése", + "show_new": "Újak mutatása", + "up_to_date": "Naprakész" + }, + "user_card": { + "block": "Letilt", + "blocked": "Letiltva!", + "follow": "Követ", + "followees": "Követettek", + "followers": "Követők", + "following": "Követve!", + "follows_you": "Követ téged!", + "mute": "Némít", + "muted": "Némított", + "per_day": "naponta", + "statuses": "Állapotok" + } +} diff --git a/src/i18n/it.json b/src/i18n/it.json new file mode 100644 index 00000000..8f69e7c1 --- /dev/null +++ b/src/i18n/it.json @@ -0,0 +1,201 @@ +{ + "general": { + "submit": "Invia", + "apply": "Applica" + }, + "nav": { + "mentions": "Menzioni", + "public_tl": "Sequenza temporale pubblica", + "timeline": "Sequenza temporale", + "twkn": "L'intera rete conosciuta", + "chat": "Chat Locale", + "friend_requests": "Richieste di Seguirti" + }, + "notifications": { + "followed_you": "ti segue", + "notifications": "Notifiche", + "read": "Leggi!", + "broken_favorite": "Stato sconosciuto, lo sto cercando...", + "favorited_you": "ha messo mi piace al tuo stato", + "load_older": "Carica notifiche più vecchie", + "repeated_you": "ha condiviso il tuo stato" + }, + "settings": { + "attachments": "Allegati", + "autoload": "Abilita caricamento automatico quando si raggiunge fondo pagina", + "avatar": "Avatar", + "bio": "Introduzione", + "current_avatar": "Il tuo avatar attuale", + "current_profile_banner": "Il tuo banner attuale", + "filtering": "Filtri", + "filtering_explanation": "Tutti i post contenenti queste parole saranno silenziati, uno per linea", + "hide_attachments_in_convo": "Nascondi gli allegati presenti nelle conversazioni", + "hide_attachments_in_tl": "Nascondi gli allegati presenti nella sequenza temporale", + "name": "Nome", + "name_bio": "Nome & Introduzione", + "nsfw_clickthrough": "Abilita il click per visualizzare gli allegati segnati come NSFW", + "profile_background": "Sfondo della tua pagina", + "profile_banner": "Banner del tuo profilo", + "reply_link_preview": "Abilita il link per la risposta al passaggio del mouse", + "set_new_avatar": "Scegli un nuovo avatar", + "set_new_profile_background": "Scegli un nuovo sfondo per la tua pagina", + "set_new_profile_banner": "Scegli un nuovo banner per il tuo profilo", + "settings": "Impostazioni", + "theme": "Tema", + "user_settings": "Impostazioni Utente", + "attachmentRadius": "Allegati", + "avatarAltRadius": "Avatar (Notifiche)", + "avatarRadius": "Avatar", + "background": "Sfondo", + "btnRadius": "Pulsanti", + "cBlue": "Blu (Rispondere, seguire)", + "cGreen": "Verde (Condividi)", + "cOrange": "Arancio (Mi piace)", + "cRed": "Rosso (Annulla)", + "change_password": "Cambia Password", + "change_password_error": "C'è stato un problema durante il cambiamento della password.", + "changed_password": "Password cambiata correttamente!", + "collapse_subject": "Riduci post che hanno un oggetto", + "confirm_new_password": "Conferma la nuova password", + "current_password": "Password attuale", + "data_import_export_tab": "Importa / Esporta Dati", + "default_vis": "Visibilità predefinita dei post", + "delete_account": "Elimina Account", + "delete_account_description": "Elimina definitivamente il tuo account e tutti i tuoi messaggi.", + "delete_account_error": "C'è stato un problema durante l'eliminazione del tuo account. Se il problema persiste contatta l'amministratore della tua istanza.", + "delete_account_instructions": "Digita la tua password nel campo sottostante per confermare l'eliminazione dell'account.", + "export_theme": "Salva settaggi", + "follow_export": "Esporta la lista di chi segui", + "follow_export_button": "Esporta la lista di chi segui in un file csv", + "follow_export_processing": "Sto elaborando, presto ti sarà chiesto di scaricare il tuo file", + "follow_import": "Importa la lista di chi segui", + "follow_import_error": "Errore nell'importazione della lista di chi segui", + "follows_imported": "Importazione riuscita! L'elaborazione richiederà un po' di tempo.", + "foreground": "In primo piano", + "general": "Generale", + "hide_post_stats": "Nascondi statistiche dei post (es. il numero di mi piace)", + "hide_user_stats": "Nascondi statistiche dell'utente (es. il numero di chi ti segue)", + "import_followers_from_a_csv_file": "Importa una lista di chi segui da un file csv", + "import_theme": "Carica settaggi", + "inputRadius": "Campi di testo", + "instance_default": "(predefinito: {value})", + "interfaceLanguage": "Linguaggio dell'interfaccia", + "invalid_theme_imported": "Il file selezionato non è un file di tema per Pleroma supportato. Il tuo tema non è stato modificato.", + "limited_availability": "Non disponibile nel tuo browser", + "links": "Collegamenti", + "lock_account_description": "Limita il tuo account solo per contatti approvati", + "loop_video": "Riproduci video in ciclo continuo", + "loop_video_silent_only": "Riproduci solo video senza audio in ciclo continuo (es. le gif di Mastodon)", + "new_password": "Nuova password", + "notification_visibility": "Tipi di notifiche da mostrare", + "notification_visibility_follows": "Nuove persone ti seguono", + "notification_visibility_likes": "Mi piace", + "notification_visibility_mentions": "Menzioni", + "notification_visibility_repeats": "Condivisioni", + "no_rich_text_description": "Togli la formattazione del testo da tutti i post", + "panelRadius": "Pannelli", + "pause_on_unfocused": "Metti in pausa l'aggiornamento continuo quando la scheda non è in primo piano", + "presets": "Valori predefiniti", + "profile_tab": "Profilo", + "radii_help": "Imposta l'arrotondamento dei bordi (in pixel)", + "replies_in_timeline": "Risposte nella sequenza temporale", + "reply_visibility_all": "Mostra tutte le risposte", + "reply_visibility_following": "Mostra solo le risposte dirette a me o agli utenti che seguo", + "reply_visibility_self": "Mostra solo risposte dirette a me", + "saving_err": "Errore nel salvataggio delle impostazioni", + "saving_ok": "Impostazioni salvate", + "security_tab": "Sicurezza", + "stop_gifs": "Riproduci GIF al passaggio del cursore del mouse", + "streaming": "Abilita aggiornamento automatico dei nuovi post quando si è in alto alla pagina", + "text": "Testo", + "theme_help": "Usa codici colore esadecimali (#rrggbb) per personalizzare il tuo schema di colori.", + "tooltipRadius": "Descrizioni/avvisi", + "values": { + "false": "no", + "true": "si" + } + }, + "timeline": { + "error_fetching": "Errore nel prelievo aggiornamenti", + "load_older": "Carica messaggi più vecchi", + "show_new": "Mostra nuovi", + "up_to_date": "Aggiornato", + "collapse": "Riduci", + "conversation": "Conversazione", + "no_retweet_hint": "La visibilità del post è impostata solo per chi ti segue o messaggio diretto e non può essere condiviso", + "repeated": "condiviso" + }, + "user_card": { + "follow": "Segui", + "followees": "Chi stai seguendo", + "followers": "Chi ti segue", + "following": "Lo stai seguendo!", + "follows_you": "Ti segue!", + "mute": "Silenzia", + "muted": "Silenziato", + "per_day": "al giorno", + "statuses": "Messaggi", + "approve": "Approva", + "block": "Blocca", + "blocked": "Bloccato!", + "deny": "Nega", + "remote_follow": "Segui da remoto" + }, + "chat": { + "title": "Chat" + }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Media proxy", + "scope_options": "Opzioni di visibilità", + "text_limit": "Lunghezza limite", + "title": "Caratteristiche", + "who_to_follow": "Chi seguire" + }, + "finder": { + "error_fetching_user": "Errore nel recupero dell'utente", + "find_user": "Trova utente" + }, + "login": { + "login": "Accedi", + "logout": "Disconnettiti", + "password": "Password", + "placeholder": "es. lain", + "register": "Registrati", + "username": "Nome utente" + }, + "post_status": { + "account_not_locked_warning": "Il tuo account non è {0}. Chiunque può seguirti e vedere i tuoi post riservati a chi ti segue.", + "account_not_locked_warning_link": "bloccato", + "attachments_sensitive": "Segna allegati come sensibili", + "content_type": { + "plain_text": "Testo normale" + }, + "content_warning": "Oggetto (facoltativo)", + "default": "Appena atterrato in L.A.", + "direct_warning": "Questo post sarà visibile solo dagli utenti menzionati.", + "posting": "Pubblica", + "scope": { + "direct": "Diretto - Pubblicato solo per gli utenti menzionati", + "private": "Solo per chi ti segue - Visibile solo da chi ti segue", + "public": "Pubblico - Visibile sulla sequenza temporale pubblica", + "unlisted": "Non elencato - Non visibile sulla sequenza temporale pubblica" + } + }, + "registration": { + "bio": "Introduzione", + "email": "Email", + "fullname": "Nome visualizzato", + "password_confirm": "Conferma password", + "registration": "Registrazione", + "token": "Codice d'invito" + }, + "user_profile": { + "timeline_title": "Sequenza Temporale dell'Utente" + }, + "who_to_follow": { + "more": "Più", + "who_to_follow": "Chi seguire" + } +} diff --git a/src/i18n/ja.json b/src/i18n/ja.json new file mode 100644 index 00000000..4da7ea30 --- /dev/null +++ b/src/i18n/ja.json @@ -0,0 +1,203 @@ +{ + "chat": { + "title": "チャット" + }, + "features_panel": { + "chat": "チャット", + "gopher": "Gopher", + "media_proxy": "メディアプロクシ", + "scope_options": "こうかいはんいせんたく", + "text_limit": "もじのかず", + "title": "ゆうこうなきのう", + "who_to_follow": "おすすめユーザー" + }, + "finder": { + "error_fetching_user": "ユーザーけんさくがエラーになりました。", + "find_user": "ユーザーをさがす" + }, + "general": { + "apply": "てきよう", + "submit": "そうしん" + }, + "login": { + "login": "ログイン", + "description": "OAuthでログイン", + "logout": "ログアウト", + "password": "パスワード", + "placeholder": "れい: lain", + "register": "はじめる", + "username": "ユーザーめい" + }, + "nav": { + "chat": "ローカルチャット", + "friend_requests": "フォローリクエスト", + "mentions": "メンション", + "dms": "ダイレクトメッセージ", + "public_tl": "パブリックタイムライン", + "timeline": "タイムライン", + "twkn": "つながっているすべてのネットワーク" + }, + "notifications": { + "broken_favorite": "ステータスがみつかりません。さがしています...", + "favorited_you": "あなたのステータスがおきにいりされました", + "followed_you": "フォローされました", + "load_older": "ふるいつうちをみる", + "notifications": "つうち", + "read": "よんだ!", + "repeated_you": "あなたのステータスがリピートされました" + }, + "post_status": { + "account_not_locked_warning": "あなたのアカウントは {0} ではありません。あなたをフォローすれば、だれでも、フォロワーげんていのステータスをよむことができます。", + "account_not_locked_warning_link": "ロックされたアカウント", + "attachments_sensitive": "ファイルをNSFWにする", + "content_type": { + "plain_text": "プレーンテキスト" + }, + "content_warning": "せつめい (かかなくてもよい)", + "default": "はねだくうこうに、つきました。", + "direct_warning": "このステータスは、メンションされたユーザーだけが、よむことができます。", + "posting": "とうこう", + "scope": { + "direct": "ダイレクト: メンションされたユーザーのみにとどきます。", + "private": "フォロワーげんてい: フォロワーのみにとどきます。", + "public": "パブリック: パブリックタイムラインにとどきます。", + "unlisted": "アンリステッド: パブリックタイムラインにとどきません。" + } + }, + "registration": { + "bio": "プロフィール", + "email": "Eメール", + "fullname": "スクリーンネーム", + "password_confirm": "パスワードのかくにん", + "registration": "はじめる", + "token": "しょうたいトークン" + }, + "settings": { + "attachmentRadius": "ファイル", + "attachments": "ファイル", + "autoload": "したにスクロールしたとき、じどうてきによみこむ。", + "avatar": "アバター", + "avatarAltRadius": "つうちのアバター", + "avatarRadius": "アバター", + "background": "バックグラウンド", + "bio": "プロフィール", + "btnRadius": "ボタン", + "cBlue": "リプライとフォロー", + "cGreen": "リピート", + "cOrange": "おきにいり", + "cRed": "キャンセル", + "change_password": "パスワードをかえる", + "change_password_error": "パスワードをかえることが、できなかったかもしれません。", + "changed_password": "パスワードが、かわりました!", + "collapse_subject": "せつめいのあるとうこうをたたむ", + "confirm_new_password": "あたらしいパスワードのかくにん", + "current_avatar": "いまのアバター", + "current_password": "いまのパスワード", + "current_profile_banner": "いまのプロフィールバナー", + "data_import_export_tab": "インポートとエクスポート", + "default_vis": "デフォルトのこうかいはんい", + "delete_account": "アカウントをけす", + "delete_account_description": "あなたのアカウントとメッセージが、きえます。", + "delete_account_error": "アカウントをけすことが、できなかったかもしれません。インスタンスのかんりしゃに、れんらくしてください。", + "delete_account_instructions": "ほんとうにアカウントをけしてもいいなら、パスワードをかいてください。", + "export_theme": "セーブ", + "filtering": "フィルタリング", + "filtering_explanation": "これらのことばをふくむすべてのものがミュートされます。1ぎょうに1つのことばをかいてください。", + "follow_export": "フォローのエクスポート", + "follow_export_button": "エクスポート", + "follow_export_processing": "おまちください。まもなくファイルをダウンロードできます。", + "follow_import": "フォローインポート", + "follow_import_error": "フォローのインポートがエラーになりました。", + "follows_imported": "フォローがインポートされました! すこしじかんがかかるかもしれません。", + "foreground": "フォアグラウンド", + "general": "ぜんぱん", + "hide_attachments_in_convo": "スレッドのファイルをかくす", + "hide_attachments_in_tl": "タイムラインのファイルをかくす", + "hide_post_stats": "とうこうのとうけいをかくす (れい: おきにいりのかず)", + "hide_user_stats": "ユーザーのとうけいをかくす (れい: フォロワーのかず)", + "import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする", + "import_theme": "ロード", + "inputRadius": "インプットフィールド", + "instance_default": "(デフォルト: {value})", + "interfaceLanguage": "インターフェースのことば", + "invalid_theme_imported": "このファイルはPleromaのテーマではありません。テーマはへんこうされませんでした。", + "limited_availability": "あなたのブラウザではできません", + "links": "リンク", + "lock_account_description": "あなたがみとめたひとだけ、あなたのアカウントをフォローできます", + "loop_video": "ビデオをくりかえす", + "loop_video_silent_only": "おとのないビデオだけくりかえす", + "name": "なまえ", + "name_bio": "なまえとプロフィール", + "new_password": "あたらしいパスワード", + "notification_visibility": "ひょうじするつうち", + "notification_visibility_follows": "フォロー", + "notification_visibility_likes": "おきにいり", + "notification_visibility_mentions": "メンション", + "notification_visibility_repeats": "リピート", + "no_rich_text_description": "リッチテキストをつかわない", + "nsfw_clickthrough": "NSFWなファイルをかくす", + "panelRadius": "パネル", + "pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる", + "presets": "プリセット", + "profile_background": "プロフィールのバックグラウンド", + "profile_banner": "プロフィールバナー", + "profile_tab": "プロフィール", + "radii_help": "インターフェースのまるさをせっていする。", + "replies_in_timeline": "タイムラインのリプライ", + "reply_link_preview": "カーソルをかさねたとき、リプライのプレビューをみる", + "reply_visibility_all": "すべてのリプライをみる", + "reply_visibility_following": "わたしにあてられたリプライと、フォローしているひとからのリプライをみる", + "reply_visibility_self": "わたしにあてられたリプライをみる", + "saving_err": "せっていをセーブできませんでした", + "saving_ok": "せっていをセーブしました", + "security_tab": "セキュリティ", + "set_new_avatar": "あたらしいアバターをせっていする", + "set_new_profile_background": "あたらしいプロフィールのバックグラウンドをせっていする", + "set_new_profile_banner": "あたらしいプロフィールバナーを設定する", + "settings": "せってい", + "stop_gifs": "カーソルをかさねたとき、GIFをうごかす", + "streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする", + "text": "もじ", + "theme": "テーマ", + "theme_help": "カラーテーマをカスタマイズできます", + "tooltipRadius": "ツールチップとアラート", + "user_settings": "ユーザーせってい", + "values": { + "false": "いいえ", + "true": "はい" + } + }, + "timeline": { + "collapse": "たたむ", + "conversation": "スレッド", + "error_fetching": "よみこみがエラーになりました", + "load_older": "ふるいステータス", + "no_retweet_hint": "とうこうを「フォロワーのみ」または「ダイレクト」にすると、リピートできなくなります", + "repeated": "リピート", + "show_new": "よみこみ", + "up_to_date": "さいしん" + }, + "user_card": { + "approve": "うけいれ", + "block": "ブロック", + "blocked": "ブロックしています!", + "deny": "おことわり", + "follow": "フォロー", + "followees": "フォロー", + "followers": "フォロワー", + "following": "フォローしています!", + "follows_you": "フォローされました!", + "mute": "ミュート", + "muted": "ミュートしています!", + "per_day": "/日", + "remote_follow": "リモートフォロー", + "statuses": "ステータス" + }, + "user_profile": { + "timeline_title": "ユーザータイムライン" + }, + "who_to_follow": { + "more": "くわしく", + "who_to_follow": "おすすめユーザー" + } +} diff --git a/src/i18n/messages.js b/src/i18n/messages.js index 7e17c931..ee08db44 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -1,1447 +1,34 @@ -const de = { - chat: { - title: 'Chat' - }, - nav: { - chat: 'Lokaler Chat', - timeline: 'Zeitleiste', - mentions: 'Erwähnungen', - public_tl: 'Lokale Zeitleiste', - twkn: 'Das gesamte Netzwerk' - }, - user_card: { - follows_you: 'Folgt dir!', - following: 'Folgst du!', - follow: 'Folgen', - blocked: 'Blockiert!', - block: 'Blockieren', - statuses: 'Beiträge', - mute: 'Stummschalten', - muted: 'Stummgeschaltet', - followers: 'Folgende', - followees: 'Folgt', - per_day: 'pro Tag', - remote_follow: 'Remote Follow' - }, - timeline: { - show_new: 'Zeige Neuere', - error_fetching: 'Fehler beim Laden', - up_to_date: 'Aktuell', - load_older: 'Lade ältere Beiträge', - conversation: 'Unterhaltung', - collapse: 'Einklappen', - repeated: 'wiederholte' - }, - settings: { - user_settings: 'Benutzereinstellungen', - name_bio: 'Name & Bio', - name: 'Name', - bio: 'Bio', - avatar: 'Avatar', - current_avatar: 'Dein derzeitiger Avatar', - set_new_avatar: 'Setze neuen Avatar', - profile_banner: 'Profil Banner', - current_profile_banner: 'Dein derzeitiger Profil Banner', - set_new_profile_banner: 'Setze neuen Profil Banner', - profile_background: 'Profil Hintergrund', - set_new_profile_background: 'Setze neuen Profil Hintergrund', - settings: 'Einstellungen', - theme: 'Farbschema', - presets: 'Voreinstellungen', - theme_help: 'Benutze HTML Farbcodes (#rrggbb) um dein Farbschema anzupassen.', - background: 'Hintergrund', - foreground: 'Vordergrund', - text: 'Text', - links: 'Links', - cBlue: 'Blau (Antworten, Folgt dir)', - cRed: 'Rot (Abbrechen)', - cOrange: 'Orange (Favorisieren)', - cGreen: 'Grün (Retweet)', - btnRadius: 'Buttons', - panelRadius: 'Panel', - avatarRadius: 'Avatare', - avatarAltRadius: 'Avatare (Benachrichtigungen)', - tooltipRadius: 'Tooltips/Warnungen', - attachmentRadius: 'Anhänge', - filtering: 'Filter', - filtering_explanation: 'Alle Beiträge die diese Wörter enthalten werden ausgeblendet. Ein Wort pro Zeile.', - attachments: 'Anhänge', - hide_attachments_in_tl: 'Anhänge in der Zeitleiste ausblenden', - hide_attachments_in_convo: 'Anhänge in Unterhaltungen ausblenden', - nsfw_clickthrough: 'Aktiviere ausblendbares Overlay für Anhänge, die als NSFW markiert sind', - stop_gifs: 'Play-on-hover GIFs', - autoload: 'Aktiviere automatisches Laden von älteren Beiträgen beim scrollen', - streaming: 'Aktiviere automatisches Laden (Streaming) von neuen Beiträgen', - reply_link_preview: 'Aktiviere reply-link Vorschau bei Maus-Hover', - follow_import: 'Folgeliste importieren', - import_followers_from_a_csv_file: 'Importiere Kontakte, denen du folgen möchtest, aus einer CSV-Datei', - follows_imported: 'Folgeliste importiert! Die Bearbeitung kann eine Zeit lang dauern.', - follow_import_error: 'Fehler beim importieren der Folgeliste' - }, - notifications: { - notifications: 'Benachrichtigungen', - read: 'Gelesen!', - followed_you: 'folgt dir', - favorited_you: 'favorisierte deine Nachricht', - repeated_you: 'wiederholte deine Nachricht' - }, - login: { - login: 'Anmelden', - username: 'Benutzername', - password: 'Passwort', - register: 'Registrieren', - logout: 'Abmelden' - }, - registration: { - registration: 'Registrierung', - fullname: 'Angezeigter Name', - email: 'Email', - bio: 'Bio', - password_confirm: 'Passwort bestätigen' - }, - post_status: { - posting: 'Veröffentlichen', - default: 'Sitze gerade im Hofbräuhaus.' - }, - finder: { - find_user: 'Finde Benutzer', - error_fetching_user: 'Fehler beim Suchen des Benutzers' - }, - general: { - submit: 'Absenden', - apply: 'Anwenden' - }, - user_profile: { - timeline_title: 'Beiträge' - } -} - -const fi = { - nav: { - timeline: 'Aikajana', - mentions: 'Maininnat', - public_tl: 'Julkinen Aikajana', - twkn: 'Koko Tunnettu Verkosto' - }, - user_card: { - follows_you: 'Seuraa sinua!', - following: 'Seuraat!', - follow: 'Seuraa', - statuses: 'Viestit', - mute: 'Hiljennä', - muted: 'Hiljennetty', - followers: 'Seuraajat', - followees: 'Seuraa', - per_day: 'päivässä' - }, - timeline: { - show_new: 'Näytä uudet', - error_fetching: 'Virhe ladatessa viestejä', - up_to_date: 'Ajantasalla', - load_older: 'Lataa vanhempia viestejä', - conversation: 'Keskustelu', - collapse: 'Sulje', - repeated: 'toisti' - }, - settings: { - user_settings: 'Käyttäjän asetukset', - name_bio: 'Nimi ja kuvaus', - name: 'Nimi', - bio: 'Kuvaus', - avatar: 'Profiilikuva', - current_avatar: 'Nykyinen profiilikuvasi', - set_new_avatar: 'Aseta uusi profiilikuva', - profile_banner: 'Juliste', - current_profile_banner: 'Nykyinen julisteesi', - set_new_profile_banner: 'Aseta uusi juliste', - profile_background: 'Taustakuva', - set_new_profile_background: 'Aseta uusi taustakuva', - settings: 'Asetukset', - theme: 'Teema', - presets: 'Valmiit teemat', - theme_help: 'Käytä heksadesimaalivärejä muokataksesi väriteemaasi.', - background: 'Tausta', - foreground: 'Korostus', - text: 'Teksti', - links: 'Linkit', - filtering: 'Suodatus', - filtering_explanation: 'Kaikki viestit, jotka sisältävät näitä sanoja, suodatetaan. Yksi sana per rivi.', - attachments: 'Liitteet', - hide_attachments_in_tl: 'Piilota liitteet aikajanalla', - hide_attachments_in_convo: 'Piilota liitteet keskusteluissa', - nsfw_clickthrough: 'Piilota NSFW liitteet klikkauksen taakse.', - autoload: 'Lataa vanhempia viestejä automaattisesti ruudun pohjalla', - streaming: 'Näytä uudet viestit automaattisesti ollessasi ruudun huipulla', - reply_link_preview: 'Keskusteluiden vastauslinkkien esikatselu' - }, - notifications: { - notifications: 'Ilmoitukset', - read: 'Lue!', - followed_you: 'seuraa sinua', - favorited_you: 'tykkäsi viestistäsi', - repeated_you: 'toisti viestisi' - }, - login: { - login: 'Kirjaudu sisään', - username: 'Käyttäjänimi', - password: 'Salasana', - register: 'Rekisteröidy', - logout: 'Kirjaudu ulos' - }, - registration: { - registration: 'Rekisteröityminen', - fullname: 'Koko nimi', - email: 'Sähköposti', - bio: 'Kuvaus', - password_confirm: 'Salasanan vahvistaminen' - }, - post_status: { - posting: 'Lähetetään', - default: 'Tulin juuri saunasta.' - }, - finder: { - find_user: 'Hae käyttäjä', - error_fetching_user: 'Virhe hakiessa käyttäjää' - }, - general: { - submit: 'Lähetä', - apply: 'Aseta' - } -} - -const en = { - chat: { - title: 'Chat' - }, - nav: { - chat: 'Local Chat', - timeline: 'Timeline', - mentions: 'Mentions', - public_tl: 'Public Timeline', - twkn: 'The Whole Known Network' - }, - user_card: { - follows_you: 'Follows you!', - following: 'Following!', - follow: 'Follow', - blocked: 'Blocked!', - block: 'Block', - statuses: 'Statuses', - mute: 'Mute', - muted: 'Muted', - followers: 'Followers', - followees: 'Following', - per_day: 'per day', - remote_follow: 'Remote follow' - }, - timeline: { - show_new: 'Show new', - error_fetching: 'Error fetching updates', - up_to_date: 'Up-to-date', - load_older: 'Load older statuses', - conversation: 'Conversation', - collapse: 'Collapse', - repeated: 'repeated' - }, - settings: { - user_settings: 'User Settings', - name_bio: 'Name & Bio', - name: 'Name', - bio: 'Bio', - avatar: 'Avatar', - current_avatar: 'Your current avatar', - set_new_avatar: 'Set new avatar', - profile_banner: 'Profile Banner', - current_profile_banner: 'Your current profile banner', - set_new_profile_banner: 'Set new profile banner', - profile_background: 'Profile Background', - set_new_profile_background: 'Set new profile background', - settings: 'Settings', - theme: 'Theme', - presets: 'Presets', - theme_help: 'Use hex color codes (#rrggbb) to customize your color theme.', - radii_help: 'Set up interface edge rounding (in pixels)', - background: 'Background', - foreground: 'Foreground', - text: 'Text', - links: 'Links', - cBlue: 'Blue (Reply, follow)', - cRed: 'Red (Cancel)', - cOrange: 'Orange (Favorite)', - cGreen: 'Green (Retweet)', - btnRadius: 'Buttons', - panelRadius: 'Panels', - avatarRadius: 'Avatars', - avatarAltRadius: 'Avatars (Notifications)', - tooltipRadius: 'Tooltips/alerts', - attachmentRadius: 'Attachments', - filtering: 'Filtering', - filtering_explanation: 'All statuses containing these words will be muted, one per line', - attachments: 'Attachments', - hide_attachments_in_tl: 'Hide attachments in timeline', - hide_attachments_in_convo: 'Hide attachments in conversations', - nsfw_clickthrough: 'Enable clickthrough NSFW attachment hiding', - stop_gifs: 'Play-on-hover GIFs', - autoload: 'Enable automatic loading when scrolled to the bottom', - streaming: 'Enable automatic streaming of new posts when scrolled to the top', - reply_link_preview: 'Enable reply-link preview on mouse hover', - follow_import: 'Follow import', - import_followers_from_a_csv_file: 'Import follows from a csv file', - follows_imported: 'Follows imported! Processing them will take a while.', - follow_import_error: 'Error importing followers' - }, - notifications: { - notifications: 'Notifications', - read: 'Read!', - followed_you: 'followed you', - favorited_you: 'favorited your status', - repeated_you: 'repeated your status' - }, - login: { - login: 'Log in', - username: 'Username', - password: 'Password', - register: 'Register', - logout: 'Log out' - }, - registration: { - registration: 'Registration', - fullname: 'Display name', - email: 'Email', - bio: 'Bio', - password_confirm: 'Password confirmation' - }, - post_status: { - posting: 'Posting', - default: 'Just landed in L.A.' - }, - finder: { - find_user: 'Find user', - error_fetching_user: 'Error fetching user' - }, - general: { - submit: 'Submit', - apply: 'Apply' - }, - user_profile: { - timeline_title: 'User Timeline' - } -} - -const et = { - nav: { - timeline: 'Ajajoon', - mentions: 'Mainimised', - public_tl: 'Avalik Ajajoon', - twkn: 'Kogu Teadaolev Võrgustik' - }, - user_card: { - follows_you: 'Jälgib sind!', - following: 'Jälgin!', - follow: 'Jälgi', - blocked: 'Blokeeritud!', - block: 'Blokeeri', - statuses: 'Staatuseid', - mute: 'Vaigista', - muted: 'Vaigistatud', - followers: 'Jälgijaid', - followees: 'Jälgitavaid', - per_day: 'päevas' - }, - timeline: { - show_new: 'Näita uusi', - error_fetching: 'Viga uuenduste laadimisel', - up_to_date: 'Uuendatud', - load_older: 'Kuva vanemaid staatuseid', - conversation: 'Vestlus' - }, - settings: { - user_settings: 'Kasutaja sätted', - name_bio: 'Nimi ja Bio', - name: 'Nimi', - bio: 'Bio', - avatar: 'Profiilipilt', - current_avatar: 'Sinu praegune profiilipilt', - set_new_avatar: 'Vali uus profiilipilt', - profile_banner: 'Profiilibänner', - current_profile_banner: 'Praegune profiilibänner', - set_new_profile_banner: 'Vali uus profiilibänner', - profile_background: 'Profiilitaust', - set_new_profile_background: 'Vali uus profiilitaust', - settings: 'Sätted', - theme: 'Teema', - filtering: 'Sisu filtreerimine', - filtering_explanation: 'Kõiki staatuseid, mis sisaldavad neid sõnu, ei kuvata. Üks sõna reale.', - attachments: 'Manused', - hide_attachments_in_tl: 'Peida manused ajajoonel', - hide_attachments_in_convo: 'Peida manused vastlustes', - nsfw_clickthrough: 'Peida tööks-mittesobivad(NSFW) manuste hiireklõpsu taha', - autoload: 'Luba ajajoone automaatne uuendamine kui ajajoon on põhja keritud', - reply_link_preview: 'Luba algpostituse kuvamine vastustes' - }, - notifications: { - notifications: 'Teavitused', - read: 'Loe!', - followed_you: 'alustas sinu jälgimist' - }, - login: { - login: 'Logi sisse', - username: 'Kasutajanimi', - password: 'Parool', - register: 'Registreeru', - logout: 'Logi välja' - }, - registration: { - registration: 'Registreerimine', - fullname: 'Kuvatav nimi', - email: 'E-post', - bio: 'Bio', - password_confirm: 'Parooli kinnitamine' - }, - post_status: { - posting: 'Postitan', - default: 'Just sõitsin elektrirongiga Tallinnast Pääskülla.' - }, - finder: { - find_user: 'Otsi kasutajaid', - error_fetching_user: 'Viga kasutaja leidmisel' - }, - general: { - submit: 'Postita' - } -} - -const hu = { - nav: { - timeline: 'Idővonal', - mentions: 'Említéseim', - public_tl: 'Publikus Idővonal', - twkn: 'Az Egész Ismert Hálózat' - }, - user_card: { - follows_you: 'Követ téged!', - following: 'Követve!', - follow: 'Követ', - blocked: 'Letiltva!', - block: 'Letilt', - statuses: 'Állapotok', - mute: 'Némít', - muted: 'Némított', - followers: 'Követők', - followees: 'Követettek', - per_day: 'naponta' - }, - timeline: { - show_new: 'Újak mutatása', - error_fetching: 'Hiba a frissítések beszerzésénél', - up_to_date: 'Naprakész', - load_older: 'Régebbi állapotok betöltése', - conversation: 'Társalgás' - }, - settings: { - user_settings: 'Felhasználói beállítások', - name_bio: 'Név és Bio', - name: 'Név', - bio: 'Bio', - avatar: 'Avatár', - current_avatar: 'Jelenlegi avatár', - set_new_avatar: 'Új avatár', - profile_banner: 'Profil Banner', - current_profile_banner: 'Jelenlegi profil banner', - set_new_profile_banner: 'Új profil banner', - profile_background: 'Profil háttérkép', - set_new_profile_background: 'Új profil háttér beállítása', - settings: 'Beállítások', - theme: 'Téma', - filtering: 'Szűrés', - filtering_explanation: 'Minden tartalom mely ezen szavakat tartalmazza némítva lesz, soronként egy', - attachments: 'Csatolmányok', - hide_attachments_in_tl: 'Csatolmányok elrejtése az idővonalon', - hide_attachments_in_convo: 'Csatolmányok elrejtése a társalgásokban', - nsfw_clickthrough: 'NSFW átkattintási tartalom elrejtésének engedélyezése', - autoload: 'Autoatikus betöltés engedélyezése lap aljára görgetéskor', - reply_link_preview: 'Válasz-link előzetes mutatása egér rátételkor' - }, - notifications: { - notifications: 'Értesítések', - read: 'Olvasva!', - followed_you: 'követ téged' - }, - login: { - login: 'Bejelentkezés', - username: 'Felhasználó név', - password: 'Jelszó', - register: 'Feliratkozás', - logout: 'Kijelentkezés' - }, - registration: { - registration: 'Feliratkozás', - fullname: 'Teljes név', - email: 'Email', - bio: 'Bio', - password_confirm: 'Jelszó megerősítése' - }, - post_status: { - posting: 'Küldés folyamatban', - default: 'Most érkeztem L.A.-be' - }, - finder: { - find_user: 'Felhasználó keresése', - error_fetching_user: 'Hiba felhasználó beszerzésével' - }, - general: { - submit: 'Elküld' - } -} - -const ro = { - nav: { - timeline: 'Cronologie', - mentions: 'Menționări', - public_tl: 'Cronologie Publică', - twkn: 'Toată Reșeaua Cunoscută' - }, - user_card: { - follows_you: 'Te urmărește!', - following: 'Urmărit!', - follow: 'Urmărește', - blocked: 'Blocat!', - block: 'Blochează', - statuses: 'Stări', - mute: 'Pune pe mut', - muted: 'Pus pe mut', - followers: 'Următori', - followees: 'Urmărește', - per_day: 'pe zi' - }, - timeline: { - show_new: 'Arată cele noi', - error_fetching: 'Erare la preluarea actualizărilor', - up_to_date: 'La zi', - load_older: 'Încarcă stări mai vechi', - conversation: 'Conversație' - }, - settings: { - user_settings: 'Setările utilizatorului', - name_bio: 'Nume și Bio', - name: 'Nume', - bio: 'Bio', - avatar: 'Avatar', - current_avatar: 'Avatarul curent', - set_new_avatar: 'Setează avatar nou', - profile_banner: 'Banner de profil', - current_profile_banner: 'Bannerul curent al profilului', - set_new_profile_banner: 'Setează banner nou la profil', - profile_background: 'Fundalul de profil', - set_new_profile_background: 'Setează fundal nou', - settings: 'Setări', - theme: 'Temă', - filtering: 'Filtru', - filtering_explanation: 'Toate stările care conțin aceste cuvinte vor fi puse pe mut, una pe linie', - attachments: 'Atașamente', - hide_attachments_in_tl: 'Ascunde atașamentele în cronologie', - hide_attachments_in_convo: 'Ascunde atașamentele în conversații', - nsfw_clickthrough: 'Permite ascunderea al atașamentelor NSFW', - autoload: 'Permite încărcarea automată când scrolat la capăt', - reply_link_preview: 'Permite previzualizarea linkului de răspuns la planarea de mouse' - }, - notifications: { - notifications: 'Notificări', - read: 'Citit!', - followed_you: 'te-a urmărit' - }, - login: { - login: 'Loghează', - username: 'Nume utilizator', - password: 'Parolă', - register: 'Înregistrare', - logout: 'Deloghează' - }, - registration: { - registration: 'Îregistrare', - fullname: 'Numele întreg', - email: 'Email', - bio: 'Bio', - password_confirm: 'Cofirmă parola' - }, - post_status: { - posting: 'Postează', - default: 'Nu de mult am aterizat în L.A.' - }, - finder: { - find_user: 'Găsește utilizator', - error_fetching_user: 'Eroare la preluarea utilizatorului' - }, - general: { - submit: 'trimite' - } -} - -const ja = { - chat: { - title: 'チャット' - }, - nav: { - chat: 'ローカルチャット', - timeline: 'タイムライン', - mentions: 'メンション', - public_tl: '公開タイムライン', - twkn: '接続しているすべてのネットワーク' - }, - user_card: { - follows_you: 'フォローされました!', - following: 'フォロー中!', - follow: 'フォロー', - blocked: 'ブロック済み!', - block: 'ブロック', - statuses: '投稿', - mute: 'ミュート', - muted: 'ミュート済み', - followers: 'フォロワー', - followees: 'フォロー', - per_day: '/日', - remote_follow: 'リモートフォロー' - }, - timeline: { - show_new: '更新', - error_fetching: '更新の取得中にエラーが発生しました。', - up_to_date: '最新', - load_older: '古い投稿を読み込む', - conversation: '会話', - collapse: '折り畳む', - repeated: 'リピート' - }, - settings: { - user_settings: 'ユーザー設定', - name_bio: '名前とプロフィール', - name: '名前', - bio: 'プロフィール', - avatar: 'アバター', - current_avatar: 'あなたの現在のアバター', - set_new_avatar: '新しいアバターを設定する', - profile_banner: 'プロフィールバナー', - current_profile_banner: '現在のプロフィールバナー', - set_new_profile_banner: '新しいプロフィールバナーを設定する', - profile_background: 'プロフィールの背景', - set_new_profile_background: '新しいプロフィールの背景を設定する', - settings: '設定', - theme: 'テーマ', - presets: 'プリセット', - theme_help: '16進数カラーコード (#aabbcc) を使用してカラーテーマをカスタマイズ出来ます。', - radii_help: 'インターフェースの縁の丸さを設定する。', - background: '背景', - foreground: '前景', - text: '文字', - links: 'リンク', - cBlue: '青 (返信, フォロー)', - cRed: '赤 (キャンセル)', - cOrange: 'オレンジ (お気に入り)', - cGreen: '緑 (リツイート)', - btnRadius: 'ボタン', - panelRadius: 'パネル', - avatarRadius: 'アバター', - avatarAltRadius: 'アバター (通知)', - tooltipRadius: 'ツールチップ/アラート', - attachmentRadius: 'ファイル', - filtering: 'フィルタリング', - filtering_explanation: 'これらの単語を含むすべてのものがミュートされます。1行に1つの単語を入力してください。', - attachments: 'ファイル', - hide_attachments_in_tl: 'タイムラインのファイルを隠す。', - hide_attachments_in_convo: '会話の中のファイルを隠す。', - nsfw_clickthrough: 'NSFWファイルの非表示を有効にする。', - stop_gifs: 'カーソルを重ねた時にGIFを再生する。', - autoload: '下にスクロールした時に自動で読み込むようにする。', - streaming: '上までスクロールした時に自動でストリーミングされるようにする。', - reply_link_preview: 'マウスカーソルを重ねた時に返信のプレビューを表示するようにする。', - follow_import: 'フォローインポート', - import_followers_from_a_csv_file: 'CSVファイルからフォローをインポートする。', - follows_imported: 'フォローがインポートされました!処理に少し時間がかかるかもしれません。', - follow_import_error: 'フォロワーのインポート中にエラーが発生しました。' - }, - notifications: { - notifications: '通知', - read: '読んだ!', - followed_you: 'フォローされました', - favorited_you: 'あなたの投稿がお気に入りされました', - repeated_you: 'あなたの投稿がリピートされました' - }, - login: { - login: 'ログイン', - username: 'ユーザー名', - password: 'パスワード', - register: '登録', - logout: 'ログアウト' - }, - registration: { - registration: '登録', - fullname: '表示名', - email: 'Eメール', - bio: 'プロフィール', - password_confirm: 'パスワードの確認' - }, - post_status: { - posting: '投稿', - default: 'ちょうどL.A.に着陸しました。' - }, - finder: { - find_user: 'ユーザー検索', - error_fetching_user: 'ユーザー検索でエラーが発生しました' - }, - general: { - submit: '送信', - apply: '適用' - }, - user_profile: { - timeline_title: 'ユーザータイムライン' - } -} +// When contributing, please sort JSON before committing so it would be easier to see what's missing and what's being added compared to English and other languages. It's not obligatory, but just an advice. +// To sort json use jq https://stedolan.github.io/jq and invoke it like `jq -S . xx.json > xx.sorted.json`, AFAIK, there's no inplace edit option like in sed +// Also, when adding a new language to "messages" variable, please do it alphabetically by language code so that users can search or check their custom language easily. -const fr = { - nav: { - chat: 'Chat local', - timeline: 'Journal', - mentions: 'Notifications', - public_tl: 'Statuts locaux', - twkn: 'Le réseau connu' - }, - user_card: { - follows_you: 'Vous suit !', - following: 'Suivi !', - follow: 'Suivre', - blocked: 'Bloqué', - block: 'Bloquer', - statuses: 'Statuts', - mute: 'Mettre en muet', - muted: 'Mis en muet', - followers: 'Vous suivent', - followees: 'Suivis', - per_day: 'par jour', - remote_follow: 'Suivre d\'une autre instance' - }, - timeline: { - show_new: 'Afficher plus', - error_fetching: 'Erreur en cherchant des mises à jours', - up_to_date: 'À jour', - load_older: 'Afficher plus', - conversation: 'Conversation', - collapse: 'Fermer', - repeated: 'a partagé' - }, - settings: { - user_settings: 'Paramètres utilisateur', - name_bio: 'Nom & Bio', - name: 'Nom', - bio: 'Bioraphie', - avatar: 'Avatar', - current_avatar: 'Votre avatar', - set_new_avatar: 'Changer d\'avatar', - profile_banner: 'Bannière du profil', - current_profile_banner: 'Bannière du profil', - set_new_profile_banner: 'Changer de bannière', - profile_background: 'Image de fond', - set_new_profile_background: 'Changer d\'image de fond', - settings: 'Paramètres', - theme: 'Thème', - filtering: 'Filtre', - filtering_explanation: 'Tout les statuts contenant ces mots vont être cachés, un mot par ligne.', - attachments: 'Pièces jointes', - hide_attachments_in_tl: 'Cacher les pièces jointes dans le journal', - hide_attachments_in_convo: 'Cacher les pièces jointes dans les conversations', - nsfw_clickthrough: 'Activer le clic pour afficher les images marquées comme contenu adulte ou sensible', - autoload: 'Activer le chargement automatique une fois le bas de la page atteint', - reply_link_preview: 'Activer un aperçu d\'une réponse sur passage de la souris', - presets: 'Thèmes prédéfinis', - theme_help: 'Utilisez les codes de couleur hexadécimaux (#aabbcc) pour customiser les couleurs de votre thème.', - background: 'Arrière plan', - foreground: 'Premier plan', - text: 'Texte', - links: 'Liens', - streaming: 'Active le défilement automatique de nouveaux statuts lorsqu\'on est au haut de la page', - follow_import: 'Importer ses abonnements', - import_followers_from_a_csv_file: 'Importer ses abonnements depuis un fichier csv', - follows_imported: 'Abonnements importés ! Le traitement peut prendre un moment.', - follow_import_error: 'Erreur lors de l\'importation des abonnements.', - cBlue: 'Bleu (Répondre, suivre)', - cRed: 'Rouge (Annuler)', - cOrange: 'Orange (Aimer)', - cGreen: 'Vert (Partager)', - btnRadius: 'Boutons', - panelRadius: 'Fenêtres', - avatarRadius: 'Avatars', - avatarAltRadius: 'Avatars (Notifications)', - tooltipRadius: 'Info-bulles/alertes ', - attachmentRadius: 'Pièces jointes', - radii_help: 'Mettre en place l\'arondissement des coins de l\'interface (en pixels)', - stop_gifs: 'Passer la souris sur un GIF pour l\'animer' - }, - notifications: { - notifications: 'Notifications', - read: 'Lu !', - followed_you: 'vous a suivi', - favorited_you: 'a aimé votre statut', - repeated_you: 'a partagé votre statut' - }, - login: { - login: 'Connexion', - username: 'Nom d\'utilisateur', - password: 'Mot de passe', - register: 'S\'inscrire', - logout: 'Déconnexion' - }, - registration: { - registration: 'Inscription', - fullname: 'Nom affiché', - email: 'Adresse email', - bio: 'Biographie', - password_confirm: 'Confirmez le mot de passe' - }, - post_status: { - posting: 'Envoi en cours', - default: 'Écrivez ici votre prochain statut.' - }, - finder: { - find_user: 'Chercher un utilisateur', - error_fetching_user: 'Une erreur est survenue lors de la recherche de l\'utilisateur' - }, - general: { - submit: 'Envoyer', - apply: 'Appliquer' - }, - user_profile: { - timeline_title: 'Journal de l\'utilisateur' - } -} - -const it = { - nav: { - timeline: 'Sequenza temporale', - mentions: 'Menzioni', - public_tl: 'Sequenza temporale pubblica', - twkn: 'L\'intiera rete conosciuta' - }, - user_card: { - follows_you: 'Ti segue!', - following: 'Lo stai seguendo!', - follow: 'Segui', - statuses: 'Messaggi', - mute: 'Ammutolisci', - muted: 'Ammutoliti', - followers: 'Chi ti segue', - followees: 'Chi stai seguendo', - per_day: 'al giorno' - }, - timeline: { - show_new: 'Mostra nuovi', - error_fetching: 'Errori nel prelievo aggiornamenti', - up_to_date: 'Aggiornato', - load_older: 'Carica messaggi più vecchi' - }, - settings: { - user_settings: 'Configurazione dell\'utente', - name_bio: 'Nome & Introduzione', - name: 'Nome', - bio: 'Introduzione', - avatar: 'Avatar', - current_avatar: 'Il tuo attuale avatar', - set_new_avatar: 'Scegli un nuovo avatar', - profile_banner: 'Sfondo del tuo profilo', - current_profile_banner: 'Sfondo attuale', - set_new_profile_banner: 'Scegli un nuovo sfondo per il tuo profilo', - profile_background: 'Sfondo della tua pagina', - set_new_profile_background: 'Scegli un nuovo sfondo per la tua pagina', - settings: 'Settaggi', - theme: 'Tema', - filtering: 'Filtri', - filtering_explanation: 'Filtra via le notifiche che contengono le seguenti parole (inserisci rigo per rigo le parole di innesco)', - attachments: 'Allegati', - hide_attachments_in_tl: 'Nascondi gli allegati presenti nella sequenza temporale', - hide_attachments_in_convo: 'Nascondi gli allegati presenti nelle conversazioni', - nsfw_clickthrough: 'Abilita la trasparenza degli allegati NSFW', - autoload: 'Abilita caricamento automatico quando si raggiunge il fondo schermo', - reply_link_preview: 'Ability il reply-link preview al passaggio del mouse' - }, - notifications: { - notifications: 'Notifiche', - read: 'Leggi!', - followed_you: 'ti ha seguito' - }, - general: { - submit: 'Invia' - } -} - -const oc = { - chat: { - title: 'Messatjariá' - }, - nav: { - chat: 'Chat local', - timeline: 'Flux d’actualitat', - mentions: 'Notificacions', - public_tl: 'Estatuts locals', - twkn: 'Lo malhum conegut' - }, - user_card: { - follows_you: 'Vos sèc !', - following: 'Seguit !', - follow: 'Seguir', - blocked: 'Blocat', - block: 'Blocar', - statuses: 'Estatuts', - mute: 'Amagar', - muted: 'Amagat', - followers: 'Seguidors', - followees: 'Abonaments', - per_day: 'per jorn', - remote_follow: 'Seguir a distància' - }, - timeline: { - show_new: 'Ne veire mai', - error_fetching: 'Error en cercant de mesas a jorn', - up_to_date: 'Actualizat', - load_older: 'Ne veire mai', - conversation: 'Conversacion', - collapse: 'Tampar', - repeated: 'repetit' - }, - settings: { - user_settings: 'Paramètres utilizaire', - name_bio: 'Nom & Bio', - name: 'Nom', - bio: 'Biografia', - avatar: 'Avatar', - current_avatar: 'Vòstre avatar actual', - set_new_avatar: 'Cambiar l’avatar', - profile_banner: 'Bandièra del perfil', - current_profile_banner: 'Bandièra actuala del perfil', - set_new_profile_banner: 'Cambiar de bandièra', - profile_background: 'Imatge de fons', - set_new_profile_background: 'Cambiar l’imatge de fons', - settings: 'Paramètres', - theme: 'Tèma', - presets: 'Pre-enregistrats', - theme_help: 'Emplegatz los còdis de color hex (#rrggbb) per personalizar vòstre tèma de color.', - radii_help: 'Configurar los caires arredondits de l’interfàcia (en pixèls)', - background: 'Rèire plan', - foreground: 'Endavant', - text: 'Tèxte', - links: 'Ligams', - cBlue: 'Blau (Respondre, seguir)', - cRed: 'Roge (Anullar)', - cOrange: 'Irange (Metre en favorit)', - cGreen: 'Verd (Repartajar)', - btnRadius: 'Botons', - panelRadius: 'Panèls', - avatarRadius: 'Avatars', - avatarAltRadius: 'Avatars (Notificacions)', - tooltipRadius: 'Astúcias/Alèrta', - attachmentRadius: 'Pèças juntas', - filtering: 'Filtre', - filtering_explanation: 'Totes los estatuts amb aqueles mots seràn en silenci, un mot per linha.', - attachments: 'Pèças juntas', - hide_attachments_in_tl: 'Rescondre las pèças juntas', - hide_attachments_in_convo: 'Rescondre las pèças juntas dins las conversacions', - nsfw_clickthrough: 'Activar lo clic per mostrar los imatges marcats coma pels adults o sensibles', - stop_gifs: 'Lançar los GIFs al subrevòl', - autoload: 'Activar lo cargament automatic un còp arribat al cap de la pagina', - streaming: 'Activar lo cargament automatic dels novèls estatus en anar amont', - reply_link_preview: 'Activar l’apercebut en passar la mirga', - follow_import: 'Importar los abonaments', - import_followers_from_a_csv_file: 'Importar los seguidors d’un fichièr csv', - follows_imported: 'Seguidors importats. Lo tractament pòt trigar una estona.', - follow_import_error: 'Error en important los seguidors' - }, - notifications: { - notifications: 'Notficacions', - read: 'Legit !', - followed_you: 'vos a seguit', - favorited_you: 'a aimat vòstre estatut', - repeated_you: 'a repetit your vòstre estatut' - }, - login: { - login: 'Connexion', - username: 'Nom d’utilizaire', - password: 'Senhal', - register: 'Se marcar', - logout: 'Desconnexion' - }, - registration: { - registration: 'Inscripcion', - fullname: 'Nom complèt', - email: 'Adreça de corrièl', - bio: 'Biografia', - password_confirm: 'Confirmar lo senhal' - }, - post_status: { - posting: 'Mandadís', - default: 'Escrivètz aquí vòstre estatut.' - }, - finder: { - find_user: 'Cercar un utilizaire', - error_fetching_user: 'Error pendent la recèrca d’un utilizaire' - }, - general: { - submit: 'Mandar', - apply: 'Aplicar' - }, - user_profile: { - timeline_title: 'Flux a l’utilizaire' - } -} - -const pl = { - chat: { - title: 'Czat' - }, - nav: { - chat: 'Lokalny czat', - timeline: 'Oś czasu', - mentions: 'Wzmianki', - public_tl: 'Publiczna oś czasu', - twkn: 'Cała znana sieć' - }, - user_card: { - follows_you: 'Obserwuje cię!', - following: 'Obserwowany!', - follow: 'Obserwuj', - blocked: 'Zablokowany!', - block: 'Zablokuj', - statuses: 'Statusy', - mute: 'Wycisz', - muted: 'Wyciszony', - followers: 'Obserwujący', - followees: 'Obserwowani', - per_day: 'dziennie', - remote_follow: 'Zdalna obserwacja' - }, - timeline: { - show_new: 'Pokaż nowe', - error_fetching: 'Błąd pobierania', - up_to_date: 'Na bieżąco', - load_older: 'Załaduj starsze statusy', - conversation: 'Rozmowa', - collapse: 'Zwiń', - repeated: 'powtórzono' - }, - settings: { - user_settings: 'Ustawienia użytkownika', - name_bio: 'Imię i bio', - name: 'Imię', - bio: 'Bio', - avatar: 'Awatar', - current_avatar: 'Twój obecny awatar', - set_new_avatar: 'Ustaw nowy awatar', - profile_banner: 'Banner profilu', - current_profile_banner: 'Twój obecny banner profilu', - set_new_profile_banner: 'Ustaw nowy banner profilu', - profile_background: 'Tło profilu', - set_new_profile_background: 'Ustaw nowe tło profilu', - settings: 'Ustawienia', - theme: 'Motyw', - presets: 'Gotowe motywy', - theme_help: 'Użyj kolorów w notacji szesnastkowej (#rrggbb), by stworzyć swój motyw.', - radii_help: 'Ustaw zaokrąglenie krawędzi interfejsu (w pikselach)', - background: 'Tło', - foreground: 'Pierwszy plan', - text: 'Tekst', - links: 'Łącza', - cBlue: 'Niebieski (odpowiedz, obserwuj)', - cRed: 'Czerwony (anuluj)', - cOrange: 'Pomarańczowy (ulubione)', - cGreen: 'Zielony (powtórzenia)', - btnRadius: 'Przyciski', - panelRadius: 'Panele', - avatarRadius: 'Awatary', - avatarAltRadius: 'Awatary (powiadomienia)', - tooltipRadius: 'Etykiety/alerty', - attachmentRadius: 'Załączniki', - filtering: 'Filtrowanie', - filtering_explanation: 'Wszystkie statusy zawierające te słowa będą wyciszone. Jedno słowo na linijkę', - attachments: 'Załączniki', - hide_attachments_in_tl: 'Ukryj załączniki w osi czasu', - hide_attachments_in_convo: 'Ukryj załączniki w rozmowach', - nsfw_clickthrough: 'Włącz domyślne ukrywanie załączników o treści nieprzyzwoitej (NSFW)', - stop_gifs: 'Odtwarzaj GIFy po najechaniu kursorem', - autoload: 'Włącz automatyczne ładowanie po przewinięciu do końca strony', - streaming: 'Włącz automatycznie strumieniowanie nowych postów gdy na początku strony', - reply_link_preview: 'Włącz dymek z podglądem postu po najechaniu na znak odpowiedzi', - follow_import: 'Import obserwowanych', - import_followers_from_a_csv_file: 'Importuj obserwowanych z pliku CSV', - follows_imported: 'Obserwowani zaimportowani! Przetwarzanie może trochę potrwać.', - follow_import_error: 'Błąd przy importowaniu obserwowanych' - }, - notifications: { - notifications: 'Powiadomienia', - read: 'Przeczytane!', - followed_you: 'obserwuje cię', - favorited_you: 'dodał twój status do ulubionych', - repeated_you: 'powtórzył twój status' - }, - login: { - login: 'Zaloguj', - username: 'Użytkownik', - password: 'Hasło', - register: 'Zarejestruj', - logout: 'Wyloguj' - }, - registration: { - registration: 'Rejestracja', - fullname: 'Wyświetlana nazwa profilu', - email: 'Email', - bio: 'Bio', - password_confirm: 'Potwierdzenie hasła' - }, - post_status: { - posting: 'Wysyłanie', - default: 'Właśnie wróciłem z kościoła' - }, - finder: { - find_user: 'Znajdź użytkownika', - error_fetching_user: 'Błąd przy pobieraniu profilu' - }, - general: { - submit: 'Wyślij', - apply: 'Zastosuj' - }, - user_profile: { - timeline_title: 'Oś czasu użytkownika' - } -} - -const es = { - chat: { - title: 'Chat' - }, - nav: { - chat: 'Chat Local', - timeline: 'Línea Temporal', - mentions: 'Menciones', - public_tl: 'Línea Temporal Pública', - twkn: 'Toda La Red Conocida' - }, - user_card: { - follows_you: '¡Te sigue!', - following: '¡Siguiendo!', - follow: 'Seguir', - blocked: '¡Bloqueado!', - block: 'Bloquear', - statuses: 'Estados', - mute: 'Silenciar', - muted: 'Silenciado', - followers: 'Seguidores', - followees: 'Siguiendo', - per_day: 'por día', - remote_follow: 'Seguir' - }, - timeline: { - show_new: 'Mostrar lo nuevo', - error_fetching: 'Error al cargar las actualizaciones', - up_to_date: 'Actualizado', - load_older: 'Cargar actualizaciones anteriores', - conversation: 'Conversación' - }, - settings: { - user_settings: 'Ajustes de Usuario', - name_bio: 'Nombre y Biografía', - name: 'Nombre', - bio: 'Biografía', - avatar: 'Avatar', - current_avatar: 'Tu avatar actual', - set_new_avatar: 'Cambiar avatar', - profile_banner: 'Cabecera del perfil', - current_profile_banner: 'Cabecera actual', - set_new_profile_banner: 'Cambiar cabecera', - profile_background: 'Fondo del Perfil', - set_new_profile_background: 'Cambiar fondo del perfil', - settings: 'Ajustes', - theme: 'Tema', - presets: 'Por defecto', - theme_help: 'Use códigos de color hexadecimales (#rrggbb) para personalizar su tema de colores.', - background: 'Segundo plano', - foreground: 'Primer plano', - text: 'Texto', - links: 'Links', - filtering: 'Filtros', - filtering_explanation: 'Todos los estados que contengan estas palabras serán silenciados, una por línea', - attachments: 'Adjuntos', - hide_attachments_in_tl: 'Ocultar adjuntos en la línea temporal', - hide_attachments_in_convo: 'Ocultar adjuntos en las conversaciones', - nsfw_clickthrough: 'Activar el clic para ocultar los adjuntos NSFW', - autoload: 'Activar carga automática al llegar al final de la página', - streaming: 'Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior', - reply_link_preview: 'Activar la previsualización del enlace de responder al pasar el ratón por encima', - follow_import: 'Importar personas que tú sigues', - import_followers_from_a_csv_file: 'Importar personas que tú sigues apartir de un archivo csv', - follows_imported: '¡Importado! Procesarlos llevará tiempo.', - follow_import_error: 'Error al importal el archivo' - }, - notifications: { - notifications: 'Notificaciones', - read: '¡Leído!', - followed_you: 'empezó a seguirte' - }, - login: { - login: 'Identificación', - username: 'Usuario', - password: 'Contraseña', - register: 'Registrar', - logout: 'Salir' - }, - registration: { - registration: 'Registro', - fullname: 'Nombre a mostrar', - email: 'Correo electrónico', - bio: 'Biografía', - password_confirm: 'Confirmación de contraseña' - }, - post_status: { - posting: 'Publicando', - default: 'Acabo de aterrizar en L.A.' - }, - finder: { - find_user: 'Encontrar usuario', - error_fetching_user: 'Error al buscar usuario' - }, - general: { - submit: 'Enviar', - apply: 'Aplicar' - } -} - -const pt = { - chat: { - title: 'Chat' - }, - nav: { - chat: 'Chat Local', - timeline: 'Linha do tempo', - mentions: 'Menções', - public_tl: 'Linha do tempo pública', - twkn: 'Toda a rede conhecida' - }, - user_card: { - follows_you: 'Segue você!', - following: 'Seguindo!', - follow: 'Seguir', - blocked: 'Bloqueado!', - block: 'Bloquear', - statuses: 'Postagens', - mute: 'Silenciar', - muted: 'Silenciado', - followers: 'Seguidores', - followees: 'Seguindo', - per_day: 'por dia', - remote_follow: 'Seguidor Remoto' - }, - timeline: { - show_new: 'Mostrar novas', - error_fetching: 'Erro buscando atualizações', - up_to_date: 'Atualizado', - load_older: 'Carregar postagens antigas', - conversation: 'Conversa' - }, - settings: { - user_settings: 'Configurações de Usuário', - name_bio: 'Nome & Biografia', - name: 'Nome', - bio: 'Biografia', - avatar: 'Avatar', - current_avatar: 'Seu avatar atual', - set_new_avatar: 'Alterar avatar', - profile_banner: 'Capa de perfil', - current_profile_banner: 'Sua capa de perfil atual', - set_new_profile_banner: 'Alterar capa de perfil', - profile_background: 'Plano de fundo de perfil', - set_new_profile_background: 'Alterar o plano de fundo de perfil', - settings: 'Configurações', - theme: 'Tema', - presets: 'Predefinições', - theme_help: 'Use cores em código hexadecimal (#rrggbb) para personalizar seu esquema de cores.', - background: 'Plano de Fundo', - foreground: 'Primeiro Plano', - text: 'Texto', - links: 'Links', - filtering: 'Filtragem', - filtering_explanation: 'Todas as postagens contendo estas palavras serão silenciadas, uma por linha.', - attachments: 'Anexos', - hide_attachments_in_tl: 'Ocultar anexos na linha do tempo.', - hide_attachments_in_convo: 'Ocultar anexos em conversas', - nsfw_clickthrough: 'Habilitar clique para ocultar anexos NSFW', - autoload: 'Habilitar carregamento automático quando a rolagem chegar ao fim.', - streaming: 'Habilitar o fluxo automático de postagens quando ao topo da página', - reply_link_preview: 'Habilitar a pré-visualização de link de respostas ao passar o mouse.', - follow_import: 'Importar seguidas', - import_followers_from_a_csv_file: 'Importe seguidores a partir de um arquivo CSV', - follows_imported: 'Seguidores importados! O processamento pode demorar um pouco.', - follow_import_error: 'Erro ao importar seguidores' - }, - notifications: { - notifications: 'Notificações', - read: 'Ler!', - followed_you: 'seguiu você' - }, - login: { - login: 'Entrar', - username: 'Usuário', - password: 'Senha', - register: 'Registrar', - logout: 'Sair' - }, - registration: { - registration: 'Registro', - fullname: 'Nome para exibição', - email: 'Correio eletrônico', - bio: 'Biografia', - password_confirm: 'Confirmação de senha' - }, - post_status: { - posting: 'Publicando', - default: 'Acabo de aterrizar em L.A.' - }, - finder: { - find_user: 'Buscar usuário', - error_fetching_user: 'Erro procurando usuário' - }, - general: { - submit: 'Enviar', - apply: 'Aplicar' - } -} - -const ru = { - chat: { - title: 'Чат' - }, - nav: { - chat: 'Локальный чат', - timeline: 'Лента', - mentions: 'Упоминания', - public_tl: 'Публичная лента', - twkn: 'Федеративная лента' - }, - user_card: { - follows_you: 'Читает вас', - following: 'Читаю', - follow: 'Читать', - blocked: 'Заблокирован', - block: 'Заблокировать', - statuses: 'Статусы', - mute: 'Игнорировать', - muted: 'Игнорирую', - followers: 'Читатели', - followees: 'Читаемые', - per_day: 'в день', - remote_follow: 'Читать удалённо' - }, - timeline: { - show_new: 'Показать новые', - error_fetching: 'Ошибка при обновлении', - up_to_date: 'Обновлено', - load_older: 'Загрузить старые статусы', - conversation: 'Разговор', - collapse: 'Свернуть', - repeated: 'повторил(а)' - }, - settings: { - user_settings: 'Настройки пользователя', - name_bio: 'Имя и описание', - name: 'Имя', - bio: 'Описание', - avatar: 'Аватар', - current_avatar: 'Текущий аватар', - set_new_avatar: 'Загрузить новый аватар', - profile_banner: 'Баннер профиля', - current_profile_banner: 'Текущий баннер профиля', - set_new_profile_banner: 'Загрузить новый баннер профиля', - profile_background: 'Фон профиля', - set_new_profile_background: 'Загрузить новый фон профиля', - settings: 'Настройки', - theme: 'Тема', - presets: 'Пресеты', - theme_help: 'Используйте шестнадцатеричные коды цветов (#rrggbb) для настройки темы.', - radii_help: 'Округление краёв элементов интерфейса (в пикселях)', - background: 'Фон', - foreground: 'Передний план', - text: 'Текст', - links: 'Ссылки', - cBlue: 'Ответить, читать', - cRed: 'Отменить', - cOrange: 'Нравится', - cGreen: 'Повторить', - btnRadius: 'Кнопки', - panelRadius: 'Панели', - avatarRadius: 'Аватары', - avatarAltRadius: 'Аватары в уведомлениях', - tooltipRadius: 'Всплывающие подсказки/уведомления', - attachmentRadius: 'Прикреплённые файлы', - filtering: 'Фильтрация', - filtering_explanation: 'Все статусы, содержащие данные слова, будут игнорироваться, по одному в строке', - attachments: 'Вложения', - hide_attachments_in_tl: 'Прятать вложения в ленте', - hide_attachments_in_convo: 'Прятать вложения в разговорах', - stop_gifs: 'Проигрывать GIF анимации только при наведении', - nsfw_clickthrough: 'Включить скрытие NSFW вложений', - autoload: 'Включить автоматическую загрузку при прокрутке вниз', - streaming: 'Включить автоматическую загрузку новых сообщений при прокрутке вверх', - reply_link_preview: 'Включить предварительный просмотр ответа при наведении мыши', - follow_import: 'Импортировать читаемых', - import_followers_from_a_csv_file: 'Импортировать читаемых из файла .csv', - follows_imported: 'Список читаемых импортирован. Обработка займёт некоторое время..', - follow_import_error: 'Ошибка при импортировании читаемых.' - }, - notifications: { - notifications: 'Уведомления', - read: 'Прочесть', - followed_you: 'начал(а) читать вас', - favorited_you: 'нравится ваш статус', - repeated_you: 'повторил(а) ваш статус' - }, - login: { - login: 'Войти', - username: 'Имя пользователя', - password: 'Пароль', - register: 'Зарегистрироваться', - logout: 'Выйти' - }, - registration: { - registration: 'Регистрация', - fullname: 'Отображаемое имя', - email: 'Email', - bio: 'Описание', - password_confirm: 'Подтверждение пароля' - }, - post_status: { - posting: 'Отправляется', - default: 'Что нового?' - }, - finder: { - find_user: 'Найти пользователя', - error_fetching_user: 'Пользователь не найден' - }, - general: { - submit: 'Отправить', - apply: 'Применить' - }, - user_profile: { - timeline_title: 'Лента пользователя' - } -} +// For anyone contributing to old huge messages.js and in need to quickly convert it to JSON +// sed command for converting currently formatted JS to JSON: +// sed -i -e "s/'//gm" -e 's/"/\\"/gm' -re 's/^( +)(.+?): ((.+?))?(,?)(\{?)$/\1"\2": "\4"/gm' -e 's/\"\{\"/{/g' -e 's/,"$/",/g' file.json +// There's only problem that apostrophe character ' gets replaced by \\ so you have to fix it manually, sorry. const messages = { - de, - fi, - en, - et, - hu, - ro, - ja, - fr, - it, - oc, - pl, - es, - pt, - ru + ar: require('./ar.json'), + ca: require('./ca.json'), + de: require('./de.json'), + en: require('./en.json'), + eo: require('./eo.json'), + es: require('./es.json'), + et: require('./et.json'), + fi: require('./fi.json'), + fr: require('./fr.json'), + ga: require('./ga.json'), + he: require('./he.json'), + hu: require('./hu.json'), + it: require('./it.json'), + ja: require('./ja.json'), + nb: require('./nb.json'), + oc: require('./oc.json'), + pl: require('./pl.json'), + pt: require('./pt.json'), + ro: require('./ro.json'), + ru: require('./ru.json'), + zh: require('./zh.json') } export default messages diff --git a/src/i18n/nb.json b/src/i18n/nb.json new file mode 100644 index 00000000..0f4dca58 --- /dev/null +++ b/src/i18n/nb.json @@ -0,0 +1,199 @@ +{ + "chat": { + "title": "Nettprat" + }, + "features_panel": { + "chat": "Nettprat", + "gopher": "Gopher", + "media_proxy": "Media proxy", + "scope_options": "Velg mottakere", + "text_limit": "Tekst-grense", + "title": "Egenskaper", + "who_to_follow": "Hvem å følge" + }, + "finder": { + "error_fetching_user": "Feil ved henting av bruker", + "find_user": "Finn bruker" + }, + "general": { + "apply": "Bruk", + "submit": "Send" + }, + "login": { + "login": "Logg inn", + "logout": "Logg ut", + "password": "Passord", + "placeholder": "f. eks lain", + "register": "Registrer", + "username": "Brukernavn" + }, + "nav": { + "chat": "Lokal nettprat", + "friend_requests": "Følgeforespørsler", + "mentions": "Nevnt", + "public_tl": "Offentlig Tidslinje", + "timeline": "Tidslinje", + "twkn": "Det hele kjente nettverket" + }, + "notifications": { + "broken_favorite": "Ukjent status, leter etter den...", + "favorited_you": "likte din status", + "followed_you": "fulgte deg", + "load_older": "Last eldre varsler", + "notifications": "Varslinger", + "read": "Les!", + "repeated_you": "Gjentok din status" + }, + "post_status": { + "account_not_locked_warning": "Kontoen din er ikke {0}. Hvem som helst kan følge deg for å se dine statuser til følgere", + "account_not_locked_warning_link": "låst", + "attachments_sensitive": "Merk vedlegg som sensitive", + "content_type": { + "plain_text": "Klar tekst" + }, + "content_warning": "Tema (valgfritt)", + "default": "Landet akkurat i L.A.", + "direct_warning": "Denne statusen vil kun bli sett av nevnte brukere", + "posting": "Publiserer", + "scope": { + "direct": "Direkte, publiser bare til nevnte brukere", + "private": "Bare følgere, publiser bare til brukere som følger deg", + "public": "Offentlig, publiser til offentlige tidslinjer", + "unlisted": "Uoppført, ikke publiser til offentlige tidslinjer" + } + }, + "registration": { + "bio": "Biografi", + "email": "Epost-adresse", + "fullname": "Visningsnavn", + "password_confirm": "Bekreft passord", + "registration": "Registrering", + "token": "Invitasjons-bevis" + }, + "settings": { + "attachmentRadius": "Vedlegg", + "attachments": "Vedlegg", + "autoload": "Automatisk lasting når du blar ned til bunnen", + "avatar": "Profilbilde", + "avatarAltRadius": "Profilbilde (Varslinger)", + "avatarRadius": "Profilbilde", + "background": "Bakgrunn", + "bio": "Biografi", + "btnRadius": "Knapper", + "cBlue": "Blå (Svar, følg)", + "cGreen": "Grønn (Gjenta)", + "cOrange": "Oransje (Lik)", + "cRed": "Rød (Avbryt)", + "change_password": "Endre passord", + "change_password_error": "Feil ved endring av passord", + "changed_password": "Passord endret", + "collapse_subject": "Sammenfold statuser med tema", + "confirm_new_password": "Bekreft nytt passord", + "current_avatar": "Ditt nåværende profilbilde", + "current_password": "Nåværende passord", + "current_profile_banner": "Din nåværende profil-banner", + "data_import_export_tab": "Data import / eksport", + "default_vis": "Standard visnings-omfang", + "delete_account": "Slett konto", + "delete_account_description": "Slett din konto og alle dine statuser", + "delete_account_error": "Det oppsto et problem ved sletting av kontoen din, hvis dette problemet forblir kontakt din administrator", + "delete_account_instructions": "Skriv inn ditt passord i feltet nedenfor for å bekrefte sletting av konto", + "export_theme": "Lagre tema", + "filtering": "Filtrering", + "filtering_explanation": "Alle statuser som inneholder disse ordene vil bli dempet, en kombinasjon av tegn per linje", + "follow_export": "Eksporter følginger", + "follow_export_button": "Eksporter følgingene dine til en .csv fil", + "follow_export_processing": "Jobber, du vil snart bli spurt om å laste ned filen din.", + "follow_import": "Importer følginger", + "follow_import_error": "Feil ved importering av følginger.", + "follows_imported": "Følginger importert! Behandling vil ta litt tid.", + "foreground": "Forgrunn", + "general": "Generell", + "hide_attachments_in_convo": "Gjem vedlegg i samtaler", + "hide_attachments_in_tl": "Gjem vedlegg på tidslinje", + "import_followers_from_a_csv_file": "Importer følginger fra en csv fil", + "import_theme": "Last tema", + "inputRadius": "Input felt", + "instance_default": "(standard: {value})", + "interfaceLanguage": "Grensesnitt-språk", + "invalid_theme_imported": "Den valgte filen er ikke ett støttet Pleroma-tema, ingen endringer til ditt tema ble gjort", + "limited_availability": "Ikke tilgjengelig i din nettleser", + "links": "Linker", + "lock_account_description": "Begrens din konto til bare godkjente følgere", + "loop_video": "Gjenta videoer", + "loop_video_silent_only": "Gjenta bare videoer uten lyd, (for eksempel Mastodon sine \"gifs\")", + "name": "Navn", + "name_bio": "Navn & Biografi", + "new_password": "Nytt passord", + "notification_visibility": "Typer varsler som skal vises", + "notification_visibility_follows": "Følginger", + "notification_visibility_likes": "Likes", + "notification_visibility_mentions": "Nevnt", + "notification_visibility_repeats": "Gjentakelser", + "no_rich_text_description": "Fjern all formatering fra statuser", + "nsfw_clickthrough": "Krev trykk for å vise statuser som kan være upassende", + "panelRadius": "Panel", + "pause_on_unfocused": "Stopp henting av poster når vinduet ikke er i fokus", + "presets": "Forhåndsdefinerte tema", + "profile_background": "Profil-bakgrunn", + "profile_banner": "Profil-banner", + "profile_tab": "Profil", + "radii_help": "Bestem hvor runde hjørnene i brukergrensesnittet skal være (i piksler)", + "replies_in_timeline": "Svar på tidslinje", + "reply_link_preview": "Vis en forhåndsvisning når du holder musen over svar til en status", + "reply_visibility_all": "Vis alle svar", + "reply_visibility_following": "Vis bare svar som er til meg eller folk jeg følger", + "reply_visibility_self": "Vis bare svar som er til meg", + "saving_err": "Feil ved lagring av innstillinger", + "saving_ok": "Innstillinger lagret", + "security_tab": "Sikkerhet", + "set_new_avatar": "Rediger profilbilde", + "set_new_profile_background": "Rediger profil-bakgrunn", + "set_new_profile_banner": "Sett ny profil-banner", + "settings": "Innstillinger", + "stop_gifs": "Spill av GIFs når du holder over dem", + "streaming": "Automatisk strømming av nye statuser når du har bladd til toppen", + "text": "Tekst", + "theme": "Tema", + "theme_help": "Bruk heksadesimale fargekoder (#rrggbb) til å endre farge-temaet ditt.", + "tooltipRadius": "Verktøytips/advarsler", + "user_settings": "Brukerinstillinger", + "values": { + "false": "nei", + "true": "ja" + } + }, + "timeline": { + "collapse": "Sammenfold", + "conversation": "Samtale", + "error_fetching": "Feil ved henting av oppdateringer", + "load_older": "Last eldre statuser", + "no_retweet_hint": "Status er markert som bare til følgere eller direkte og kan ikke gjentas", + "repeated": "gjentok", + "show_new": "Vis nye", + "up_to_date": "Oppdatert" + }, + "user_card": { + "approve": "Godkjenn", + "block": "Blokker", + "blocked": "Blokkert!", + "deny": "Avslå", + "follow": "Følg", + "followees": "Følger", + "followers": "Følgere", + "following": "Følger!", + "follows_you": "Følger deg!", + "mute": "Demp", + "muted": "Dempet", + "per_day": "per dag", + "remote_follow": "Følg eksternt", + "statuses": "Statuser" + }, + "user_profile": { + "timeline_title": "Bruker-tidslinje" + }, + "who_to_follow": { + "more": "Mer", + "who_to_follow": "Hvem å følge" + } +} diff --git a/src/i18n/oc.json b/src/i18n/oc.json new file mode 100644 index 00000000..2ce666c6 --- /dev/null +++ b/src/i18n/oc.json @@ -0,0 +1,201 @@ +{ + "chat": { + "title": "Messatjariá" + }, + "finder": { + "error_fetching_user": "Error pendent la recèrca d’un utilizaire", + "find_user": "Cercar un utilizaire" + }, + "general": { + "apply": "Aplicar", + "submit": "Mandar" + }, + "login": { + "login": "Connexion", + "logout": "Desconnexion", + "password": "Senhal", + "placeholder": "e.g. lain", + "register": "Se marcar", + "username": "Nom d’utilizaire" + }, + "nav": { + "chat": "Chat local", + "mentions": "Notificacions", + "public_tl": "Estatuts locals", + "timeline": "Flux d’actualitat", + "twkn": "Lo malhum conegut", + "friend_requests": "Demandas d'abonament" + }, + "notifications": { + "favorited_you": "a aimat vòstre estatut", + "followed_you": "vos a seguit", + "notifications": "Notficacions", + "read": "Legit !", + "repeated_you": "a repetit vòstre estatut", + "broken_favorite": "Estatut desconegut, sèm a lo cercar...", + "load_older": "Cargar las notificaciones mai ancianas" + }, + "post_status": { + "content_warning": "Avís de contengut (opcional)", + "default": "Escrivètz aquí vòstre estatut.", + "posting": "Mandadís", + "account_not_locked_warning": "Vòstre compte es pas {0}. Qual que siá pòt vos seguir per veire vòstras publicacions destinadas pas qu'a vòstres seguidors.", + "account_not_locked_warning_link": "clavat", + "attachments_sensitive": "Marcar las pèças juntas coma sensiblas", + "content_type": { + "plain_text": "Tèxte brut" + }, + "direct_warning": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats.", + "scope": { + "direct": "Dirècte - Publicar pels utilizaires mencionats solament", + "private": "Seguidors solament - Publicar pels sols seguidors", + "public": "Public - Publicar pel flux d’actualitat public", + "unlisted": "Pas listat - Publicar pas pel flux public" + } + }, + "registration": { + "bio": "Biografia", + "email": "Adreça de corrièl", + "fullname": "Nom complèt", + "password_confirm": "Confirmar lo senhal", + "registration": "Inscripcion", + "token": "Geton de convidat" + }, + "settings": { + "attachmentRadius": "Pèças juntas", + "attachments": "Pèças juntas", + "autoload": "Activar lo cargament automatic un còp arribat al cap de la pagina", + "avatar": "Avatar", + "avatarAltRadius": "Avatars (Notificacions)", + "avatarRadius": "Avatars", + "background": "Rèire plan", + "bio": "Biografia", + "btnRadius": "Botons", + "cBlue": "Blau (Respondre, seguir)", + "cGreen": "Verd (Repartajar)", + "cOrange": "Irange (Aimar)", + "cRed": "Roge (Anullar)", + "change_password": "Cambiar lo senhal", + "change_password_error": "Una error s’es producha en cambiant lo senhal.", + "changed_password": "Senhal corrèctament cambiat !", + "confirm_new_password": "Confirmatz lo nòu senhal", + "current_avatar": "Vòstre avatar actual", + "current_password": "Senhal actual", + "current_profile_banner": "Bandièra actuala del perfil", + "delete_account": "Suprimir lo compte", + "delete_account_description": "Suprimir vòstre compte e los messatges per sempre.", + "delete_account_error": "Una error s’es producha en suprimir lo compte. S’aquò ten d’arribar mercés de contactar vòstre administrador d’instància.", + "delete_account_instructions": "Picatz vòstre senhal dins lo camp tèxte çai-jos per confirmar la supression del compte.", + "filtering": "Filtre", + "filtering_explanation": "Totes los estatuts amb aqueles mots seràn en silenci, un mot per linha", + "follow_export": "Exportar los abonaments", + "follow_export_button": "Exportar vòstres abonaments dins un fichièr csv", + "follow_export_processing": "Tractament, vos demandarem lèu de telecargar lo fichièr", + "follow_import": "Importar los abonaments", + "follow_import_error": "Error en important los seguidors", + "follows_imported": "Seguidors importats. Lo tractament pòt trigar una estona.", + "foreground": "Endavant", + "hide_attachments_in_convo": "Rescondre las pèças juntas dins las conversacions", + "hide_attachments_in_tl": "Rescondre las pèças juntas", + "import_followers_from_a_csv_file": "Importar los seguidors d’un fichièr csv", + "inputRadius": "Camps tèxte", + "links": "Ligams", + "name": "Nom", + "name_bio": "Nom & Bio", + "new_password": "Nòu senhal", + "nsfw_clickthrough": "Activar lo clic per mostrar los imatges marcats coma pels adults o sensibles", + "panelRadius": "Panèls", + "presets": "Pre-enregistrats", + "profile_background": "Imatge de fons", + "profile_banner": "Bandièra del perfil", + "radii_help": "Configurar los caires arredondits de l’interfàcia (en pixèls)", + "reply_link_preview": "Activar l’apercebut en passar la mirga", + "set_new_avatar": "Cambiar l’avatar", + "set_new_profile_background": "Cambiar l’imatge de fons", + "set_new_profile_banner": "Cambiar de bandièra", + "settings": "Paramètres", + "stop_gifs": "Lançar los GIFs al subrevòl", + "streaming": "Activar lo cargament automatic dels novèls estatus en anar amont", + "text": "Tèxte", + "theme": "Tèma", + "theme_help": "Emplegatz los còdis de color hex (#rrggbb) per personalizar vòstre tèma de color.", + "tooltipRadius": "Astúcias/Alèrta", + "user_settings": "Paramètres utilizaire", + "collapse_subject": "Replegar las publicacions amb de subjèctes", + "data_import_export_tab": "Importar / Exportar las donadas", + "default_vis": "Nivèl de visibilitat per defaut", + "export_theme": "Enregistrar la preconfiguracion", + "general": "General", + "hide_post_stats": "Amagar los estatistics de publicacion (ex. lo ombre de favorits)", + "hide_user_stats": "Amagar las estatisticas de l’utilizaire (ex. lo nombre de seguidors)", + "import_theme": "Cargar un tèma", + "instance_default": "(defaut : {value})", + "interfaceLanguage": "Lenga de l’interfàcia", + "invalid_theme_imported": "Lo fichièr seleccionat es pas un tèma Pleroma valid. Cap de cambiament es estat fach a vòstre tèma.", + "limited_availability": "Pas disponible per vòstre navigador", + "lock_account_description": "Limitar vòstre compte als seguidors acceptats solament", + "loop_video": "Bocla vidèo", + "loop_video_silent_only": "Legir en bocla solament las vidèos sens son (coma los « Gifs » de Mastodon)", + "notification_visibility": "Tipes de notificacion de mostrar", + "notification_visibility_follows": "Abonaments", + "notification_visibility_likes": "Aiman", + "notification_visibility_mentions": "Mencions", + "notification_visibility_repeats": "Repeticions", + "no_rich_text_description": "Netejar lo format tèxte de totas las publicacions", + "pause_on_unfocused": "Pausar la difusion quand l’onglet es pas seleccionat", + "profile_tab": "Perfil", + "replies_in_timeline": "Responsas del flux", + "reply_visibility_all": "Mostrar totas las responsas", + "reply_visibility_following": "Mostrar pas que las responsas que me son destinada a ieu o un utilizaire que seguissi", + "reply_visibility_self": "Mostrar pas que las responsas que me son destinadas", + "saving_err": "Error en enregistrant los paramètres", + "saving_ok": "Paramètres enregistrats", + "security_tab": "Seguretat", + "values": { + "false": "non", + "true": "òc" + } + }, + "timeline": { + "collapse": "Tampar", + "conversation": "Conversacion", + "error_fetching": "Error en cercant de mesas a jorn", + "load_older": "Ne veire mai", + "repeated": "repetit", + "show_new": "Ne veire mai", + "up_to_date": "A jorn", + "no_retweet_hint": "La publicacion marcada coma pels seguidors solament o dirècte pòt pas èsser repetida" + }, + "user_card": { + "block": "Blocar", + "blocked": "Blocat !", + "follow": "Seguir", + "followees": "Abonaments", + "followers": "Seguidors", + "following": "Seguit !", + "follows_you": "Vos sèc !", + "mute": "Amagar", + "muted": "Amagat", + "per_day": "per jorn", + "remote_follow": "Seguir a distància", + "statuses": "Estatuts", + "approve": "Validar", + "deny": "Refusar" + }, + "user_profile": { + "timeline_title": "Flux utilizaire" + }, + "features_panel": { + "chat": "Discutida", + "gopher": "Gopher", + "media_proxy": "Servidor mandatari dels mèdias", + "scope_options": "Opcions d'encastres", + "text_limit": "Limit de tèxte", + "title": "Foncionalitats", + "who_to_follow": "Qui seguir" + }, + "who_to_follow": { + "more": "Mai", + "who_to_follow": "Qui seguir" + } +}
\ No newline at end of file diff --git a/src/i18n/pl.json b/src/i18n/pl.json new file mode 100644 index 00000000..a3952d4f --- /dev/null +++ b/src/i18n/pl.json @@ -0,0 +1,133 @@ +{ + "chat": { + "title": "Czat" + }, + "finder": { + "error_fetching_user": "Błąd przy pobieraniu profilu", + "find_user": "Znajdź użytkownika" + }, + "general": { + "apply": "Zastosuj", + "submit": "Wyślij" + }, + "login": { + "login": "Zaloguj", + "logout": "Wyloguj", + "password": "Hasło", + "placeholder": "n.p. lain", + "register": "Zarejestruj", + "username": "Użytkownik" + }, + "nav": { + "chat": "Lokalny czat", + "mentions": "Wzmianki", + "public_tl": "Publiczna oś czasu", + "timeline": "Oś czasu", + "twkn": "Cała znana sieć" + }, + "notifications": { + "favorited_you": "dodał twój status do ulubionych", + "followed_you": "obserwuje cię", + "notifications": "Powiadomienia", + "read": "Przeczytane!", + "repeated_you": "powtórzył twój status" + }, + "post_status": { + "default": "Właśnie wróciłem z kościoła", + "posting": "Wysyłanie" + }, + "registration": { + "bio": "Bio", + "email": "Email", + "fullname": "Wyświetlana nazwa profilu", + "password_confirm": "Potwierdzenie hasła", + "registration": "Rejestracja" + }, + "settings": { + "attachmentRadius": "Załączniki", + "attachments": "Załączniki", + "autoload": "Włącz automatyczne ładowanie po przewinięciu do końca strony", + "avatar": "Awatar", + "avatarAltRadius": "Awatary (powiadomienia)", + "avatarRadius": "Awatary", + "background": "Tło", + "bio": "Bio", + "btnRadius": "Przyciski", + "cBlue": "Niebieski (odpowiedz, obserwuj)", + "cGreen": "Zielony (powtórzenia)", + "cOrange": "Pomarańczowy (ulubione)", + "cRed": "Czerwony (anuluj)", + "change_password": "Zmień hasło", + "change_password_error": "Podczas zmiany hasła wystąpił problem.", + "changed_password": "Hasło zmienione poprawnie!", + "confirm_new_password": "Potwierdź nowe hasło", + "current_avatar": "Twój obecny awatar", + "current_password": "Obecne hasło", + "current_profile_banner": "Twój obecny banner profilu", + "delete_account": "Usuń konto", + "delete_account_description": "Trwale usuń konto i wszystkie posty.", + "delete_account_error": "Wystąpił problem z usuwaniem twojego konta. Jeżeli problem powtarza się, poinformuj administratora swojej instancji.", + "delete_account_instructions": "Wprowadź swoje hasło w poniższe pole aby potwierdzić usunięcie konta.", + "filtering": "Filtrowanie", + "filtering_explanation": "Wszystkie statusy zawierające te słowa będą wyciszone. Jedno słowo na linijkę.", + "follow_export": "Eksport obserwowanych", + "follow_export_button": "Eksportuj swoją listę obserwowanych do pliku CSV", + "follow_export_processing": "Przetwarzanie, wkrótce twój plik zacznie się ściągać.", + "follow_import": "Import obserwowanych", + "follow_import_error": "Błąd przy importowaniu obserwowanych", + "follows_imported": "Obserwowani zaimportowani! Przetwarzanie może trochę potrwać.", + "foreground": "Pierwszy plan", + "hide_attachments_in_convo": "Ukryj załączniki w rozmowach", + "hide_attachments_in_tl": "Ukryj załączniki w osi czasu", + "import_followers_from_a_csv_file": "Importuj obserwowanych z pliku CSV", + "inputRadius": "Pola tekstowe", + "links": "Łącza", + "name": "Imię", + "name_bio": "Imię i bio", + "new_password": "Nowe hasło", + "nsfw_clickthrough": "Włącz domyślne ukrywanie załączników o treści nieprzyzwoitej (NSFW)", + "panelRadius": "Panele", + "presets": "Gotowe motywy", + "profile_background": "Tło profilu", + "profile_banner": "Banner profilu", + "radii_help": "Ustaw zaokrąglenie krawędzi interfejsu (w pikselach)", + "reply_link_preview": "Włącz dymek z podglądem postu po najechaniu na znak odpowiedzi", + "set_new_avatar": "Ustaw nowy awatar", + "set_new_profile_background": "Ustaw nowe tło profilu", + "set_new_profile_banner": "Ustaw nowy banner profilu", + "settings": "Ustawienia", + "stop_gifs": "Odtwarzaj GIFy po najechaniu kursorem", + "streaming": "Włącz automatycznie strumieniowanie nowych postów gdy na początku strony", + "text": "Tekst", + "theme": "Motyw", + "theme_help": "Użyj kolorów w notacji szesnastkowej (#rrggbb), by stworzyć swój motyw.", + "tooltipRadius": "Etykiety/alerty", + "user_settings": "Ustawienia użytkownika" + }, + "timeline": { + "collapse": "Zwiń", + "conversation": "Rozmowa", + "error_fetching": "Błąd pobierania", + "load_older": "Załaduj starsze statusy", + "repeated": "powtórzono", + "show_new": "Pokaż nowe", + "up_to_date": "Na bieżąco" + }, + "user_card": { + "block": "Zablokuj", + "blocked": "Zablokowany!", + "follow": "Obserwuj", + "followees": "Obserwowani", + "followers": "Obserwujący", + "following": "Obserwowany!", + "follows_you": "Obserwuje cię!", + "mute": "Wycisz", + "muted": "Wyciszony", + "per_day": "dziennie", + "remote_follow": "Zdalna obserwacja", + "statuses": "Statusy" + }, + "user_profile": { + "timeline_title": "Oś czasu użytkownika" + } +} diff --git a/src/i18n/pt.json b/src/i18n/pt.json new file mode 100644 index 00000000..544eacdf --- /dev/null +++ b/src/i18n/pt.json @@ -0,0 +1,117 @@ +{ + "chat": { + "title": "Chat" + }, + "finder": { + "error_fetching_user": "Erro procurando usuário", + "find_user": "Buscar usuário" + }, + "general": { + "apply": "Aplicar", + "submit": "Enviar" + }, + "login": { + "login": "Entrar", + "logout": "Sair", + "password": "Senha", + "placeholder": "p.e. lain", + "register": "Registrar", + "username": "Usuário" + }, + "nav": { + "chat": "Chat local", + "mentions": "Menções", + "public_tl": "Linha do tempo pública", + "timeline": "Linha do tempo", + "twkn": "Toda a rede conhecida" + }, + "notifications": { + "favorited_you": "favoritou sua postagem", + "followed_you": "seguiu você", + "notifications": "Notificações", + "read": "Lido!", + "repeated_you": "repetiu sua postagem" + }, + "post_status": { + "default": "Acabei de chegar no Rio!", + "posting": "Publicando" + }, + "registration": { + "bio": "Biografia", + "email": "Correio eletrônico", + "fullname": "Nome para exibição", + "password_confirm": "Confirmação de senha", + "registration": "Registro" + }, + "settings": { + "attachmentRadius": "Anexos", + "attachments": "Anexos", + "autoload": "Habilitar carregamento automático quando a rolagem chegar ao fim.", + "avatar": "Avatar", + "avatarAltRadius": "Avatares (Notificações)", + "avatarRadius": "Avatares", + "background": "Plano de Fundo", + "bio": "Biografia", + "btnRadius": "Botões", + "cBlue": "Azul (Responder, seguir)", + "cGreen": "Verde (Repetir)", + "cOrange": "Laranja (Favoritar)", + "cRed": "Vermelho (Cancelar)", + "current_avatar": "Seu avatar atual", + "current_profile_banner": "Sua capa de perfil atual", + "filtering": "Filtragem", + "filtering_explanation": "Todas as postagens contendo estas palavras serão silenciadas, uma por linha.", + "follow_import": "Importar seguidas", + "follow_import_error": "Erro ao importar seguidores", + "follows_imported": "Seguidores importados! O processamento pode demorar um pouco.", + "foreground": "Primeiro Plano", + "hide_attachments_in_convo": "Ocultar anexos em conversas", + "hide_attachments_in_tl": "Ocultar anexos na linha do tempo.", + "import_followers_from_a_csv_file": "Importe seguidores a partir de um arquivo CSV", + "links": "Links", + "name": "Nome", + "name_bio": "Nome & Biografia", + "nsfw_clickthrough": "Habilitar clique para ocultar anexos NSFW", + "panelRadius": "Paineis", + "presets": "Predefinições", + "profile_background": "Plano de fundo de perfil", + "profile_banner": "Capa de perfil", + "radii_help": "Arredondar arestas da interface (em píxeis)", + "reply_link_preview": "Habilitar a pré-visualização de link de respostas ao passar o mouse.", + "set_new_avatar": "Alterar avatar", + "set_new_profile_background": "Alterar o plano de fundo de perfil", + "set_new_profile_banner": "Alterar capa de perfil", + "settings": "Configurações", + "stop_gifs": "Reproduzir GIFs ao passar o cursor em cima", + "streaming": "Habilitar o fluxo automático de postagens quando ao topo da página", + "text": "Texto", + "theme": "Tema", + "theme_help": "Use cores em código hexadecimal (#rrggbb) para personalizar seu esquema de cores.", + "tooltipRadius": "Dicass/alertas", + "user_settings": "Configurações de Usuário" + }, + "timeline": { + "conversation": "Conversa", + "error_fetching": "Erro buscando atualizações", + "load_older": "Carregar postagens antigas", + "show_new": "Mostrar novas", + "up_to_date": "Atualizado" + }, + "user_card": { + "block": "Bloquear", + "blocked": "Bloqueado!", + "follow": "Seguir", + "followees": "Seguindo", + "followers": "Seguidores", + "following": "Seguindo!", + "follows_you": "Segue você!", + "mute": "Silenciar", + "muted": "Silenciado", + "per_day": "por dia", + "remote_follow": "Seguidor Remoto", + "statuses": "Postagens" + }, + "user_profile": { + "timeline_title": "Linha do tempo do usuário" + } +} diff --git a/src/i18n/ro.json b/src/i18n/ro.json new file mode 100644 index 00000000..3cee264f --- /dev/null +++ b/src/i18n/ro.json @@ -0,0 +1,83 @@ +{ + "finder": { + "error_fetching_user": "Eroare la preluarea utilizatorului", + "find_user": "Găsește utilizator" + }, + "general": { + "submit": "trimite" + }, + "login": { + "login": "Loghează", + "logout": "Deloghează", + "password": "Parolă", + "placeholder": "d.e. lain", + "register": "Înregistrare", + "username": "Nume utilizator" + }, + "nav": { + "mentions": "Menționări", + "public_tl": "Cronologie Publică", + "timeline": "Cronologie", + "twkn": "Toată Reșeaua Cunoscută" + }, + "notifications": { + "followed_you": "te-a urmărit", + "notifications": "Notificări", + "read": "Citit!" + }, + "post_status": { + "default": "Nu de mult am aterizat în L.A.", + "posting": "Postează" + }, + "registration": { + "bio": "Bio", + "email": "Email", + "fullname": "Numele întreg", + "password_confirm": "Cofirmă parola", + "registration": "Îregistrare" + }, + "settings": { + "attachments": "Atașamente", + "autoload": "Permite încărcarea automată când scrolat la capăt", + "avatar": "Avatar", + "bio": "Bio", + "current_avatar": "Avatarul curent", + "current_profile_banner": "Bannerul curent al profilului", + "filtering": "Filtru", + "filtering_explanation": "Toate stările care conțin aceste cuvinte vor fi puse pe mut, una pe linie", + "hide_attachments_in_convo": "Ascunde atașamentele în conversații", + "hide_attachments_in_tl": "Ascunde atașamentele în cronologie", + "name": "Nume", + "name_bio": "Nume și Bio", + "nsfw_clickthrough": "Permite ascunderea al atașamentelor NSFW", + "profile_background": "Fundalul de profil", + "profile_banner": "Banner de profil", + "reply_link_preview": "Permite previzualizarea linkului de răspuns la planarea de mouse", + "set_new_avatar": "Setează avatar nou", + "set_new_profile_background": "Setează fundal nou", + "set_new_profile_banner": "Setează banner nou la profil", + "settings": "Setări", + "theme": "Temă", + "user_settings": "Setările utilizatorului" + }, + "timeline": { + "conversation": "Conversație", + "error_fetching": "Erare la preluarea actualizărilor", + "load_older": "Încarcă stări mai vechi", + "show_new": "Arată cele noi", + "up_to_date": "La zi" + }, + "user_card": { + "block": "Blochează", + "blocked": "Blocat!", + "follow": "Urmărește", + "followees": "Urmărește", + "followers": "Următori", + "following": "Urmărit!", + "follows_you": "Te urmărește!", + "mute": "Pune pe mut", + "muted": "Pus pe mut", + "per_day": "pe zi", + "statuses": "Stări" + } +} diff --git a/src/i18n/ru.json b/src/i18n/ru.json new file mode 100644 index 00000000..13c7fec3 --- /dev/null +++ b/src/i18n/ru.json @@ -0,0 +1,295 @@ +{ + "chat": { + "title": "Чат" + }, + "finder": { + "error_fetching_user": "Пользователь не найден", + "find_user": "Найти пользователя" + }, + "general": { + "apply": "Применить", + "submit": "Отправить" + }, + "login": { + "login": "Войти", + "logout": "Выйти", + "password": "Пароль", + "placeholder": "e.c. lain", + "register": "Зарегистрироваться", + "username": "Имя пользователя" + }, + "nav": { + "chat": "Локальный чат", + "mentions": "Упоминания", + "public_tl": "Публичная лента", + "timeline": "Лента", + "twkn": "Федеративная лента" + }, + "notifications": { + "broken_favorite": "Неизвестный статус, ищем...", + "favorited_you": "нравится ваш статус", + "followed_you": "начал(а) читать вас", + "load_older": "Загрузить старые уведомления", + "notifications": "Уведомления", + "read": "Прочесть", + "repeated_you": "повторил(а) ваш статус" + }, + "post_status": { + "account_not_locked_warning": "Ваш аккаунт не {0}. Кто угодно может зафоловить вас чтобы прочитать посты только для подписчиков", + "account_not_locked_warning_link": "залочен", + "attachments_sensitive": "Вложения содержат чувствительный контент", + "content_warning": "Тема (не обязательно)", + "default": "Что нового?", + "direct_warning": "Этот пост будет видет только упомянутым пользователям", + "posting": "Отправляется", + "scope": { + "direct": "Личное - этот пост видят только те кто в нём упомянут", + "private": "Для подписчиков - этот пост видят только подписчики", + "public": "Публичный - этот пост виден всем", + "unlisted": "Непубличный - этот пост не виден на публичных лентах" + } + }, + "registration": { + "bio": "Описание", + "email": "Email", + "fullname": "Отображаемое имя", + "password_confirm": "Подтверждение пароля", + "registration": "Регистрация", + "token": "Код приглашения", + "validations": { + "username_required": "не должно быть пустым", + "fullname_required": "не должно быть пустым", + "email_required": "не должен быть пустым", + "password_required": "не должен быть пустым", + "password_confirmation_required": "не должно быть пустым", + "password_confirmation_match": "должно совпадать с паролем" + } + }, + "settings": { + "attachmentRadius": "Прикреплённые файлы", + "attachments": "Вложения", + "autoload": "Включить автоматическую загрузку при прокрутке вниз", + "avatar": "Аватар", + "avatarAltRadius": "Аватары в уведомлениях", + "avatarRadius": "Аватары", + "background": "Фон", + "bio": "Описание", + "btnRadius": "Кнопки", + "cBlue": "Ответить, читать", + "cGreen": "Повторить", + "cOrange": "Нравится", + "cRed": "Отменить", + "change_password": "Сменить пароль", + "change_password_error": "Произошла ошибка при попытке изменить пароль.", + "changed_password": "Пароль изменён успешно.", + "collapse_subject": "Сворачивать посты с темой", + "confirm_new_password": "Подтверждение нового пароля", + "current_avatar": "Текущий аватар", + "current_password": "Текущий пароль", + "current_profile_banner": "Текущий баннер профиля", + "data_import_export_tab": "Импорт / Экспорт данных", + "delete_account": "Удалить аккаунт", + "delete_account_description": "Удалить ваш аккаунт и все ваши сообщения.", + "delete_account_error": "Возникла ошибка в процессе удаления вашего аккаунта. Если это повторяется, свяжитесь с администратором вашего сервера.", + "delete_account_instructions": "Введите ваш пароль в поле ниже для подтверждения удаления.", + "export_theme": "Сохранить Тему", + "filtering": "Фильтрация", + "filtering_explanation": "Все статусы, содержащие данные слова, будут игнорироваться, по одному в строке", + "follow_export": "Экспортировать читаемых", + "follow_export_button": "Экспортировать читаемых в файл .csv", + "follow_export_processing": "Ведётся обработка, скоро вам будет предложено загрузить файл", + "follow_import": "Импортировать читаемых", + "follow_import_error": "Ошибка при импортировании читаемых.", + "follows_imported": "Список читаемых импортирован. Обработка займёт некоторое время..", + "foreground": "Передний план", + "general": "Общие", + "hide_attachments_in_convo": "Прятать вложения в разговорах", + "hide_attachments_in_tl": "Прятать вложения в ленте", + "hide_isp": "Скрыть серверную панель", + "import_followers_from_a_csv_file": "Импортировать читаемых из файла .csv", + "import_theme": "Загрузить Тему", + "inputRadius": "Поля ввода", + "checkboxRadius": "Чекбоксы", + "interface": "Интерфейс", + "interfaceLanguage": "Язык интерфейса", + "limited_availability": "Не доступно в вашем браузере", + "links": "Ссылки", + "lock_account_description": "Аккаунт доступен только подтверждённым подписчикам", + "loop_video": "Зациливать видео", + "loop_video_silent_only": "Зацикливать только беззвучные видео (т.е. \"гифки\" с Mastodon)", + "name": "Имя", + "name_bio": "Имя и описание", + "new_password": "Новый пароль", + "notification_visibility": "Показывать уведомления", + "notification_visibility_follows": "Подписки", + "notification_visibility_likes": "Лайки", + "notification_visibility_mentions": "Упоминания", + "notification_visibility_repeats": "Повторы", + "no_rich_text_description": "Убрать форматирование из всех постов", + "hide_network_description": "Не показывать кого я читаю и кто меня читает", + "nsfw_clickthrough": "Включить скрытие NSFW вложений", + "panelRadius": "Панели", + "pause_on_unfocused": "Приостановить загрузку когда вкладка не в фокусе", + "presets": "Пресеты", + "profile_background": "Фон профиля", + "profile_banner": "Баннер профиля", + "profile_tab": "Профиль", + "radii_help": "Скругление углов элементов интерфейса (в пикселях)", + "replies_in_timeline": "Ответы в ленте", + "reply_link_preview": "Включить предварительный просмотр ответа при наведении мыши", + "reply_visibility_all": "Показывать все ответы", + "reply_visibility_following": "Показывать только ответы мне и тех на кого я подписан", + "reply_visibility_self": "Показывать только ответы мне", + "security_tab": "Безопасность", + "set_new_avatar": "Загрузить новый аватар", + "set_new_profile_background": "Загрузить новый фон профиля", + "set_new_profile_banner": "Загрузить новый баннер профиля", + "settings": "Настройки", + "subject_input_always_show": "Всегда показывать поле ввода темы", + "stop_gifs": "Проигрывать GIF анимации только при наведении", + "streaming": "Включить автоматическую загрузку новых сообщений при прокрутке вверх", + "text": "Текст", + "theme": "Тема", + "theme_help": "Используйте шестнадцатеричные коды цветов (#rrggbb) для настройки темы.", + "theme_help_v2_1": "Вы так же можете перепоределить цвета определенных компонентов нажав соотв. галочку. Используйте кнопку \"Очистить всё\" чтобы снять все переопределения", + "theme_help_v2_2": "Под некоторыми полями ввода это идикаторы контрастности, наведите на них мышью чтобы узнать больше. Приспользовании прозрачности контраст расчитывается для наихудшего варианта.", + "tooltipRadius": "Всплывающие подсказки/уведомления", + "user_settings": "Настройки пользователя", + "style": { + "switcher": { + "keep_color": "Оставить цвета", + "keep_shadows": "Оставить тени", + "keep_opacity": "Оставить прозрачность", + "keep_roundness": "Оставить скругление", + "keep_fonts": "Оставить шрифты", + "save_load_hint": "Опции \"оставить...\" позволяют сохранить текущие настройки при выборе другой темы или импорта её из файла. Так же они влияют на то какие компоненты будут сохранены при экспорте темы. Когда все галочки сняты все компоненты будут экспортированы.", + "reset": "Сбросить", + "clear_all": "Очистить всё", + "clear_opacity": "Очистить прозрачность" + }, + "common": { + "color": "Цвет", + "opacity": "Прозрачность", + "contrast": { + "hint": "Уровень контраста: {ratio}, что {level} {context}", + "level": { + "aa": "соответствует гайдлайну Level AA (минимальный)", + "aaa": "соответствует гайдлайну Level AAA (рекомендуемый)", + "bad": "не соответствует каким либо гайдлайнам" + }, + "context": { + "18pt": "для крупного (18pt+) текста", + "text": "для текста" + } + } + }, + "common_colors": { + "_tab_label": "Общие", + "main": "Общие цвета", + "foreground_hint": "См. вкладку \"Дополнительно\" для более детального контроля", + "rgbo": "Иконки, акценты, ярылки" + }, + "advanced_colors": { + "_tab_label": "Дополнительно", + "alert": "Фон уведомлений", + "alert_error": "Ошибки", + "badge": "Фон значков", + "badge_notification": "Уведомления", + "panel_header": "Заголовок панели", + "top_bar": "Верняя полоска", + "borders": "Границы", + "buttons": "Кнопки", + "inputs": "Поля ввода", + "faint_text": "Маловажный текст" + }, + "radii": { + "_tab_label": "Скругление" + }, + "shadows": { + "_tab_label": "Светотень", + "component": "Компонент", + "override": "Переопределить", + "shadow_id": "Тень №{value}", + "blur": "Размытие", + "spread": "Разброс", + "inset": "Внутренняя", + "hint": "Для теней вы так же можете использовать --variable в качестве цвета чтобы использовать CSS3-переменные. В таком случае прозрачность работать не будет.", + "filter_hint": { + "always_drop_shadow": "Внимание, эта тень всегда использует {0} когда браузер поддерживает это", + "drop_shadow_syntax": "{0} не поддерживает параметр {1} и ключевое слово {2}", + "avatar_inset": "Одновременное использование внутренних и внешних теней на (прозрачных) аватарках может дать не те результаты что вы ожидаете", + "spread_zero": "Тени с разбросом > 0 будут выглядеть как если бы разброс установлен в 0", + "inset_classic": "Внутренние тени будут использовать {0}" + }, + "components": { + "panel": "Панель", + "panelHeader": "Заголовок панели", + "topBar": "Верхняя полоска", + "avatar": "Аватарка (профиль)", + "avatarStatus": "Аватарка (в ленте)", + "popup": "Всплывающие подсказки", + "button": "Кнопки", + "buttonHover": "Кнопки (наведен курсор)", + "buttonPressed": "Кнопки (нажата)", + "buttonPressedHover": "Кнопки (нажата+наведен курсор)", + "input": "Поля ввода" + } + }, + "fonts": { + "_tab_label": "Шрифты", + "help": "Выберите тип шрифта для использования в интерфейсе. При выборе варианта \"другой\" надо ввести название шрифта в точности как он называется в системе.", + "components": { + "interface": "Интерфейс", + "input": "Поля ввода", + "post": "Текст постов", + "postCode": "Моноширинный текст в посте (форматирование)" + }, + "family": "Шрифт", + "size": "Размер (в пикселях)", + "weight": "Ширина", + "custom": "Другой" + }, + "preview": { + "header": "Пример", + "content": "Контент", + "error": "Ошибка стоп 000", + "button": "Кнопка", + "text": "Еще немного {0} и масенькая {1}", + "mono": "контента", + "input": "Что нового?", + "faint_link": "Его придется убрать", + "fine_print": "Если проблемы остались — ваш гуртовщик мыши плохо стоит. {0}.", + "header_faint": "Все идет по плану", + "checkbox": "Я подтверждаю что не было ни единого разрыва", + "link": "ссылка" + } + } + }, + "timeline": { + "collapse": "Свернуть", + "conversation": "Разговор", + "error_fetching": "Ошибка при обновлении", + "load_older": "Загрузить старые статусы", + "no_retweet_hint": "Пост помечен как \"только для подписчиков\" или \"личное\" и поэтому не может быть повторён", + "repeated": "повторил(а)", + "show_new": "Показать новые", + "up_to_date": "Обновлено" + }, + "user_card": { + "block": "Заблокировать", + "blocked": "Заблокирован", + "follow": "Читать", + "followees": "Читаемые", + "followers": "Читатели", + "following": "Читаю", + "follows_you": "Читает вас", + "mute": "Игнорировать", + "muted": "Игнорирую", + "per_day": "в день", + "remote_follow": "Читать удалённо", + "statuses": "Статусы" + }, + "user_profile": { + "timeline_title": "Лента пользователя" + } +} diff --git a/src/i18n/zh.json b/src/i18n/zh.json new file mode 100644 index 00000000..7ad23c57 --- /dev/null +++ b/src/i18n/zh.json @@ -0,0 +1,201 @@ +{ + "chat": { + "title": "聊天" + }, + "features_panel": { + "chat": "聊天", + "gopher": "Gopher", + "media_proxy": "媒体代理", + "scope_options": "可见范围设置", + "text_limit": "文本长度限制", + "title": "功能", + "who_to_follow": "推荐关注" + }, + "finder": { + "error_fetching_user": "获取用户时发生错误", + "find_user": "寻找用户" + }, + "general": { + "apply": "应用", + "submit": "提交" + }, + "login": { + "login": "登录", + "logout": "登出", + "password": "密码", + "placeholder": "例如:lain", + "register": "注册", + "username": "用户名" + }, + "nav": { + "chat": "本地聊天", + "friend_requests": "关注请求", + "mentions": "提及", + "public_tl": "公共时间线", + "timeline": "时间线", + "twkn": "所有已知网络" + }, + "notifications": { + "broken_favorite": "未知的状态,正在搜索中...", + "favorited_you": "收藏了你的状态", + "followed_you": "关注了你", + "load_older": "加载更早的通知", + "notifications": "通知", + "read": "阅读!", + "repeated_you": "转发了你的状态" + }, + "post_status": { + "account_not_locked_warning": "你的帐号没有 {0}。任何人都可以关注你并浏览你的上锁内容。", + "account_not_locked_warning_link": "上锁", + "attachments_sensitive": "标记附件为敏感内容", + "content_type": { + "plain_text": "纯文本" + }, + "content_warning": "主题(可选)", + "default": "刚刚抵达上海", + "direct_warning": "本条内容只有被提及的用户能够看到。", + "posting": "发送", + "scope": { + "direct": "私信 - 只发送给被提及的用户", + "private": "仅关注者 - 只有关注了你的人能看到", + "public": "公共 - 发送到公共时间轴", + "unlisted": "不公开 - 所有人可见,但不会发送到公共时间轴" + } + }, + "registration": { + "bio": "简介", + "email": "电子邮箱", + "fullname": "全名", + "password_confirm": "确认密码", + "registration": "注册", + "token": "邀请码" + }, + "settings": { + "attachmentRadius": "附件", + "attachments": "附件", + "autoload": "启用滚动到底部时的自动加载", + "avatar": "头像", + "avatarAltRadius": "头像(通知)", + "avatarRadius": "头像", + "background": "背景", + "bio": "简介", + "btnRadius": "按钮", + "cBlue": "蓝色(回复,关注)", + "cGreen": "绿色(转发)", + "cOrange": "橙色(收藏)", + "cRed": "红色(取消)", + "change_password": "修改密码", + "change_password_error": "修改密码的时候出了点问题。", + "changed_password": "成功修改了密码!", + "collapse_subject": "折叠带主题的内容", + "confirm_new_password": "确认新密码", + "current_avatar": "当前头像", + "current_password": "当前密码", + "current_profile_banner": "您当前的横幅图片", + "data_import_export_tab": "数据导入/导出", + "default_vis": "默认可见范围", + "delete_account": "删除账户", + "delete_account_description": "永久删除你的帐号和所有消息。", + "delete_account_error": "删除账户时发生错误,如果一直删除不了,请联系实例管理员。", + "delete_account_instructions": "在下面输入你的密码来确认删除账户", + "export_theme": "导出预置主题", + "filtering": "过滤器", + "filtering_explanation": "所有包含以下词汇的内容都会被隐藏,一行一个", + "follow_export": "导出关注", + "follow_export_button": "将关注导出成 csv 文件", + "follow_export_processing": "正在处理,过一会儿就可以下载你的文件了", + "follow_import": "导入关注", + "follow_import_error": "导入关注时错误", + "follows_imported": "关注已导入!尚需要一些时间来处理。", + "foreground": "前景", + "general": "通用", + "hide_attachments_in_convo": "在对话中隐藏附件", + "hide_attachments_in_tl": "在时间线上隐藏附件", + "hide_post_stats": "隐藏推文相关的统计数据(例如:收藏的次数)", + "hide_user_stats": "隐藏用户的统计数据(例如:关注者的数量)", + "import_followers_from_a_csv_file": "从 csv 文件中导入关注", + "import_theme": "导入预置主题", + "inputRadius": "输入框", + "instance_default": "(默认:{value})", + "interfaceLanguage": "界面语言", + "invalid_theme_imported": "您所选择的主题文件不被 Pleroma 支持,因此主题未被修改。", + "limited_availability": "在您的浏览器中无法使用", + "links": "链接", + "lock_account_description": "你需要手动审核关注请求", + "loop_video": "循环视频", + "loop_video_silent_only": "只循环没有声音的视频(例如:Mastodon 里的“GIF”)", + "name": "名字", + "name_bio": "名字及简介", + "new_password": "新密码", + "notification_visibility": "要显示的通知类型", + "notification_visibility_follows": "关注", + "notification_visibility_likes": "点赞", + "notification_visibility_mentions": "提及", + "notification_visibility_repeats": "转发", + "no_rich_text_description": "不显示富文本格式", + "nsfw_clickthrough": "将不和谐附件隐藏,点击才能打开", + "panelRadius": "面板", + "pause_on_unfocused": "在离开页面时暂停时间线推送", + "presets": "预置", + "profile_background": "个人资料背景图", + "profile_banner": "横幅图片", + "profile_tab": "个人资料", + "radii_help": "设置界面边缘的圆角 (单位:像素)", + "replies_in_timeline": "时间线中的回复", + "reply_link_preview": "启用鼠标悬停时预览回复链接", + "reply_visibility_all": "显示所有回复", + "reply_visibility_following": "只显示发送给我的回复/发送给我关注的用户的回复", + "reply_visibility_self": "只显示发送给我的回复", + "saving_err": "保存设置时发生错误", + "saving_ok": "设置已保存", + "security_tab": "安全", + "set_new_avatar": "设置新头像", + "set_new_profile_background": "设置新的个人资料背景", + "set_new_profile_banner": "设置新的横幅图片", + "settings": "设置", + "stop_gifs": "鼠标悬停时播放GIF", + "streaming": "开启滚动到顶部时的自动推送", + "text": "文本", + "theme": "主题", + "theme_help": "使用十六进制代码(#rrggbb)来设置主题颜色。", + "tooltipRadius": "提醒", + "user_settings": "用户设置", + "values": { + "false": "否", + "true": "是" + } + }, + "timeline": { + "collapse": "折叠", + "conversation": "对话", + "error_fetching": "获取更新时发生错误", + "load_older": "加载更早的状态", + "no_retweet_hint": "这条内容仅关注者可见,或者是私信,因此不能转发。", + "repeated": "已转发", + "show_new": "显示新内容", + "up_to_date": "已是最新" + }, + "user_card": { + "approve": "允许", + "block": "屏蔽", + "blocked": "已屏蔽!", + "deny": "拒绝", + "follow": "关注", + "followees": "正在关注", + "followers": "关注者", + "following": "正在关注!", + "follows_you": "关注了你!", + "mute": "隐藏", + "muted": "已隐藏", + "per_day": "每天", + "remote_follow": "跨站关注", + "statuses": "状态" + }, + "user_profile": { + "timeline_title": "用户时间线" + }, + "who_to_follow": { + "more": "更多", + "who_to_follow": "推荐关注" + } +} diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js index 60811e65..ccd92633 100644 --- a/src/lib/persisted_state.js +++ b/src/lib/persisted_state.js @@ -1,7 +1,7 @@ import merge from 'lodash.merge' import objectPath from 'object-path' import localforage from 'localforage' -import { throttle, each } from 'lodash' +import { each } from 'lodash' let loaded = false @@ -12,18 +12,20 @@ const defaultReducer = (state, paths) => ( }, {}) ) +const saveImmedeatelyActions = [ + 'markNotificationsAsSeen', + 'clearCurrentUser', + 'setCurrentUser', + 'setHighlight', + 'setOption', + 'setClientData', + 'setToken' +] + const defaultStorage = (() => { return localforage })() -const defaultSetState = (key, state, storage) => { - if (!loaded) { - console.log('waiting for old state to be loaded...') - } else { - return storage.setItem(key, state) - } -} - export default function createPersistedState ({ key = 'vuex-lz', paths = [], @@ -31,13 +33,20 @@ export default function createPersistedState ({ let value = storage.getItem(key) return value }, - setState = throttle(defaultSetState, 60000), + setState = (key, state, storage) => { + if (!loaded) { + console.log('waiting for old state to be loaded...') + return Promise.resolve() + } else { + return storage.setItem(key, state) + } + }, reducer = defaultReducer, storage = defaultStorage, subscriber = store => handler => store.subscribe(handler) } = {}) { - return store => { - getState(key, storage).then((savedState) => { + return getState(key, storage).then((savedState) => { + return store => { try { if (typeof savedState === 'object') { // build user cache @@ -60,23 +69,36 @@ export default function createPersistedState ({ value: store.state.config.customTheme }) } - if (store.state.users.lastLoginName) { - store.dispatch('loginUser', {username: store.state.users.lastLoginName, password: 'xxx'}) + if (store.state.oauth.token) { + store.dispatch('loginUser', store.state.oauth.token) } loaded = true } catch (e) { console.log("Couldn't load state") + console.error(e) loaded = true } - }) - - subscriber(store)((mutation, state) => { - try { - setState(key, reducer(state, paths), storage) - } catch (e) { - console.log("Couldn't persist state:") - console.log(e) - } - }) - } + subscriber(store)((mutation, state) => { + try { + if (saveImmedeatelyActions.includes(mutation.type)) { + setState(key, reducer(state, paths), storage) + .then(success => { + if (typeof success !== 'undefined') { + if (mutation.type === 'setOption') { + store.dispatch('settingsSaved', { success }) + } + } + }, error => { + if (mutation.type === 'setOption') { + store.dispatch('settingsSaved', { error }) + } + }) + } + } catch (e) { + console.log("Couldn't persist state:") + console.log(e) + } + }) + } + }) } diff --git a/src/main.js b/src/main.js index 3d2bcbb0..6ce2df13 100644 --- a/src/main.js +++ b/src/main.js @@ -1,23 +1,15 @@ import Vue from 'vue' import VueRouter from 'vue-router' import Vuex from 'vuex' -import App from './App.vue' -import PublicTimeline from './components/public_timeline/public_timeline.vue' -import PublicAndExternalTimeline from './components/public_and_external_timeline/public_and_external_timeline.vue' -import FriendsTimeline from './components/friends_timeline/friends_timeline.vue' -import TagTimeline from './components/tag_timeline/tag_timeline.vue' -import ConversationPage from './components/conversation-page/conversation-page.vue' -import Mentions from './components/mentions/mentions.vue' -import UserProfile from './components/user_profile/user_profile.vue' -import Settings from './components/settings/settings.vue' -import Registration from './components/registration/registration.vue' -import UserSettings from './components/user_settings/user_settings.vue' +import interfaceModule from './modules/interface.js' +import instanceModule from './modules/instance.js' import statusesModule from './modules/statuses.js' import usersModule from './modules/users.js' import apiModule from './modules/api.js' import configModule from './modules/config.js' import chatModule from './modules/chat.js' +import oauthModule from './modules/oauth.js' import VueTimeago from 'vue-timeago' import VueI18n from 'vue-i18n' @@ -28,6 +20,8 @@ import messages from './i18n/messages.js' import VueChatScroll from 'vue-chat-scroll' +import afterStoreSetup from './boot/after_store.js' + const currentLocale = (window.navigator.language || 'en').split('-')[0] Vue.use(Vuex) @@ -42,135 +36,71 @@ Vue.use(VueTimeago, { Vue.use(VueI18n) Vue.use(VueChatScroll) -const persistedStateOptions = { - paths: [ - 'config.hideAttachments', - 'config.hideAttachmentsInConv', - 'config.hideNsfw', - 'config.autoLoad', - 'config.hoverPreview', - 'config.streaming', - 'config.muteWords', - 'config.customTheme', - 'users.lastLoginName' - ] -} - -const store = new Vuex.Store({ - modules: { - statuses: statusesModule, - users: usersModule, - api: apiModule, - config: configModule, - chat: chatModule - }, - plugins: [createPersistedState(persistedStateOptions)], - strict: false // Socket modifies itself, let's ignore this for now. - // strict: process.env.NODE_ENV !== 'production' -}) - const i18n = new VueI18n({ + // By default, use the browser locale, we will update it if neccessary locale: currentLocale, fallbackLocale: 'en', messages }) -window.fetch('/api/statusnet/config.json') - .then((res) => res.json()) - .then((data) => { - const {name, closed: registrationClosed, textlimit} = data.site +const persistedStateOptions = { + paths: [ + 'config', + 'users.lastLoginName', + 'oauth' + ] +} - store.dispatch('setOption', { name: 'name', value: name }) - store.dispatch('setOption', { name: 'registrationOpen', value: (registrationClosed === '0') }) - store.dispatch('setOption', { name: 'textlimit', value: parseInt(textlimit) }) - }) +const registerPushNotifications = store => { + store.subscribe((mutation, state) => { + const vapidPublicKey = state.instance.vapidPublicKey + const permission = state.interface.notificationPermission === 'granted' + const isUserMutation = mutation.type === 'setCurrentUser' -window.fetch('/static/config.json') - .then((res) => res.json()) - .then((data) => { - const {theme, background, logo, showInstanceSpecificPanel} = data - store.dispatch('setOption', { name: 'theme', value: theme }) - store.dispatch('setOption', { name: 'background', value: background }) - store.dispatch('setOption', { name: 'logo', value: logo }) - store.dispatch('setOption', { name: 'showInstanceSpecificPanel', value: showInstanceSpecificPanel }) - if (data['chatDisabled']) { - store.dispatch('disableChat') + if (isUserMutation && vapidPublicKey && permission) { + return store.dispatch('registerPushNotifications') } if (data['nsfwCensorImage']) { store.dispatch('setOption', { name: 'nsfwCensorImage', value: data['nsfwCensorImage'] }) } - const routes = [ - { name: 'root', path: '/', redirect: data['defaultPath'] || '/main/all' }, - { path: '/main/all', component: PublicAndExternalTimeline }, - { path: '/main/public', component: PublicTimeline }, - { path: '/main/friends', component: FriendsTimeline }, - { path: '/tag/:tag', component: TagTimeline }, - { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, - { name: 'user-profile', path: '/users/:id', component: UserProfile }, - { name: 'mentions', path: '/:username/mentions', component: Mentions }, - { name: 'settings', path: '/settings', component: Settings }, - { name: 'registration', path: '/registration', component: Registration }, - { name: 'user-settings', path: '/user-settings', component: UserSettings } - ] - - const router = new VueRouter({ - mode: 'history', - routes, - scrollBehavior: (to, from, savedPosition) => { - if (to.matched.some(m => m.meta.dontScroll)) { - return false - } - return savedPosition || { x: 0, y: 0 } - } - }) - - /* eslint-disable no-new */ - new Vue({ - router, - store, - i18n, - el: '#app', - render: h => h(App) - }) - }) + const user = state.users.currentUser + const isVapidMutation = mutation.type === 'setInstanceOption' && mutation.payload.name === 'vapidPublicKey' -window.fetch('/static/terms-of-service.html') - .then((res) => res.text()) - .then((html) => { - store.dispatch('setOption', { name: 'tos', value: html }) - }) + if (isVapidMutation && user && permission) { + return store.dispatch('registerPushNotifications') + } -window.fetch('/api/pleroma/emoji.json') - .then( - (res) => res.json() - .then( - (values) => { - const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: values[key] } - }) - store.dispatch('setOption', { name: 'customEmoji', value: emoji }) - store.dispatch('setOption', { name: 'pleromaBackend', value: true }) - }, - (failure) => { - store.dispatch('setOption', { name: 'pleromaBackend', value: false }) - } - ), - (error) => console.log(error) - ) - -window.fetch('/static/emoji.json') - .then((res) => res.json()) - .then((values) => { - const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: false, 'utf': values[key] } - }) - store.dispatch('setOption', { name: 'emoji', value: emoji }) + const isPermMutation = mutation.type === 'setNotificationPermission' && mutation.payload === 'granted' + + if (isPermMutation && user && vapidPublicKey) { + return store.dispatch('registerPushNotifications') + } }) +} -window.fetch('/instance/panel.html') - .then((res) => res.text()) - .then((html) => { - store.dispatch('setOption', { name: 'instanceSpecificPanelContent', value: html }) +createPersistedState(persistedStateOptions).then((persistedState) => { + const store = new Vuex.Store({ + modules: { + interface: interfaceModule, + instance: instanceModule, + statuses: statusesModule, + users: usersModule, + api: apiModule, + config: configModule, + chat: chatModule, + oauth: oauthModule + }, + plugins: [persistedState, registerPushNotifications], + strict: false // Socket modifies itself, let's ignore this for now. + // strict: process.env.NODE_ENV !== 'production' }) + afterStoreSetup({ store, i18n }) +}) + +// These are inlined by webpack's DefinePlugin +/* eslint-disable */ +window.___pleromafe_mode = process.env +window.___pleromafe_commit_hash = COMMIT_HASH +window.___pleromafe_dev_overrides = DEV_OVERRIDES diff --git a/src/modules/api.js b/src/modules/api.js index c91fb97b..2f07a91e 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -7,7 +7,8 @@ const api = { backendInteractor: backendInteractorService(), fetchers: {}, socket: null, - chatDisabled: false + chatDisabled: false, + followRequests: [] }, mutations: { setBackendInteractor (state, backendInteractor) { @@ -24,6 +25,9 @@ const api = { }, setChatDisabled (state, value) { state.chatDisabled = value + }, + setFollowRequests (state, value) { + state.followRequests = value } }, actions: { @@ -42,6 +46,9 @@ const api = { store.commit('addFetcher', {timeline, fetcher}) } }, + fetchOldPost (store, { postId }) { + store.state.backendInteractor.fetchOldPost({ store, postId }) + }, stopFetching (store, timeline) { const fetcher = store.state.fetchers[timeline] window.clearInterval(fetcher) @@ -57,6 +64,10 @@ const api = { }, disableChat (store) { store.commit('setChatDisabled', true) + }, + removeFollowRequest (store, request) { + let requests = store.state.followRequests.filter((it) => it !== request) + store.commit('setFollowRequests', requests) } } } diff --git a/src/modules/config.js b/src/modules/config.js index 9a62905e..ccfd0190 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,16 +1,36 @@ -import { set } from 'vue' -import StyleSetter from '../services/style_setter/style_setter.js' +import { set, delete as del } from 'vue' +import { setPreset, applyTheme } from '../services/style_setter/style_setter.js' + +const browserLocale = (window.navigator.language || 'en').split('-')[0] const defaultState = { - name: 'Pleroma FE', colors: {}, + collapseMessageWithSubject: undefined, // instance default hideAttachments: false, hideAttachmentsInConv: false, hideNsfw: true, + preloadImage: true, + loopVideo: true, + loopVideoSilentOnly: true, autoLoad: true, streaming: false, hoverPreview: true, - muteWords: [] + pauseOnUnfocused: true, + stopGifs: false, + replyVisibility: 'all', + notificationVisibility: { + follows: true, + mentions: true, + likes: true, + repeats: true + }, + webPushNotifications: true, + muteWords: [], + highlight: {}, + interfaceLanguage: browserLocale, + scopeCopy: undefined, // instance default + subjectLineBehavior: undefined, // instance default + alwaysShowSubjectInput: undefined // instance default } const config = { @@ -18,23 +38,28 @@ const config = { mutations: { setOption (state, { name, value }) { set(state, name, value) + }, + setHighlight (state, { user, color, type }) { + const data = this.state.config.highlight[user] + if (color || type) { + set(state.highlight, user, { color: color || data.color, type: type || data.type }) + } else { + del(state.highlight, user) + } } }, actions: { - setPageTitle ({state}, option = '') { - document.title = `${option} ${state.name}` + setHighlight ({ commit, dispatch }, { user, color, type }) { + commit('setHighlight', {user, color, type}) }, setOption ({ commit, dispatch }, { name, value }) { commit('setOption', {name, value}) switch (name) { - case 'name': - dispatch('setPageTitle') - break case 'theme': - StyleSetter.setPreset(value, commit) + setPreset(value, commit) break case 'customTheme': - StyleSetter.setColors(value, commit) + applyTheme(value, commit) } } } diff --git a/src/modules/errors.js b/src/modules/errors.js new file mode 100644 index 00000000..c809e1b5 --- /dev/null +++ b/src/modules/errors.js @@ -0,0 +1,12 @@ +import { capitalize } from 'lodash' + +export function humanizeErrors (errors) { + return Object.entries(errors).reduce((errs, [k, val]) => { + let message = val.reduce((acc, message) => { + let key = capitalize(k.replace(/_/g, ' ')) + return acc + [key, message].join(' ') + '. ' + }, '') + return [...errs, message] + }, []) +} + diff --git a/src/modules/instance.js b/src/modules/instance.js new file mode 100644 index 00000000..7c27d52a --- /dev/null +++ b/src/modules/instance.js @@ -0,0 +1,69 @@ +import { set } from 'vue' +import { setPreset } from '../services/style_setter/style_setter.js' + +const defaultState = { + // Stuff from static/config.json and apiConfig + name: 'Pleroma FE', + registrationOpen: true, + textlimit: 5000, + server: 'http://localhost:4040/', + theme: 'pleroma-dark', + background: '/static/aurora_borealis.jpg', + logo: '/static/logo.png', + logoMask: true, + logoMargin: '.2em', + redirectRootNoLogin: '/main/all', + redirectRootLogin: '/main/friends', + showInstanceSpecificPanel: false, + scopeOptionsEnabled: true, + formattingOptionsEnabled: false, + alwaysShowSubjectInput: true, + collapseMessageWithSubject: false, + hidePostStats: false, + hideUserStats: false, + disableChat: false, + scopeCopy: true, + subjectLineBehavior: 'email', + loginMethod: 'password', + + // Nasty stuff + pleromaBackend: true, + emoji: [], + customEmoji: [], + + // Feature-set, apparently, not everything here is reported... + mediaProxyAvailable: false, + chatAvailable: false, + gopherAvailable: false, + suggestionsEnabled: false, + suggestionsWeb: '', + + // Html stuff + instanceSpecificPanelContent: '', + tos: '' +} + +const instance = { + state: defaultState, + mutations: { + setInstanceOption (state, { name, value }) { + if (typeof value !== 'undefined') { + set(state, name, value) + } + } + }, + actions: { + setInstanceOption ({ commit, dispatch }, { name, value }) { + commit('setInstanceOption', {name, value}) + switch (name) { + case 'name': + dispatch('setPageTitle') + break + case 'theme': + setPreset(value, commit) + } + } + } +} + +export default instance diff --git a/src/modules/interface.js b/src/modules/interface.js new file mode 100644 index 00000000..956c9cb3 --- /dev/null +++ b/src/modules/interface.js @@ -0,0 +1,49 @@ +import { set, delete as del } from 'vue' + +const defaultState = { + settings: { + currentSaveStateNotice: null, + noticeClearTimeout: null, + notificationPermission: null + }, + browserSupport: { + cssFilter: window.CSS && window.CSS.supports && ( + window.CSS.supports('filter', 'drop-shadow(0 0)') || + window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') + ) + } +} + +const interfaceMod = { + state: defaultState, + mutations: { + settingsSaved (state, { success, error }) { + if (success) { + if (state.noticeClearTimeout) { + clearTimeout(state.noticeClearTimeout) + } + set(state.settings, 'currentSaveStateNotice', { error: false, data: success }) + set(state.settings, 'noticeClearTimeout', + setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000)) + } else { + set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error }) + } + }, + setNotificationPermission (state, permission) { + state.notificationPermission = permission + } + }, + actions: { + setPageTitle ({ rootState }, option = '') { + document.title = `${option} ${rootState.instance.name}` + }, + settingsSaved ({ commit, dispatch }, { success, error }) { + commit('settingsSaved', { success, error }) + }, + setNotificationPermission ({ commit }, permission) { + commit('setNotificationPermission', permission) + } + } +} + +export default interfaceMod diff --git a/src/modules/oauth.js b/src/modules/oauth.js new file mode 100644 index 00000000..144ff830 --- /dev/null +++ b/src/modules/oauth.js @@ -0,0 +1,18 @@ +const oauth = { + state: { + client_id: false, + client_secret: false, + token: false + }, + mutations: { + setClientData (state, data) { + state.client_id = data.client_id + state.client_secret = data.client_secret + }, + setToken (state, token) { + state.token = token + } + } +} + +export default oauth diff --git a/src/modules/statuses.js b/src/modules/statuses.js index bd52f161..8cdd4e28 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -15,6 +15,7 @@ const emptyTl = () => ({ followers: [], friends: [], viewing: 'statuses', + userId: 0, flushMarker: 0 }) @@ -22,16 +23,25 @@ export const defaultState = { allStatuses: [], allStatusesObject: {}, maxId: 0, - notifications: [], + notifications: { + desktopNotificationSilence: true, + maxId: 0, + minId: Number.POSITIVE_INFINITY, + data: [], + error: false, + brokenFavorites: {} + }, favorites: new Set(), error: false, timelines: { mentions: emptyTl(), public: emptyTl(), user: emptyTl(), + own: emptyTl(), publicAndExternal: emptyTl(), friends: emptyTl(), - tag: emptyTl() + tag: emptyTl(), + dms: emptyTl() } } @@ -45,7 +55,7 @@ export const prepareStatus = (status) => { if (status.nsfw === undefined) { status.nsfw = isNsfw(status) if (status.retweeted_status) { - status.retweeted_status.nsfw = status.nsfw + status.nsfw = status.retweeted_status.nsfw } } @@ -58,6 +68,15 @@ export const prepareStatus = (status) => { return status } +const visibleNotificationTypes = (rootState) => { + return [ + rootState.config.notificationVisibility.likes && 'like', + rootState.config.notificationVisibility.mentions && 'mention', + rootState.config.notificationVisibility.repeats && 'repeat', + rootState.config.notificationVisibility.follows && 'follow' + ].filter(_ => _) +} + export const statusType = (status) => { if (status.is_post_verb) { return 'status' @@ -76,8 +95,7 @@ export const statusType = (status) => { return 'deletion' } - // TODO change to status.activity_type === 'follow' when gs supports it - if (status.text.match(/started following/)) { + if (status.text.match(/started following/) || status.activity_type === 'follow') { return 'follow' } @@ -113,7 +131,7 @@ const sortTimeline = (timeline) => { return timeline } -const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false }) => { +const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId }) => { // Sanity check if (!isArray(statuses)) { return false @@ -130,15 +148,24 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us timelineObject.maxId = maxNew } + // This makes sure that user timeline won't get data meant for other + // user. I.e. opening different user profiles makes request which could + // return data late after user already viewing different user profile + if (timeline === 'user' && timelineObject.userId !== userId) { + return + } + const addStatus = (status, showImmediately, addToTimeline = true) => { const result = mergeOrAdd(allStatuses, allStatusesObject, status) status = result.item - if (result.new) { - if (statusType(status) === 'retweet' && status.retweeted_status.user.id === user.id) { - addNotification({ type: 'repeat', status: status.retweeted_status, action: status }) - } + const brokenFavorites = state.notifications.brokenFavorites[status.id] || [] + brokenFavorites.forEach((fav) => { + fav.status = status + }) + delete state.notifications.brokenFavorites[status.id] + if (result.new) { // We are mentioned in a post if (statusType(status) === 'status' && find(status.attentions, { id: user.id })) { const mentions = state.timelines.mentions @@ -150,10 +177,14 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us sortTimeline(mentions) } - // Don't add notification for self-mention - if (status.user.id !== user.id) { - addNotification({ type: 'mention', status, action: status }) - } + } + if (status.visibility === 'direct') { + const dms = state.timelines.dms + + mergeOrAdd(dms.statuses, dms.statusesObject, status) + dms.newStatusCount += 1 + + sortTimeline(dms) } } @@ -176,45 +207,14 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us return status } - const addNotification = ({type, status, action}) => { - // Only add a new notification if we don't have one for the same action - if (!find(state.notifications, (oldNotification) => oldNotification.action.id === action.id)) { - state.notifications.push({ type, status, action, seen: false }) - - if ('Notification' in window && window.Notification.permission === 'granted') { - const title = action.user.name - const result = {} - result.icon = action.user.profile_image_url - result.body = action.text // there's a problem that it doesn't put a space before links tho - - // Shows first attached non-nsfw image, if any. Should add configuration for this somehow... - if (action.attachments && action.attachments.length > 0 && !action.nsfw && - action.attachments[0].mimetype.startsWith('image/')) { - result.image = action.attachments[0].url - } - - let notification = new window.Notification(title, result) - - // Chrome is known for not closing notifications automatically - // according to MDN, anyway. - setTimeout(notification.close.bind(notification), 5000) - } - } - } - - const favoriteStatus = (favorite) => { + const favoriteStatus = (favorite, counter) => { const status = find(allStatuses, { id: toInteger(favorite.in_reply_to_status_id) }) if (status) { - status.fave_num += 1 - // This is our favorite, so the relevant bit. if (favorite.user.id === user.id) { status.favorited = true - } - - // Add a notification if the user's status is favorited - if (status.user.id === user.id) { - addNotification({type: 'favorite', status, action: favorite}) + } else { + status.fave_num += 1 } } return status @@ -248,18 +248,12 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us }, 'favorite': (favorite) => { // Only update if this is a new favorite. + // Ignore our own favorites because we get info about likes as response to like request if (!state.favorites.has(favorite.id)) { state.favorites.add(favorite.id) favoriteStatus(favorite) } }, - 'follow': (status) => { - let re = new RegExp(`started following ${user.name} \\(${user.statusnet_profile_url}\\)`) - let repleroma = new RegExp(`started following ${user.screen_name}$`) - if (status.text.match(re) || status.text.match(repleroma)) { - addNotification({ type: 'follow', status: status, action: status }) - } - }, 'deletion': (deletion) => { const uri = deletion.uri @@ -269,7 +263,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us return } - remove(state.notifications, ({action: {id}}) => id === status.id) + remove(state.notifications.data, ({action: {id}}) => id === status.id) remove(allStatuses, { uri }) if (timeline) { @@ -298,8 +292,68 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } } +const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes }) => { + const allStatuses = state.allStatuses + const allStatusesObject = state.allStatusesObject + each(notifications, (notification) => { + const result = mergeOrAdd(allStatuses, allStatusesObject, notification.notice) + const action = result.item + // Only add a new notification if we don't have one for the same action + if (!find(state.notifications.data, (oldNotification) => oldNotification.action.id === action.id)) { + state.notifications.maxId = Math.max(notification.id, state.notifications.maxId) + state.notifications.minId = Math.min(notification.id, state.notifications.minId) + + const fresh = !notification.is_seen + const status = notification.ntype === 'like' + ? find(allStatuses, { id: action.in_reply_to_status_id }) + : action + + const result = { + type: notification.ntype, + status, + action, + seen: !fresh + } + + if (notification.ntype === 'like' && !status) { + let broken = state.notifications.brokenFavorites[action.in_reply_to_status_id] + if (broken) { + broken.push(result) + } else { + dispatch('fetchOldPost', { postId: action.in_reply_to_status_id }) + broken = [ result ] + state.notifications.brokenFavorites[action.in_reply_to_status_id] = broken + } + } + + state.notifications.data.push(result) + + if ('Notification' in window && window.Notification.permission === 'granted') { + const title = action.user.name + const result = {} + result.icon = action.user.profile_image_url + result.body = action.text // there's a problem that it doesn't put a space before links tho + + // Shows first attached non-nsfw image, if any. Should add configuration for this somehow... + if (action.attachments && action.attachments.length > 0 && !action.nsfw && + action.attachments[0].mimetype.startsWith('image/')) { + result.image = action.attachments[0].url + } + + if (fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) { + let notification = new window.Notification(title, result) + // Chrome is known for not closing notifications automatically + // according to MDN, anyway. + setTimeout(notification.close.bind(notification), 5000) + } + } + } + }) +} + export const mutations = { addNewStatuses, + addNewNotifications, showNewStatuses (state, { timeline }) { const oldTimeline = (state.timelines[timeline]) @@ -316,6 +370,11 @@ export const mutations = { const newStatus = state.allStatusesObject[status.id] newStatus.favorited = value }, + setFavoritedConfirm (state, { status }) { + const newStatus = state.allStatusesObject[status.id] + newStatus.favorited = status.favorited + newStatus.fave_num = status.fave_num + }, setRetweeted (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] newStatus.repeated = value @@ -334,6 +393,12 @@ export const mutations = { setError (state, { value }) { state.error = value }, + setNotificationsError (state, { value }) { + state.notifications.error = value + }, + setNotificationsSilence (state, { value }) { + state.notifications.desktopNotificationSilence = value + }, setProfileView (state, { v }) { // load followers / friends only when needed state.timelines['user'].viewing = v @@ -344,8 +409,8 @@ export const mutations = { addFollowers (state, { followers }) { state.timelines['user'].followers = followers }, - markNotificationsAsSeen (state, notifications) { - each(notifications, (notification) => { + markNotificationsAsSeen (state) { + each(state.notifications.data, (notification) => { notification.seen = true }) }, @@ -357,12 +422,21 @@ export const mutations = { const statuses = { state: defaultState, actions: { - addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false }) { - commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser }) + addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { + commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) + }, + addNewNotifications ({ rootState, commit, dispatch }, { notifications, older }) { + commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older }) }, setError ({ rootState, commit }, { value }) { commit('setError', { value }) }, + setNotificationsError ({ rootState, commit }, { value }) { + commit('setNotificationsError', { value }) + }, + setNotificationsSilence ({ rootState, commit }, { value }) { + commit('setNotificationsSilence', { value }) + }, addFriends ({ rootState, commit }, { friends }) { commit('addFriends', { friends }) }, @@ -377,19 +451,50 @@ const statuses = { // Optimistic favoriting... commit('setFavorited', { status, value: true }) apiService.favorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) + .then(response => { + if (response.ok) { + return response.json() + } else { + return {} + } + }) + .then(status => { + commit('setFavoritedConfirm', { status }) + }) }, unfavorite ({ rootState, commit }, status) { // Optimistic favoriting... commit('setFavorited', { status, value: false }) apiService.unfavorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) + .then(response => { + if (response.ok) { + return response.json() + } else { + return {} + } + }) + .then(status => { + commit('setFavoritedConfirm', { status }) + }) }, retweet ({ rootState, commit }, status) { // Optimistic retweeting... commit('setRetweeted', { status, value: true }) apiService.retweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) }, + unretweet ({ rootState, commit }, status) { + commit('setRetweeted', { status, value: false }) + apiService.unretweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) + }, queueFlush ({ rootState, commit }, { timeline, id }) { commit('queueFlush', { timeline, id }) + }, + markNotificationsAsSeen ({ rootState, commit }) { + commit('markNotificationsAsSeen') + apiService.markNotificationsAsSeen({ + id: rootState.statuses.notifications.maxId, + credentials: rootState.users.currentUser.credentials + }) } }, mutations diff --git a/src/modules/users.js b/src/modules/users.js index 8303ecc1..25d1c81f 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -1,6 +1,9 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import { compact, map, each, merge } from 'lodash' import { set } from 'vue' +import registerPushNotifications from '../services/push/push.js' +import oauthApi from '../services/new_api/oauth' +import { humanizeErrors } from './errors' // TODO: Unify with mergeOrAdd in statuses.js export const mergeOrAdd = (arr, obj, item) => { @@ -9,17 +12,28 @@ export const mergeOrAdd = (arr, obj, item) => { if (oldItem) { // We already have this, so only merge the new info. merge(oldItem, item) - return {item: oldItem, new: false} + return { item: oldItem, new: false } } else { // This is a new item, prepare it arr.push(item) obj[item.id] = item - return {item, new: true} + if (item.screen_name && !item.screen_name.includes('@')) { + obj[item.screen_name] = item + } + return { item, new: true } } } +const getNotificationPermission = () => { + const Notification = window.Notification + + if (!Notification) return Promise.resolve(null) + if (Notification.permission === 'default') return Notification.requestPermission() + return Promise.resolve(Notification.permission) +} + export const mutations = { - setMuted (state, { user: {id}, muted }) { + setMuted (state, { user: { id }, muted }) { const user = state.usersObject[id] set(user, 'muted', muted) }, @@ -42,15 +56,32 @@ export const mutations = { }, setUserForStatus (state, status) { status.user = state.usersObject[status.user.id] + }, + setColor (state, { user: { id }, highlighted }) { + const user = state.usersObject[id] + set(user, 'highlight', highlighted) + }, + signUpPending (state) { + state.signUpPending = true + state.signUpErrors = [] + }, + signUpSuccess (state) { + state.signUpPending = false + }, + signUpFailure (state, errors) { + state.signUpPending = false + state.signUpErrors = errors } } export const defaultState = { + loggingIn: false, lastLoginName: false, currentUser: false, - loggingIn: false, users: [], - usersObject: {} + usersObject: {}, + signUpPending: false, + signUpErrors: [] } const users = { @@ -58,8 +89,15 @@ const users = { mutations, actions: { fetchUser (store, id) { - store.rootState.api.backendInteractor.fetchUser({id}) - .then((user) => store.commit('addNewUsers', user)) + store.rootState.api.backendInteractor.fetchUser({ id }) + .then((user) => store.commit('addNewUsers', [user])) + }, + registerPushNotifications (store) { + const token = store.state.currentUser.credentials + const vapidPublicKey = store.rootState.instance.vapidPublicKey + const isEnabled = store.rootState.config.webPushNotifications + + registerPushNotifications(isEnabled, vapidPublicKey, token) }, addNewStatuses (store, { statuses }) { const users = map(statuses, 'user') @@ -76,26 +114,59 @@ const users = { store.commit('setUserForStatus', status) }) }, + async signUp (store, userInfo) { + store.commit('signUpPending') + + let rootState = store.rootState + + let response = await rootState.api.backendInteractor.register(userInfo) + if (response.ok) { + const data = { + oauth: rootState.oauth, + instance: rootState.instance.server + } + let app = await oauthApi.getOrCreateApp(data) + let result = await oauthApi.getTokenWithCredentials({ + app, + instance: data.instance, + username: userInfo.username, + password: userInfo.password + }) + store.commit('signUpSuccess') + store.commit('setToken', result.access_token) + store.dispatch('loginUser', result.access_token) + } else { + let data = await response.json() + let errors = humanizeErrors(JSON.parse(data.error)) + store.commit('signUpFailure', errors) + throw Error(errors) + } + }, logout (store) { store.commit('clearCurrentUser') + store.commit('setToken', false) store.dispatch('stopFetching', 'friends') store.commit('setBackendInteractor', backendInteractorService()) }, - loginUser (store, userCredentials) { + loginUser (store, accessToken) { return new Promise((resolve, reject) => { const commit = store.commit commit('beginLogin') - store.rootState.api.backendInteractor.verifyCredentials(userCredentials) + store.rootState.api.backendInteractor.verifyCredentials(accessToken) .then((response) => { if (response.ok) { response.json() .then((user) => { - user.credentials = userCredentials + // user.credentials = userCredentials + user.credentials = accessToken commit('setCurrentUser', user) commit('addNewUsers', [user]) + getNotificationPermission() + .then(permission => commit('setNotificationPermission', permission)) + // Set our new backend interactor - commit('setBackendInteractor', backendInteractorService(userCredentials)) + commit('setBackendInteractor', backendInteractorService(accessToken)) if (user.token) { store.dispatch('initializeSocket', user.token) @@ -103,6 +174,8 @@ const users = { // Start getting fresh tweets. store.dispatch('startFetching', 'friends') + // Start getting our own posts, only really needed for mitigating broken favorites + store.dispatch('startFetching', ['own', user.id]) // Get user mutes and follower info store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => { @@ -110,12 +183,8 @@ const users = { store.commit('addNewUsers', mutedUsers) }) - if ('Notification' in window && window.Notification.permission === 'default') { - window.Notification.requestPermission() - } - // Fetch our friends - store.rootState.api.backendInteractor.fetchFriends() + store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) .then((friends) => commit('addNewUsers', friends)) }) } else { diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index f14bfd6d..ae876b7f 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -8,12 +8,14 @@ const TAG_TIMELINE_URL = '/api/statusnet/tags/timeline' const FAVORITE_URL = '/api/favorites/create' const UNFAVORITE_URL = '/api/favorites/destroy' const RETWEET_URL = '/api/statuses/retweet' +const UNRETWEET_URL = '/api/statuses/unretweet' const STATUS_UPDATE_URL = '/api/statuses/update.json' const STATUS_DELETE_URL = '/api/statuses/destroy' const STATUS_URL = '/api/statuses/show' const MEDIA_UPLOAD_URL = '/api/statusnet/media/upload' const CONVERSATION_URL = '/api/statusnet/conversation' const MENTIONS_URL = '/api/statuses/mentions.json' +const DM_TIMELINE_URL = '/api/statuses/dm_timeline.json' const FOLLOWERS_URL = '/api/statuses/followers.json' const FRIENDS_URL = '/api/statuses/friends.json' const FOLLOWING_URL = '/api/friendships/create.json' @@ -26,10 +28,18 @@ const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json' const PROFILE_UPDATE_URL = '/api/account/update_profile.json' const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' const QVITTER_USER_TIMELINE_URL = '/api/qvitter/statuses/user_timeline.json' +const QVITTER_USER_NOTIFICATIONS_URL = '/api/qvitter/statuses/notifications.json' +const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json' const BLOCKING_URL = '/api/blocks/create.json' const UNBLOCKING_URL = '/api/blocks/destroy.json' const USER_URL = '/api/users/show.json' const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' +const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' +const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' +const FOLLOW_REQUESTS_URL = '/api/pleroma/friend_requests' +const APPROVE_USER_URL = '/api/pleroma/friendships/approve' +const DENY_USER_URL = '/api/pleroma/friendships/deny' +const SUGGESTIONS_URL = '/api/v1/suggestions' import { each, map } from 'lodash' import 'whatwg-fetch' @@ -44,16 +54,6 @@ let fetch = (url, options) => { return oldfetch(fullUrl, options) } -// from https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding -let utoa = (str) => { - // first we use encodeURIComponent to get percent-encoded UTF-8, - // then we convert the percent encodings into raw bytes which - // can be fed into btoa. - return btoa(encodeURIComponent(str) - .replace(/%([0-9A-F]{2})/g, - (match, p1) => { return String.fromCharCode('0x' + p1) })) -} - // Params // cropH // cropW @@ -128,8 +128,8 @@ const updateProfile = ({credentials, params}) => { const form = new FormData() each(params, (value, key) => { - if (key === 'description' || /* Always include description, because it might be empty */ - value) { + /* Always include description, no_rich_text and locked, because it might be empty or false */ + if (key === 'description' || key === 'locked' || key === 'no_rich_text' || value) { form.append(key, value) } }) @@ -151,6 +151,7 @@ const updateProfile = ({credentials, params}) => { // bio // homepage // location +// token const register = (params) => { const form = new FormData() @@ -166,9 +167,9 @@ const register = (params) => { }) } -const authHeaders = (user) => { - if (user && user.username && user.password) { - return { 'Authorization': `Basic ${utoa(`${user.username}:${user.password}`)}` } +const authHeaders = (accessToken) => { + if (accessToken) { + return { 'Authorization': `Bearer ${accessToken}` } } else { return { } } @@ -214,6 +215,22 @@ const unblockUser = ({id, credentials}) => { }).then((data) => data.json()) } +const approveUser = ({id, credentials}) => { + let url = `${APPROVE_USER_URL}?user_id=${id}` + return fetch(url, { + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} + +const denyUser = ({id, credentials}) => { + let url = `${DENY_USER_URL}?user_id=${id}` + return fetch(url, { + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} + const fetchUser = ({id, credentials}) => { let url = `${USER_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) @@ -238,6 +255,12 @@ const fetchAllFollowing = ({username, credentials}) => { .then((data) => data.json()) } +const fetchFollowRequests = ({credentials}) => { + const url = FOLLOW_REQUESTS_URL + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) +} + const fetchConversation = ({id, credentials}) => { let url = `${CONVERSATION_URL}/${id}.json?count=100` return fetch(url, { headers: authHeaders(credentials) }) @@ -271,8 +294,13 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use public: PUBLIC_TIMELINE_URL, friends: FRIENDS_TIMELINE_URL, mentions: MENTIONS_URL, + dms: DM_TIMELINE_URL, + notifications: QVITTER_USER_NOTIFICATIONS_URL, 'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL, user: QVITTER_USER_TIMELINE_URL, + // separate timeline for own posts, so it won't break due to user timeline bugs + // really needed only for broken favorites + own: QVITTER_USER_TIMELINE_URL, tag: TAG_TIMELINE_URL } @@ -298,7 +326,14 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') url += `?${queryString}` - return fetch(url, { headers: authHeaders(credentials) }).then((data) => data.json()) + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => { + if (data.ok) { + return data + } + throw new Error('Error fetching timeline') + }) + .then((data) => data.json()) } const verifyCredentials = (user) => { @@ -329,12 +364,23 @@ const retweet = ({ id, credentials }) => { }) } -const postStatus = ({credentials, status, mediaIds, inReplyToStatusId}) => { +const unretweet = ({ id, credentials }) => { + return fetch(`${UNRETWEET_URL}/${id}.json`, { + headers: authHeaders(credentials), + method: 'POST' + }) +} + +const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType}) => { const idsText = mediaIds.join(',') const form = new FormData() form.append('status', status) form.append('source', 'Pleroma FE') + if (spoilerText) form.append('spoiler_text', spoilerText) + if (visibility) form.append('visibility', visibility) + if (sensitive) form.append('sensitive', sensitive) + if (contentType) form.append('content_type', contentType) form.append('media_ids', idsText) if (inReplyToStatusId) { form.append('in_reply_to_status_id', inReplyToStatusId) @@ -373,6 +419,34 @@ const followImport = ({params, credentials}) => { .then((response) => response.ok) } +const deleteAccount = ({credentials, password}) => { + const form = new FormData() + + form.append('password', password) + + return fetch(DELETE_ACCOUNT_URL, { + body: form, + method: 'POST', + headers: authHeaders(credentials) + }) + .then((response) => response.json()) +} + +const changePassword = ({credentials, password, newPassword, newPasswordConfirmation}) => { + const form = new FormData() + + form.append('password', password) + form.append('new_password', newPassword) + form.append('new_password_confirmation', newPasswordConfirmation) + + return fetch(CHANGE_PASSWORD_URL, { + body: form, + method: 'POST', + headers: authHeaders(credentials) + }) + .then((response) => response.json()) +} + const fetchMutes = ({credentials}) => { const url = '/api/qvitter/mutes.json' @@ -381,6 +455,24 @@ const fetchMutes = ({credentials}) => { }).then((data) => data.json()) } +const suggestions = ({credentials}) => { + return fetch(SUGGESTIONS_URL, { + headers: authHeaders(credentials) + }).then((data) => data.json()) +} + +const markNotificationsAsSeen = ({id, credentials}) => { + const body = new FormData() + + body.append('latest_id', id) + + return fetch(QVITTER_USER_NOTIFICATIONS_READ_URL, { + body, + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -396,6 +488,7 @@ const apiService = { favorite, unfavorite, retweet, + unretweet, postStatus, deleteStatus, uploadMedia, @@ -408,7 +501,14 @@ const apiService = { updateProfile, updateBanner, externalProfile, - followImport + followImport, + deleteAccount, + changePassword, + fetchFollowRequests, + approveUser, + denyUser, + suggestions, + markNotificationsAsSeen } export default apiService diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 52b8286b..c84373ac 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -42,15 +42,34 @@ const backendInteractorService = (credentials) => { return apiService.unblockUser({credentials, id}) } + const approveUser = (id) => { + return apiService.approveUser({credentials, id}) + } + + const denyUser = (id) => { + return apiService.denyUser({credentials, id}) + } + const startFetching = ({timeline, store, userId = false}) => { return timelineFetcherService.startFetching({timeline, store, credentials, userId}) } + const fetchOldPost = ({store, postId}) => { + return timelineFetcherService.fetchAndUpdate({ + store, + credentials, + timeline: 'own', + older: true, + until: postId + 1 + }) + } + const setUserMute = ({id, muted = true}) => { return apiService.setUserMute({id, muted, credentials}) } const fetchMutes = () => apiService.fetchMutes({credentials}) + const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials}) const register = (params) => apiService.register(params) const updateAvatar = ({params}) => apiService.updateAvatar({credentials, params}) @@ -61,6 +80,9 @@ const backendInteractorService = (credentials) => { const externalProfile = (profileUrl) => apiService.externalProfile({profileUrl, credentials}) const followImport = ({params}) => apiService.followImport({params, credentials}) + const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password}) + const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation}) + const backendInteractorServiceInstance = { fetchStatus, fetchConversation, @@ -74,6 +96,7 @@ const backendInteractorService = (credentials) => { fetchAllFollowing, verifyCredentials: apiService.verifyCredentials, startFetching, + fetchOldPost, setUserMute, fetchMutes, register, @@ -82,7 +105,12 @@ const backendInteractorService = (credentials) => { updateBanner, updateProfile, externalProfile, - followImport + followImport, + deleteAccount, + changePassword, + fetchFollowRequests, + approveUser, + denyUser } return backendInteractorServiceInstance diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js index 13dd8979..7576c518 100644 --- a/src/services/color_convert/color_convert.js +++ b/src/services/color_convert/color_convert.js @@ -1,6 +1,15 @@ import { map } from 'lodash' const rgb2hex = (r, g, b) => { + if (r === null || typeof r === 'undefined') { + return undefined + } + if (r[0] === '#') { + return r + } + if (typeof r === 'object') { + ({ r, g, b } = r) + } [r, g, b] = map([r, g, b], (val) => { val = Math.ceil(val) val = val < 0 ? 0 : val @@ -10,6 +19,91 @@ const rgb2hex = (r, g, b) => { return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}` } +/** + * Converts 8-bit RGB component into linear component + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml + * https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation + * + * @param {Number} bit - color component [0..255] + * @returns {Number} linear component [0..1] + */ +const c2linear = (bit) => { + // W3C gives 0.03928 while wikipedia states 0.04045 + // what those magical numbers mean - I don't know. + // something about gamma-correction, i suppose. + // Sticking with W3C example. + const c = bit / 255 + if (c < 0.03928) { + return c / 12.92 + } else { + return Math.pow((c + 0.055) / 1.055, 2.4) + } +} + +/** + * Converts sRGB into linear RGB + * @param {Object} srgb - sRGB color + * @returns {Object} linear rgb color + */ +const srgbToLinear = (srgb) => { + return 'rgb'.split('').reduce((acc, c) => { acc[c] = c2linear(srgb[c]); return acc }, {}) +} + +/** + * Calculates relative luminance for given color + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml + * + * @param {Object} srgb - sRGB color + * @returns {Number} relative luminance + */ +const relativeLuminance = (srgb) => { + const {r, g, b} = srgbToLinear(srgb) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/** + * Generates color ratio between two colors. Order is unimporant + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef + * + * @param {Object} a - sRGB color + * @param {Object} b - sRGB color + * @returns {Number} color ratio + */ +const getContrastRatio = (a, b) => { + const la = relativeLuminance(a) + const lb = relativeLuminance(b) + const [l1, l2] = la > lb ? [la, lb] : [lb, la] + + return (l1 + 0.05) / (l2 + 0.05) +} + +/** + * This performs alpha blending between solid background and semi-transparent foreground + * + * @param {Object} fg - top layer color + * @param {Number} fga - top layer's alpha + * @param {Object} bg - bottom layer color + * @returns {Object} sRGB of resulting color + */ +const alphaBlend = (fg, fga, bg) => { + if (fga === 1 || typeof fga === 'undefined') return fg + return 'rgb'.split('').reduce((acc, c) => { + // Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending + // for opaque bg and transparent fg + acc[c] = (fg[c] * fga + bg[c] * (1 - fga)) + return acc + }, {}) +} + +const invert = (rgb) => { + return 'rgb'.split('').reduce((acc, c) => { + acc[c] = 255 - rgb[c] + return acc + }, {}) +} + const hex2rgb = (hex) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) return result ? { @@ -19,16 +113,18 @@ const hex2rgb = (hex) => { } : null } -const rgbstr2hex = (rgb) => { - if (rgb[0] === '#') { - return rgb - } - rgb = rgb.match(/\d+/g) - return `#${((Number(rgb[0]) << 16) + (Number(rgb[1]) << 8) + Number(rgb[2])).toString(16)}` +const mixrgb = (a, b) => { + return Object.keys(a).reduce((acc, k) => { + acc[k] = (a[k] + b[k]) / 2 + return acc + }, {}) } export { rgb2hex, hex2rgb, - rgbstr2hex + mixrgb, + invert, + getContrastRatio, + alphaBlend } diff --git a/src/services/file_type/file_type.service.js b/src/services/file_type/file_type.service.js index f9d3b466..f543ec79 100644 --- a/src/services/file_type/file_type.service.js +++ b/src/services/file_type/file_type.service.js @@ -9,11 +9,11 @@ const fileType = (typeString) => { type = 'image' } - if (typeString.match(/video\/(webm|mp4)/)) { + if (typeString.match(/video/)) { type = 'video' } - if (typeString.match(/audio|ogg/)) { + if (typeString.match(/audio/)) { type = 'audio' } diff --git a/src/services/new_api/oauth.js b/src/services/new_api/oauth.js new file mode 100644 index 00000000..9e656507 --- /dev/null +++ b/src/services/new_api/oauth.js @@ -0,0 +1,82 @@ +import {reduce} from 'lodash' + +const getOrCreateApp = ({oauth, instance}) => { + const url = `${instance}/api/v1/apps` + const form = new window.FormData() + + form.append('client_name', `PleromaFE_${Math.random()}`) + form.append('redirect_uris', `${window.location.origin}/oauth-callback`) + form.append('scopes', 'read write follow') + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} +const login = (args) => { + getOrCreateApp(args).then((app) => { + args.commit('setClientData', app) + + const data = { + response_type: 'code', + client_id: app.client_id, + redirect_uri: app.redirect_uri, + scope: 'read write follow' + } + + const dataString = reduce(data, (acc, v, k) => { + const encoded = `${k}=${encodeURIComponent(v)}` + if (!acc) { + return encoded + } else { + return `${acc}&${encoded}` + } + }, false) + + // Do the redirect... + const url = `${args.instance}/oauth/authorize?${dataString}` + + window.location.href = url + }) +} + +const getTokenWithCredentials = ({app, instance, username, password}) => { + const url = `${instance}/oauth/token` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('grant_type', 'password') + form.append('username', username) + form.append('password', password) + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} + +const getToken = ({app, instance, code}) => { + const url = `${instance}/oauth/token` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('grant_type', 'authorization_code') + form.append('code', code) + form.append('redirect_uri', `${window.location.origin}/oauth-callback`) + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} + +const oauth = { + login, + getToken, + getTokenWithCredentials, + getOrCreateApp +} + +export default oauth diff --git a/src/services/new_api/user_search.js b/src/services/new_api/user_search.js new file mode 100644 index 00000000..ce7da88e --- /dev/null +++ b/src/services/new_api/user_search.js @@ -0,0 +1,16 @@ +import utils from './utils.js' + +const search = ({query, store}) => { + return utils.request({ + store, + url: '/api/pleroma/search_user', + params: { + query + } + }).then((data) => data.json()) +} +const UserSearch = { + search +} + +export default UserSearch diff --git a/src/services/new_api/utils.js b/src/services/new_api/utils.js new file mode 100644 index 00000000..078f392f --- /dev/null +++ b/src/services/new_api/utils.js @@ -0,0 +1,36 @@ +const queryParams = (params) => { + return Object.keys(params) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .join('&') +} + +const headers = (store) => { + const accessToken = store.state.oauth.token + if (accessToken) { + return {'Authorization': `Bearer ${accessToken}`} + } else { + return {} + } +} + +const request = ({method = 'GET', url, params, store}) => { + const instance = store.state.instance.server + let fullUrl = `${instance}${url}` + + if (method === 'GET' && params) { + fullUrl = fullUrl + `?${queryParams(params)}` + } + + return window.fetch(fullUrl, { + method, + headers: headers(store), + credentials: 'same-origin' + }) +} + +const utils = { + queryParams, + request +} + +export default utils diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js new file mode 100644 index 00000000..1480cded --- /dev/null +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -0,0 +1,46 @@ +import apiService from '../api/api.service.js' + +const update = ({store, notifications, older}) => { + store.dispatch('setNotificationsError', { value: false }) + + store.dispatch('addNewNotifications', { notifications, older }) +} + +const fetchAndUpdate = ({store, credentials, older = false}) => { + const args = { credentials } + const rootState = store.rootState || store.state + const timelineData = rootState.statuses.notifications + + if (older) { + if (timelineData.minId !== Number.POSITIVE_INFINITY) { + args['until'] = timelineData.minId + } + } else { + args['since'] = timelineData.maxId + } + + args['timeline'] = 'notifications' + + return apiService.fetchTimeline(args) + .then((notifications) => { + update({store, notifications, older}) + }, () => store.dispatch('setNotificationsError', { value: true })) + .catch(() => store.dispatch('setNotificationsError', { value: true })) +} + +const startFetching = ({credentials, store}) => { + fetchAndUpdate({ credentials, store }) + const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) + // Initially there's set flag to silence all desktop notifications so + // that there won't spam of them when user just opened up the FE we + // reset that flag after a while to show new notifications once again. + setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) + return setInterval(boundFetchAndUpdate, 10000) +} + +const notificationsFetcher = { + fetchAndUpdate, + startFetching +} + +export default notificationsFetcher diff --git a/src/services/push/push.js b/src/services/push/push.js new file mode 100644 index 00000000..1ac304d1 --- /dev/null +++ b/src/services/push/push.js @@ -0,0 +1,69 @@ +import runtime from 'serviceworker-webpack-plugin/lib/runtime' + +function urlBase64ToUint8Array (base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/') + + const rawData = window.atob(base64) + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))) +} + +function isPushSupported () { + return 'serviceWorker' in navigator && 'PushManager' in window +} + +function registerServiceWorker () { + return runtime.register() + .catch((err) => console.error('Unable to register service worker.', err)) +} + +function subscribe (registration, isEnabled, vapidPublicKey) { + if (!isEnabled) return Promise.reject(new Error('Web Push is disabled in config')) + if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found')) + + const subscribeOptions = { + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) + } + return registration.pushManager.subscribe(subscribeOptions) +} + +function sendSubscriptionToBackEnd (subscription, token) { + return window.fetch('/api/v1/push/subscription/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + subscription, + data: { + alerts: { + follow: true, + favourite: true, + mention: true, + reblog: true + } + } + }) + }) + .then((response) => { + if (!response.ok) throw new Error('Bad status code from server.') + return response.json() + }) + .then((responseData) => { + if (!responseData.id) throw new Error('Bad response from server.') + return responseData + }) +} + +export default function registerPushNotifications (isEnabled, vapidPublicKey, token) { + if (isPushSupported()) { + registerServiceWorker() + .then((registration) => subscribe(registration, isEnabled, vapidPublicKey)) + .then((subscription) => sendSubscriptionToBackEnd(subscription, token)) + .catch((e) => console.warn(`Failed to setup Web Push Notifications: ${e.message}`)) + } +} diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index 001ff8a5..7f8b0fc0 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -1,10 +1,10 @@ import { map } from 'lodash' import apiService from '../api/api.service.js' -const postStatus = ({ store, status, media = [], inReplyToStatusId = undefined }) => { +const postStatus = ({ store, status, spoilerText, visibility, sensitive, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => { const mediaIds = map(media, 'id') - return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, mediaIds, inReplyToStatusId}) + return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType}) .then((data) => data.json()) .then((data) => { if (!data.error) { diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index 9dc4a3e1..10e7ed9b 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -1,5 +1,6 @@ import { times } from 'lodash' -import { rgb2hex, hex2rgb } from '../color_convert/color_convert.js' +import { brightness, invertLightness, convert, contrastRatio } from 'chromatism' +import { rgb2hex, hex2rgb, mixrgb, getContrastRatio, alphaBlend } from '../color_convert/color_convert.js' // While this is not used anymore right now, I left it in if we want to do custom // styles that aren't just colors, so user can pick from a few different distinct @@ -39,8 +40,6 @@ const setStyle = (href, commit) => { colors[name] = color }) - commit('setOption', { name: 'colors', value: colors }) - body.removeChild(baseEl) const styleEl = document.createElement('style') @@ -53,7 +52,27 @@ const setStyle = (href, commit) => { cssEl.addEventListener('load', setDynamic) } -const setColors = (col, commit) => { +const rgb2rgba = function (rgba) { + return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})` +} + +const getTextColor = function (bg, text, preserve) { + const bgIsLight = convert(bg).hsl.l > 50 + const textIsLight = convert(text).hsl.l > 50 + + if ((bgIsLight && textIsLight) || (!bgIsLight && !textIsLight)) { + const base = typeof text.a !== 'undefined' ? { a: text.a } : {} + const result = Object.assign(base, invertLightness(text).rgb) + if (!preserve && getContrastRatio(bg, result) < 4.5) { + return contrastRatio(bg, text).rgb + } + return result + } + return text +} + +const applyTheme = (input, commit) => { + const { rules, theme } = generatePreset(input) const head = document.head const body = document.body body.style.display = 'none' @@ -62,57 +81,411 @@ const setColors = (col, commit) => { head.appendChild(styleEl) const styleSheet = styleEl.sheet - const isDark = (col.text.r + col.text.g + col.text.b) > (col.bg.r + col.bg.g + col.bg.b) - let colors = {} - let radii = {} + styleSheet.toString() + styleSheet.insertRule(`body { ${rules.radii} }`, 'index-max') + styleSheet.insertRule(`body { ${rules.colors} }`, 'index-max') + styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max') + styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max') + body.style.display = 'initial' - const mod = isDark ? -10 : 10 + // commit('setOption', { name: 'colors', value: htmlColors }) + // commit('setOption', { name: 'radii', value: radii }) + commit('setOption', { name: 'customTheme', value: input }) + commit('setOption', { name: 'colors', value: theme.colors }) +} - colors.bg = rgb2hex(col.bg.r, col.bg.g, col.bg.b) // background - colors.lightBg = rgb2hex((col.bg.r + col.fg.r) / 2, (col.bg.g + col.fg.g) / 2, (col.bg.b + col.fg.b) / 2) // hilighted bg - colors.btn = rgb2hex(col.fg.r, col.fg.g, col.fg.b) // panels & buttons - colors.border = rgb2hex(col.fg.r - mod, col.fg.g - mod, col.fg.b - mod) // borders - colors.faint = rgb2hex( - col.text.r * 0.45 + col.fg.r * 0.55, - col.text.g * 0.45 + col.fg.g * 0.55, - col.text.b * 0.45 + col.fg.b * 0.55) // faint text - colors.fg = rgb2hex(col.text.r, col.text.g, col.text.b) // text - colors.lightFg = rgb2hex(col.text.r - mod, col.text.g - mod, col.text.b - mod) // strong text +const getCssShadow = (input, usesDropShadow) => { + if (input.length === 0) { + return 'none' + } - colors['base07'] = rgb2hex(col.text.r - mod * 2, col.text.g - mod * 2, col.text.b - mod * 2) + return input + .filter(_ => usesDropShadow ? _.inset : _) + .map((shad) => [ + shad.x, + shad.y, + shad.blur, + shad.spread + ].map(_ => _ + 'px').concat([ + getCssColor(shad.color, shad.alpha), + shad.inset ? 'inset' : '' + ]).join(' ')).join(', ') +} - colors.link = rgb2hex(col.link.r, col.link.g, col.link.b) // links - colors.icon = rgb2hex((col.bg.r + col.text.r) / 2, (col.bg.g + col.text.g) / 2, (col.bg.b + col.text.b) / 2) // icons +const getCssShadowFilter = (input) => { + if (input.length === 0) { + return 'none' + } - colors.cBlue = col.cBlue && rgb2hex(col.cBlue.r, col.cBlue.g, col.cBlue.b) - colors.cRed = col.cRed && rgb2hex(col.cRed.r, col.cRed.g, col.cRed.b) - colors.cGreen = col.cGreen && rgb2hex(col.cGreen.r, col.cGreen.g, col.cGreen.b) - colors.cOrange = col.cOrange && rgb2hex(col.cOrange.r, col.cOrange.g, col.cOrange.b) + return input + // drop-shadow doesn't support inset or spread + .filter((shad) => !shad.inset && Number(shad.spread) === 0) + .map((shad) => [ + shad.x, + shad.y, + // drop-shadow's blur is twice as strong compared to box-shadow + shad.blur / 2 + ].map(_ => _ + 'px').concat([ + getCssColor(shad.color, shad.alpha) + ]).join(' ')) + .map(_ => `drop-shadow(${_})`) + .join(' ') +} - colors.cAlertRed = col.cRed && `rgba(${col.cRed.r}, ${col.cRed.g}, ${col.cRed.b}, .5)` +const getCssColor = (input, a) => { + let rgb = {} + if (typeof input === 'object') { + rgb = input + } else if (typeof input === 'string') { + if (input.startsWith('#')) { + rgb = hex2rgb(input) + } else if (input.startsWith('--')) { + return `var(${input})` + } else { + return input + } + } + return rgb2rgba({ ...rgb, a }) +} - radii.btnRadius = col.btnRadius - radii.panelRadius = col.panelRadius - radii.avatarRadius = col.avatarRadius - radii.avatarAltRadius = col.avatarAltRadius - radii.tooltipRadius = col.tooltipRadius - radii.attachmentRadius = col.attachmentRadius +const generateColors = (input) => { + const colors = {} + const opacity = Object.assign({ + alert: 0.5, + input: 0.5, + faint: 0.5 + }, Object.entries(input.opacity || {}).reduce((acc, [k, v]) => { + if (typeof v !== 'undefined') { + acc[k] = v + } + return acc + }, {})) + const col = Object.entries(input.colors || input).reduce((acc, [k, v]) => { + if (typeof v === 'object') { + acc[k] = v + } else { + acc[k] = hex2rgb(v) + } + return acc + }, {}) - styleSheet.toString() - styleSheet.insertRule(`body { ${Object.entries(colors).filter(([k, v]) => v).map(([k, v]) => `--${k}: ${v}`).join(';')} }`, 'index-max') - styleSheet.insertRule(`body { ${Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}: ${v}px`).join(';')} }`, 'index-max') - body.style.display = 'initial' + const isLightOnDark = convert(col.bg).hsl.l < convert(col.text).hsl.l + const mod = isLightOnDark ? 1 : -1 + + colors.text = col.text + colors.lightText = brightness(20 * mod, colors.text).rgb + colors.link = col.link + colors.faint = col.faint || Object.assign({}, col.text) + + colors.bg = col.bg + colors.lightBg = col.lightBg || brightness(5, colors.bg).rgb + + colors.fg = col.fg + colors.fgText = col.fgText || getTextColor(colors.fg, colors.text) + colors.fgLink = col.fgLink || getTextColor(colors.fg, colors.link, true) + + colors.border = col.border || brightness(2 * mod, colors.fg).rgb + + colors.btn = col.btn || Object.assign({}, col.fg) + colors.btnText = col.btnText || getTextColor(colors.btn, colors.fgText) + + colors.input = col.input || Object.assign({}, col.fg) + colors.inputText = col.inputText || getTextColor(colors.input, colors.lightText) + + colors.panel = col.panel || Object.assign({}, col.fg) + colors.panelText = col.panelText || getTextColor(colors.panel, colors.fgText) + colors.panelLink = col.panelLink || getTextColor(colors.panel, colors.fgLink) + colors.panelFaint = col.panelFaint || getTextColor(colors.panel, colors.faint) - commit('setOption', { name: 'colors', value: colors }) - commit('setOption', { name: 'radii', value: radii }) - commit('setOption', { name: 'customTheme', value: col }) + colors.topBar = col.topBar || Object.assign({}, col.fg) + colors.topBarText = col.topBarText || getTextColor(colors.topBar, colors.fgText) + colors.topBarLink = col.topBarLink || getTextColor(colors.topBar, colors.fgLink) + + colors.faintLink = col.faintLink || Object.assign({}, col.link) + + colors.icon = mixrgb(colors.bg, colors.text) + + colors.cBlue = col.cBlue || hex2rgb('#0000FF') + colors.cRed = col.cRed || hex2rgb('#FF0000') + colors.cGreen = col.cGreen || hex2rgb('#00FF00') + colors.cOrange = col.cOrange || hex2rgb('#E3FF00') + + colors.alertError = col.alertError || Object.assign({}, colors.cRed) + colors.alertErrorText = getTextColor(alphaBlend(colors.alertError, opacity.alert, colors.bg), colors.text) + colors.alertErrorPanelText = getTextColor(alphaBlend(colors.alertError, opacity.alert, colors.panel), colors.panelText) + + colors.badgeNotification = col.badgeNotification || Object.assign({}, colors.cRed) + colors.badgeNotificationText = contrastRatio(colors.badgeNotification).rgb + + Object.entries(opacity).forEach(([ k, v ]) => { + if (typeof v === 'undefined') return + if (k === 'alert') { + colors.alertError.a = v + return + } + if (k === 'faint') { + colors[k + 'Link'].a = v + colors['panelFaint'].a = v + } + if (k === 'bg') { + colors['lightBg'].a = v + } + if (colors[k]) { + colors[k].a = v + } else { + console.error('Wrong key ' + k) + } + }) + + const htmlColors = Object.entries(colors) + .reduce((acc, [k, v]) => { + if (!v) return acc + acc.solid[k] = rgb2hex(v) + acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgb2rgba(v) + return acc + }, { complete: {}, solid: {} }) + return { + rules: { + colors: Object.entries(htmlColors.complete) + .filter(([k, v]) => v) + .map(([k, v]) => `--${k}: ${v}`) + .join(';') + }, + theme: { + colors: htmlColors.solid, + opacity + } + } } -const setPreset = (val, commit) => { - window.fetch('/static/styles.json') +const generateRadii = (input) => { + let inputRadii = input.radii || {} + // v1 -> v2 + if (typeof input.btnRadius !== 'undefined') { + inputRadii = Object + .entries(input) + .filter(([k, v]) => k.endsWith('Radius')) + .reduce((acc, e) => { acc[e[0].split('Radius')[0]] = e[1]; return acc }, {}) + } + const radii = Object.entries(inputRadii).filter(([k, v]) => v).reduce((acc, [k, v]) => { + acc[k] = v + return acc + }, { + btn: 4, + input: 4, + checkbox: 2, + panel: 10, + avatar: 5, + avatarAlt: 50, + tooltip: 2, + attachment: 5 + }) + + return { + rules: { + radii: Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}Radius: ${v}px`).join(';') + }, + theme: { + radii + } + } +} + +const generateFonts = (input) => { + const fonts = Object.entries(input.fonts || {}).filter(([k, v]) => v).reduce((acc, [k, v]) => { + acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => { + acc[k] = v + return acc + }, acc[k]) + return acc + }, { + interface: { + family: 'sans-serif' + }, + input: { + family: 'inherit' + }, + post: { + family: 'inherit' + }, + postCode: { + family: 'monospace' + } + }) + + return { + rules: { + fonts: Object + .entries(fonts) + .filter(([k, v]) => v) + .map(([k, v]) => `--${k}Font: ${v.family}`).join(';') + }, + theme: { + fonts + } + } +} + +const generateShadows = (input) => { + const border = (top, shadow) => ({ + x: 0, + y: top ? 1 : -1, + blur: 0, + spread: 0, + color: shadow ? '#000000' : '#FFFFFF', + alpha: 0.2, + inset: true + }) + const buttonInsetFakeBorders = [border(true, false), border(false, true)] + const inputInsetFakeBorders = [border(true, true), border(false, false)] + const hoverGlow = { + x: 0, + y: 0, + blur: 4, + spread: 0, + color: '--faint', + alpha: 1 + } + + const shadows = { + panel: [{ + x: 1, + y: 1, + blur: 4, + spread: 0, + color: '#000000', + alpha: 0.6 + }], + topBar: [{ + x: 0, + y: 0, + blur: 4, + spread: 0, + color: '#000000', + alpha: 0.6 + }], + popup: [{ + x: 2, + y: 2, + blur: 3, + spread: 0, + color: '#000000', + alpha: 0.5 + }], + avatar: [{ + x: 0, + y: 1, + blur: 8, + spread: 0, + color: '#000000', + alpha: 0.7 + }], + avatarStatus: [], + panelHeader: [], + button: [{ + x: 0, + y: 0, + blur: 2, + spread: 0, + color: '#000000', + alpha: 1 + }, ...buttonInsetFakeBorders], + buttonHover: [hoverGlow, ...buttonInsetFakeBorders], + buttonPressed: [hoverGlow, ...inputInsetFakeBorders], + input: [...inputInsetFakeBorders, { + x: 0, + y: 0, + blur: 2, + inset: true, + spread: 0, + color: '#000000', + alpha: 1 + }], + ...(input.shadows || {}) + } + + return { + rules: { + shadows: Object + .entries(shadows) + // TODO for v2.1: if shadow doesn't have non-inset shadows with spread > 0 - optionally + // convert all non-inset shadows into filter: drop-shadow() to boost performance + .map(([k, v]) => [ + `--${k}Shadow: ${getCssShadow(v)}`, + `--${k}ShadowFilter: ${getCssShadowFilter(v)}`, + `--${k}ShadowInset: ${getCssShadow(v, true)}` + ].join(';')) + .join(';') + }, + theme: { + shadows + } + } +} + +const composePreset = (colors, radii, shadows, fonts) => { + return { + rules: { + ...shadows.rules, + ...colors.rules, + ...radii.rules, + ...fonts.rules + }, + theme: { + ...shadows.theme, + ...colors.theme, + ...radii.theme, + ...fonts.theme + } + } +} + +const generatePreset = (input) => { + const shadows = generateShadows(input) + const colors = generateColors(input) + const radii = generateRadii(input) + const fonts = generateFonts(input) + + return composePreset(colors, radii, shadows, fonts) +} + +const getThemes = () => { + return window.fetch('/static/styles.json') .then((data) => data.json()) .then((themes) => { - const theme = themes[val] ? themes[val] : themes['pleroma-dark'] + return Promise.all(Object.entries(themes).map(([k, v]) => { + if (typeof v === 'object') { + return Promise.resolve([k, v]) + } else if (typeof v === 'string') { + return window.fetch(v) + .then((data) => data.json()) + .then((theme) => { + return [k, theme] + }) + .catch((e) => { + console.error(e) + return [] + }) + } + })) + }) + .then((promises) => { + return promises + .filter(([k, v]) => v) + .reduce((acc, [k, v]) => { + acc[k] = v + return acc + }, {}) + }) +} + +const setPreset = (val, commit) => { + getThemes().then((themes) => { + const theme = themes[val] ? themes[val] : themes['pleroma-dark'] + const isV1 = Array.isArray(theme) + const data = isV1 ? {} : theme.theme + + if (isV1) { const bgRgb = hex2rgb(theme[1]) const fgRgb = hex2rgb(theme[2]) const textRgb = hex2rgb(theme[3]) @@ -123,7 +496,7 @@ const setPreset = (val, commit) => { const cBlueRgb = hex2rgb(theme[7] || '#0000FF') const cOrangeRgb = hex2rgb(theme[8] || '#E3FF00') - const col = { + data.colors = { bg: bgRgb, fg: fgRgb, text: textRgb, @@ -133,23 +506,32 @@ const setPreset = (val, commit) => { cGreen: cGreenRgb, cOrange: cOrangeRgb } + } - // This is a hack, this function is only called during initial load. - // We want to cancel loading the theme from config.json if we're already - // loading a theme from the persisted state. - // Needed some way of dealing with the async way of things. - // load config -> set preset -> wait for styles.json to load -> - // load persisted state -> set colors -> styles.json loaded -> set colors - if (!window.themeLoaded) { - setColors(col, commit) - } - }) + // This is a hack, this function is only called during initial load. + // We want to cancel loading the theme from config.json if we're already + // loading a theme from the persisted state. + // Needed some way of dealing with the async way of things. + // load config -> set preset -> wait for styles.json to load -> + // load persisted state -> set colors -> styles.json loaded -> set colors + if (!window.themeLoaded) { + applyTheme(data, commit) + } + }) } -const StyleSetter = { +export { setStyle, setPreset, - setColors + applyTheme, + getTextColor, + generateColors, + generateRadii, + generateShadows, + generateFonts, + generatePreset, + getThemes, + composePreset, + getCssShadow, + getCssShadowFilter } - -export default StyleSetter diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index bb5fdc2e..c2a7de56 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -2,25 +2,26 @@ import { camelCase } from 'lodash' import apiService from '../api/api.service.js' -const update = ({store, statuses, timeline, showImmediately}) => { +const update = ({store, statuses, timeline, showImmediately, userId}) => { const ccTimeline = camelCase(timeline) store.dispatch('setError', { value: false }) store.dispatch('addNewStatuses', { timeline: ccTimeline, + userId, statuses, showImmediately }) } -const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false, tag = false}) => { +const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false, tag = false, until}) => { const args = { timeline, credentials } const rootState = store.rootState || store.state const timelineData = rootState.statuses.timelines[camelCase(timeline)] if (older) { - args['until'] = timelineData.minVisibleId + args['until'] = until || timelineData.minVisibleId } else { args['since'] = timelineData.maxId } @@ -33,7 +34,7 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false if (!older && statuses.length >= 20 && !timelineData.loading) { store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) } - update({store, statuses, timeline, showImmediately}) + update({store, statuses, timeline, showImmediately, userId}) }, () => store.dispatch('setError', { value: true })) } @@ -41,6 +42,7 @@ const startFetching = ({timeline = 'friends', credentials, store, userId = false const rootState = store.rootState || store.state const timelineData = rootState.statuses.timelines[camelCase(timeline)] const showImmediately = timelineData.visibleStatuses.length === 0 + timelineData.userId = userId fetchAndUpdate({timeline, credentials, store, showImmediately, userId, tag}) const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store, userId, tag }) return setInterval(boundFetchAndUpdate, 10000) diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js new file mode 100644 index 00000000..f6ddfb9c --- /dev/null +++ b/src/services/user_highlighter/user_highlighter.js @@ -0,0 +1,48 @@ +import { hex2rgb } from '../color_convert/color_convert.js' +const highlightStyle = (prefs) => { + if (prefs === undefined) return + const {color, type} = prefs + if (typeof color !== 'string') return + const rgb = hex2rgb(color) + if (rgb == null) return + const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})` + const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)` + const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)` + if (type === 'striped') { + return { + backgroundImage: [ + 'repeating-linear-gradient(135deg,', + `${tintColor} ,`, + `${tintColor} 20px,`, + `${tintColor2} 20px,`, + `${tintColor2} 40px` + ].join(' '), + backgroundPosition: '0 0' + } + } else if (type === 'solid') { + return { + backgroundColor: tintColor2 + } + } else if (type === 'side') { + return { + backgroundImage: [ + 'linear-gradient(to right,', + `${solidColor} ,`, + `${solidColor} 2px,`, + `transparent 6px` + ].join(' '), + backgroundPosition: '0 0' + } + } +} + +const highlightClass = (user) => { + return 'USER____' + user.screen_name + .replace(/\./g, '_') + .replace(/@/g, '_AT_') +} + +export { + highlightClass, + highlightStyle +} diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 00000000..6cecb3f3 --- /dev/null +++ b/src/sw.js @@ -0,0 +1,38 @@ +/* eslint-env serviceworker */ + +import localForage from 'localforage' + +function isEnabled () { + return localForage.getItem('vuex-lz') + .then(data => data.config.webPushNotifications) +} + +function getWindowClients () { + return clients.matchAll({ includeUncontrolled: true }) + .then((clientList) => clientList.filter(({ type }) => type === 'window')) +} + +self.addEventListener('push', (event) => { + if (event.data) { + event.waitUntil(isEnabled().then((isEnabled) => { + return isEnabled && getWindowClients().then((list) => { + const data = event.data.json() + + if (list.length === 0) return self.registration.showNotification(data.title, data) + }) + })) + } +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + + event.waitUntil(getWindowClients().then((list) => { + for (var i = 0; i < list.length; i++) { + var client = list[i] + if (client.url === '/' && 'focus' in client) { return client.focus() } + } + + if (clients.openWindow) return clients.openWindow('/') + })) +}) diff --git a/static/config.json b/static/config.json index 9863ec02..c49930ef 100644 --- a/static/config.json +++ b/static/config.json @@ -2,7 +2,19 @@ "theme": "pleroma-dark", "background": "/static/aurora_borealis.jpg", "logo": "/static/logo.png", - "defaultPath": "/main/all", + "logoMask": true, + "logoMargin": ".1em", + "redirectRootNoLogin": "/main/all", + "redirectRootLogin": "/main/friends", "chatDisabled": false, - "showInstanceSpecificPanel": false + "showInstanceSpecificPanel": false, + "scopeOptionsEnabled": false, + "formattingOptionsEnabled": false, + "collapseMessageWithSubject": false, + "scopeCopy": false, + "subjectLineBehavior": "email", + "alwaysShowSubjectInput": true, + "hidePostStats": false, + "hideUserStats": false, + "loginMethod": "password" } diff --git a/static/font/LICENSE.txt b/static/font/LICENSE.txt index c26be384..95966f00 100644 --- a/static/font/LICENSE.txt +++ b/static/font/LICENSE.txt @@ -19,6 +19,15 @@ Font license info Homepage: http://www.entypo.com +## Iconic + + Copyright (C) 2012 by P.J. Onori + + Author: P.J. Onori + License: SIL (http://scripts.sil.org/OFL) + Homepage: http://somerandomdude.com/work/iconic/ + + ## Fontelico Copyright (C) 2012 by Fontello project diff --git a/static/font/config.json b/static/font/config.json index 37adff79..3abeffe9 100644 --- a/static/font/config.json +++ b/static/font/config.json @@ -1,10 +1,9 @@ { - "name": "", "css_prefix_text": "icon-", "css_use_suffix": false, "hinting": true, "units_per_em": 1000, - "ascent": 850, + "ascent": 857, "glyphs": [ { "uid": "9bd60140934a1eb9236fd7a8ab1ff6ba", @@ -51,7 +50,7 @@ { "uid": "09feb4465d9bd1364f4e301c9ddbaa92", "css": "retweet", - "code": 59396, + "code": 59398, "src": "fontawesome" }, { @@ -67,12 +66,6 @@ "src": "fontawesome" }, { - "uid": "9e1c33b6849ceb08db8acfaf02188b7d", - "css": "plus-squared", - "code": 59398, - "src": "entypo" - }, - { "uid": "e99461abfef3923546da8d745372c995", "css": "cog", "code": 59399, @@ -115,6 +108,12 @@ "src": "fontawesome" }, { + "uid": "e35de5ea31cd56970498e33efbcb8e36", + "css": "link-ext-alt", + "code": 61583, + "src": "fontawesome" + }, + { "uid": "381da2c2f7fd51f8de877c044d7f439d", "css": "picture", "code": 59403, @@ -155,6 +154,66 @@ "css": "bell", "code": 59408, "src": "fontawesome" + }, + { + "uid": "ccc2329632396dc096bb638d4b46fb98", + "css": "mail-alt", + "code": 61664, + "src": "fontawesome" + }, + { + "uid": "c1f1975c885aa9f3dad7810c53b82074", + "css": "lock", + "code": 59409, + "src": "fontawesome" + }, + { + "uid": "05376be04a27d5a46e855a233d6e8508", + "css": "lock-open-alt", + "code": 61758, + "src": "fontawesome" + }, + { + "uid": "197375a3cea8cb90b02d06e4ddf1433d", + "css": "globe", + "code": 59410, + "src": "fontawesome" + }, + { + "uid": "b3a9e2dab4d19ea3b2f628242c926bfe", + "css": "brush", + "code": 59411, + "src": "iconic" + }, + { + "uid": "ca90da02d2c6a3183f2458e4dc416285", + "css": "adjust", + "code": 59396, + "src": "fontawesome" + }, + { + "uid": "5e2ab018e3044337bcef5f7e94098ea1", + "css": "thumbs-up-alt", + "code": 61796, + "src": "fontawesome" + }, + { + "uid": "c76b7947c957c9b78b11741173c8349b", + "css": "attention", + "code": 59412, + "src": "fontawesome" + }, + { + "uid": "1a5cfa186647e8c929c2b17b9fc4dac1", + "css": "plus-squared", + "code": 61694, + "src": "fontawesome" + }, + { + "uid": "44e04715aecbca7f266a17d5a7863c68", + "css": "plus", + "code": 59413, + "src": "fontawesome" } ] }
\ No newline at end of file diff --git a/static/font/css/fontello-codes.css b/static/font/css/fontello-codes.css index e4e4e64d..5cfcbf6a 100644 --- a/static/font/css/fontello-codes.css +++ b/static/font/css/fontello-codes.css @@ -3,9 +3,9 @@ .icon-upload:before { content: '\e801'; } /* '' */ .icon-star:before { content: '\e802'; } /* '' */ .icon-star-empty:before { content: '\e803'; } /* '' */ -.icon-retweet:before { content: '\e804'; } /* '' */ +.icon-adjust:before { content: '\e804'; } /* '' */ .icon-eye-off:before { content: '\e805'; } /* '' */ -.icon-plus-squared:before { content: '\e806'; } /* '' */ +.icon-retweet:before { content: '\e806'; } /* '' */ .icon-cog:before { content: '\e807'; } /* '' */ .icon-logout:before { content: '\e808'; } /* '' */ .icon-down-open:before { content: '\e809'; } /* '' */ @@ -16,11 +16,21 @@ .icon-left-open:before { content: '\e80e'; } /* '' */ .icon-up-open:before { content: '\e80f'; } /* '' */ .icon-bell:before { content: '\e810'; } /* '' */ +.icon-lock:before { content: '\e811'; } /* '' */ +.icon-globe:before { content: '\e812'; } /* '' */ +.icon-brush:before { content: '\e813'; } /* '' */ +.icon-attention:before { content: '\e814'; } /* '' */ +.icon-plus:before { content: '\e815'; } /* '' */ .icon-spin3:before { content: '\e832'; } /* '' */ .icon-spin4:before { content: '\e834'; } /* '' */ .icon-link-ext:before { content: '\f08e'; } /* '' */ +.icon-link-ext-alt:before { content: '\f08f'; } /* '' */ .icon-menu:before { content: '\f0c9'; } /* '' */ +.icon-mail-alt:before { content: '\f0e0'; } /* '' */ .icon-comment-empty:before { content: '\f0e5'; } /* '' */ +.icon-plus-squared:before { content: '\f0fe'; } /* '' */ .icon-reply:before { content: '\f112'; } /* '' */ +.icon-lock-open-alt:before { content: '\f13e'; } /* '' */ +.icon-thumbs-up-alt:before { content: '\f164'; } /* '' */ .icon-binoculars:before { content: '\f1e5'; } /* '' */ .icon-user-plus:before { content: '\f234'; } /* '' */
\ No newline at end of file diff --git a/static/font/css/fontello-embedded.css b/static/font/css/fontello-embedded.css index 20e498fe..58a57456 100644 --- a/static/font/css/fontello-embedded.css +++ b/static/font/css/fontello-embedded.css @@ -1,15 +1,15 @@ @font-face { font-family: 'fontello'; - src: url('../font/fontello.eot?12951540'); - src: url('../font/fontello.eot?12951540#iefix') format('embedded-opentype'), - url('../font/fontello.svg?12951540#fontello') format('svg'); + src: url('../font/fontello.eot?4112743'); + src: url('../font/fontello.eot?4112743#iefix') format('embedded-opentype'), + url('../font/fontello.svg?4112743#fontello') format('svg'); font-weight: normal; font-style: normal; } @font-face { font-family: 'fontello'; - src: url('data:application/octet-stream;base64,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') format('woff'), - url('data:application/octet-stream;base64,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') format('truetype'); + src: url('data:application/octet-stream;base64,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') format('woff'), + url('data:application/octet-stream;base64,AAEAAAAPAIAAAwBwR1NVQiCLJXoAAAD8AAAAVE9TLzI+L1N8AAABUAAAAFZjbWFwKOFtSQAAAagAAAOsY3Z0IAb//vQAADPoAAAAIGZwZ22KkZBZAAA0CAAAC3BnYXNwAAAAEAAAM+AAAAAIZ2x5Zkpw6uUAAAVUAAAo0mhlYWQTu6FIAAAuKAAAADZoaGVhB8kD/wAALmAAAAAkaG10eIIP/+YAAC6EAAAAkGxvY2G11aikAAAvFAAAAEptYXhwAXkNpgAAL2AAAAAgbmFtZcydHiAAAC+AAAACzXBvc3So9e7HAAAyUAAAAZBwcmVw5UErvAAAP3gAAACGAAEAAAAKADAAPgACREZMVAAObGF0bgAaAAQAAAAAAAAAAQAAAAQAAAAAAAAAAQAAAAFsaWdhAAgAAAABAAAAAQAEAAQAAAABAAgAAQAGAAAAAQAAAAEDnQGQAAUAAAJ6ArwAAACMAnoCvAAAAeAAMQECAAACAAUDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFBmRWQAQOgA8jQDWf9xAFoDZwCeAAAAAQAAAAAAAAAAAAUAAAADAAAALAAAAAQAAAH4AAEAAAAAAPIAAwABAAAALAADAAoAAAH4AAQAxgAAABwAEAADAAzoFegy6DTwj/DJ8ODw5fD+8RLxPvFk8eXyNP//AADoAOgy6DTwjvDJ8ODw5fD+8RLxPvFk8eXyNP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAcAEYARgBGAEgASABIAEgASABIAEgASABIAAAAAQACAAMABAAFAAYABwAIAAkACgALAAwADQAOAA8AEAARABIAEwAUABUAFgAXABgAGQAaABsAHAAdAB4AHwAgACEAIgAjAAABBgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAG0AAAAAAAAACMAAOgAAADoAAAAAAEAAOgBAADoAQAAAAIAAOgCAADoAgAAAAMAAOgDAADoAwAAAAQAAOgEAADoBAAAAAUAAOgFAADoBQAAAAYAAOgGAADoBgAAAAcAAOgHAADoBwAAAAgAAOgIAADoCAAAAAkAAOgJAADoCQAAAAoAAOgKAADoCgAAAAsAAOgLAADoCwAAAAwAAOgMAADoDAAAAA0AAOgNAADoDQAAAA4AAOgOAADoDgAAAA8AAOgPAADoDwAAABAAAOgQAADoEAAAABEAAOgRAADoEQAAABIAAOgSAADoEgAAABMAAOgTAADoEwAAABQAAOgUAADoFAAAABUAAOgVAADoFQAAABYAAOgyAADoMgAAABcAAOg0AADoNAAAABgAAPCOAADwjgAAABkAAPCPAADwjwAAABoAAPDJAADwyQAAABsAAPDgAADw4AAAABwAAPDlAADw5QAAAB0AAPD+AADw/gAAAB4AAPESAADxEgAAAB8AAPE+AADxPgAAACAAAPFkAADxZAAAACEAAPHlAADx5QAAACIAAPI0AADyNAAAACMAAQAA//YC1AKNACQAHkAbIhkQBwQAAgFHAwECAAJvAQEAAGYUHBQUBAUYKyUUDwEGIi8BBwYiLwEmND8BJyY0PwE2Mh8BNzYyHwEWFA8BFxYC1A9MECwQpKQQLBBMEBCkpBAQTBAsEKSkECwQTA8PpKQPdxYQTA8PpaUPD0wQLBCkpBAsEEwQEKSkEBBMDy4PpKQPAAQAAP+4A6EDNQAIABEAKQBAAEZAQzUBBwYJAAICAAJHAAkGCW8IAQYHBm8ABwMHbwAEAAIEVAUBAwEBAAIDAGAABAQCWAACBAJMPTwjMyMiMiU5GBIKBR0rJTQmDgIeATY3NCYOAh4BNjcVFAYjISImJzU0NhczHgE7ATI2NzMyFgMGKwEVFAYHIyImJzUjIiY/ATYyHwEWAsoUHhQCGBoYjRQgEgIWHBhGIBb8yxceASAW7gw2I48iNg3uFiC2CRiPFA+PDxQBjxcTEfoKHgr6EiQOFgISIBIEGgwOFgISIBIEGomzFiAgFrMWIAEfKCgfHgFSFvoPFAEWDvosEfoKCvoRAAAAAAEAAP/RA6EDRwAfAB1AGhIPCgQDBQACAUcAAgACbwEBAABmHRQXAwUXKwEUDwETFRQOAS8BBwYiJjU0NxMnJjU0NyU3NjIfAQUWA6EPyjAMFQz7+gwWDAEwyw4fARh+CyAMfQEYIAHwDA/F/ukMCxABB4SEBxIKBAgBF8UPDBUFKP4XF/4oBQACAAD/0QOhA0cACQApACdAJBwZFA4NCQgHBgUDAQwAAgFHAAIAAm8BAQAAZiUkFxYSEAMFFCsBNy8BDwEXBzcXExQPARMVFCMiLwEHBiImNTQ3EycmNTQ3JTc2Mh8BBRYCe6rramnsqynT0/4PyjAXCgz7+gwWDAEwyw4fARh+CyAMfQEYIAEppiLV1SKm629vAbIMD8X+6QwcB4SEBxIKBAgBF8UPDBUFKP4XF/4oBQAAAAAC//3/uANfAxIABwAUACtAKAADAAABAwBgBAEBAgIBVAQBAQECWAACAQJMAAASEQwLAAcABxEFBRUrJREiDgIeAQEUDgEiLgI+ATIeAQGtU4xQAlSIAgFyxujIbgZ6vPS6fjUCYFKMpIxSATB1xHR0xOrEdHTEAAAFAAD/ygPoArgACQAaAD4ARABXAFdAVDQbAgAEUwYCAgBSQwIBAlBCKScIAQYGAQRHAAUEBW8AAgABAAIBbQABBgABBmsABgMABgNrAAMDbgAEAAAEVAAEBABYAAAEAExMSxMuGSQUHQcFGislNy4BNzQ3BgcWATQmByIGFRQWMjY1NDYzMjY3FBUGAg8BBiMiJyY1NDcuAScmNDc+ATMyFzc2MzIWHwEWBxYTFAYHExYXFAcGBw4BIzc+ATcmJzceARcWATYrMDgBIoBVXgFqEAtGZBAWEEQwCxDKO+o7HAUKB0QJGVCGMgsLVvyXMjIfBQoDDgskCwEJFVhJnQT6CxYnVNx8KXfIRUFdIzViIAtwTyNqPUM6QYSQAWcLEAFkRQsQEAswRBB1BAFp/lppMgknBgoHKiR4TREqEoOYCjYJBgYUBgEF/v1OgBsBGBleExMkLWBqSgqEaWRAPyRiNhMAAAIAAP//BDACgwAhAEMAQkA/IgEEBgFHAwEBBwYHAQZtCQEGBAcGBGsIAQIABwECB2AABAAABFQABAQAWAUBAAQATEJAFiElGCEWFSgTCgUdKyUUBichIiYvAS4BMxEjIi4BPwE2Mh8BFhQGByMVITIfARYlFA8BBiIvASY0NjsBNSEiLwEmNDY3ITIWHwEeARURMzIWAsoKCP3pBQYCAwECAWsPFAEIswsgDLIJFg5rAUEJBVkEAWUIsgwgC7MIFg5r/r4JBVkECggCGAQGAgMBAmsOFhIHDAECAwQBDAFPFhsK1gwM1gocFAHWBmwF4g0K1g0N1gobFtYHawUNCgECAwUCCAP+shYAAAACAAD/uANaAxIACABqAEVAQmVZTEEEAAQ7CgIBADQoGxAEAwEDRwAFBAVvBgEEAARvAAABAG8AAQMBbwADAgNvAAICZlxbU1FJSCsqIiATEgcFFisBNCYiDgEWMjYlFRQGDwEGBxYXFhQHDgEnIi8BBgcGBwYrASImNScmJwcGIicmJyY0Nz4BNyYvAS4BJzU0Nj8BNjcmJyY0Nz4BMzIfATY3Njc2OwEyFh8BFhc3NjIXFhcWFAcOAQcWHwEeAQI7UnhSAlZ0VgEcCAdoCgsTKAYFD1ANBwdNGRoJBwQQfAgMEBsXTwYQBkYWBAUIKAoPCGYHCAEKBWgIDhclBgUPUA0HCE0YGgkIAxF8BwwBDxwXTwUPB0gUBAQJKAoPCGYHCgFlO1RUdlRUeHwHDAEQHhUbMgYOBhVQAQU8DQhMHBAKB2cJDDwFBkAeBQ4GDDIPHBsPAQwHfAcMARAZGiAtBwwHFFAFPA0ITBwQCgdnCQs7BQVDHAUOBgwyDxwaEAEMAAAAAgAAAAADawLKACcAQABCQD8UAQIBAUcABgIFAgYFbQAFAwIFA2sABAMAAwQAbQABAAIGAQJgAAMEAANUAAMDAFgAAAMATBYjGSUqJScHBRsrJRQWDwEOAQcjIiY1ETQ2OwEyFhUXFg8BDgEnIyIGBxEUFhczMh4CARQHAQYiJj0BIyImPQE0NjczNTQ2FhcBFgFlAgECAQgIskNeXkOyCAoBAQECAQgIsiU0ATYktAYCBgICBgv+0QscFvoOFhYO+hYcCwEvCzUCEgUOCQIDXkMBiENeCggLCQYNBwgBNCb+eCU0AQQCCAEsDgv+0AoUD6EWDtYPFAGhDhYCCf7QCgAAAAABAAD/7gO2AjAAFAAZQBYNAQABAUcCAQEAAW8AAABmFBcSAwUXKwkBBiInASY0PwE2MhcJATYyHwEWFAOr/mIKHgr+YgsLXQoeCgEoASgLHAxcCwGW/mMLCwGdCx4KXAsL/tgBKAsLXAscAAAB//7/ewO4A2cAMQAfQBwAAQAAAVQAAQEAWAIBAAEATAEAKikAMQExAwUUKxciJy4BNwE2Fx4BFxYHAQ4BJyY2NwE2FgcBBhcWNzY3ATYmJyYHAQYeAjcBNhYHAQb0ZkRIBFYB8FBeLEYMGlD+JihgIB4GLAFMGDQa/rQsGAwMGBYB2jIgPDY2/hJCBGSGSgHwGDQa/hBShUhGwF4B8FAaDEYsYFD+JigKIBhkKgFOGjQY/rQsGggCBBYB2jJ2EA4y/hJMhmIEQAHuGC4a/hBSAAAAAAT///+4BC8DEgAIAA8AHwAvAFVAUh0UAgEDDwEAAQ4NDAkEAgAcFQIEAgRHAAIABAACBG0ABgcBAwEGA2AAAQAAAgEAYAAEBQUEVAAEBAVYAAUEBUwREC4rJiMZFxAfER8TExIIBRcrARQOASY0Nh4BARUhNTcXASUhIgYHERQWNyEyNicRNCYXERQGByEiJjcRNDY3ITIWAWU+Wj4+Wj4CPPzusloBHQEe/IMHCgEMBgN9BwwBClE0JfyDJDYBNCUDfSU0AhgtPgJCVkIEOv76+muzWQEdoQoI/VoHDAEKCAKmCAoS/VolNAE2JAKmJTQBNgAL////cQQvAxIADwAfAC8APwBPAF8AbwB/AI8AnwCvAMRAGZBAAgkIiIBgIAQFBHg4AgMCUDAAAwEABEdLsCFQWEA3ABUSDAIICRUIYBMBCRABBAUJBGARDQIFDgYCAgMFAmAPAQMKAQABAwBgCwcCAQEUWAAUFA0USRtAPgAVEgwCCAkVCGATAQkQAQQFCQRgEQ0CBQ4GAgIDBQJgDwEDCgEAAQMAYAsHAgEUFAFUCwcCAQEUWAAUARRMWUAmrqumo56blpSOjIaEfnx2c25rZmReW1ZUTks1NTUmNSY1NTMWBR0rFzU0JgcjIgYdARQWOwEyNic1NCYrASIGHQEUFjczMjYnNTQmJyMiBh0BFBYXMzI2ARE0JiMhIgYXERQWMyEyNgE1NCYHIyIGHQEUFjsBMjYBNTQmByMiBgcVFBY7ATI2AxE0JgchIgYXERQWFyEyNhc1NCYrASIGBxUUFjczMjY3NTQmJyMiBgcVFBYXMzI2NzU0JgcjIgYHFRQWOwEyNjcRFAYjISImNxE0NjchMhbWFA9IDhYWDkgOFgEUD0gOFhYOSA4WARQPSA4WFg5IDhYCOxYO/lMOFgEUDwGtDxT9xRQPSA4WFg5IDhYDERYORw8UARYORw8U1RYO/lMOFgEUDwGtDxTXFg5HDxQBFg5HDxQBFg5HDxQBFg5HDxQBFg5HDxQBFg5HDxRINCX8gyQ2ATQlA30lNCRIDhYBFA9IDhYW5EgOFhYOSA4WARTmRw8UARYORw8UARb+YQEeDhYWDv7iDhYWApFHDxYBFBBHDhYW/YtIDhYBFA9IDhYWAbsBHQ8WARQQ/uMPFAEWyUgOFhYOSA4WARTmRw8UARYORw8UARbkRw8WARQQRw4WFmf9EiU0NCUC7iU0ATYAAQAA/8cCdANLABQAF0AUCQEAAQFHAAEAAW8AAABmHBICBRYrCQEGIi8BJjQ3CQEmND8BNjIXARYUAmr+YgscC10LCwEo/tgLC10KHgoBngoBcP5hCgpdCxwLASkBKAscC10LC/5iCxwAAAAAAQAA/8cCmANLABQAF0AUAQEAAQFHAAEAAW8AAABmFxcCBRYrCQIWFA8BBiInASY0NwE2Mh8BFhQCjv7XASkKCl0LHAv+YgsLAZ4KHgpdCgKx/tj+1woeCl0KCgGfCh4KAZ4LC10KHgABAAAAAAO2Ak0AFAAZQBYFAQACAUcAAgACbwEBAABmFxQSAwUXKyUHBiInCQEGIi8BJjQ3ATYyFwEWFAOrXAseCv7Y/tgLHAtdCwsBngscCwGeC3JcCgoBKf7XCgpcCx4KAZ4KCv5iCxwAAAADAAD/cQPEA1oADAAaAEIA6UAMAAECAAFHKBsCAwFGS7AOUFhAKwcBBQEAAQVlAAACAQBjAAMAAQUDAWAABAQIWAAICAxIAAICBlgABgYNBkkbS7AhUFhALAcBBQEAAQVlAAACAQACawADAAEFAwFgAAQECFgACAgMSAACAgZYAAYGDQZJG0uwJFBYQCkHAQUBAAEFZQAAAgEAAmsAAwABBQMBYAACAAYCBlwABAQIWAAICAwESRtALwcBBQEAAQVlAAACAQACawAIAAQDCARgAAMAAQUDAWAAAgYGAlQAAgIGWAAGAgZMWVlZQAwfIhIoFhEjExIJBR0rBTQjIiY3NCIVFBY3MiUhJhE0LgIiDgIVEAUUBisBFAYiJjUjIiY1PgQ3NDY3JjU0PgEWFRQHHgEXFB4DAf0JITABEjooCf6MAtaVGjRSbFI0GgKmKh36VHZU+h0qHC4wJBIChGkFICwgBWqCARYiMDBZCDAhCQkpOgGpqAEpHDw4IiI4PBz+16gdKjtUVDsqHRgyVF6ITVSSEAoLFx4CIhULChCSVE6GYFI0AAAAAgAAAAACgwMSAAcAHwAqQCcFAwIAAQIBAAJtAAICbgAEAQEEVAAEBAFYAAEEAUwjEyU2ExAGBRorEyE1NCYOARcFERQGByEiJicRNDYXMzU0NjIWBxUzMhazAR1UdlQBAdAgFv3pFx4BIBYRlMyWAhIXHgGsbDtUAlA9of6+Fh4BIBUBQhYgAWxmlJRmbB4AA//9/7gDWQMSAAwBvQH3AndLsAlQWEE8AL0AuwC4AJ8AlgCIAAYAAwAAAI8AAQACAAMA2gDTAG0AWQBRAEIAPgAzACAAGQAKAAcAAgGeAZgBlgGMAYsBegF1AWUBYwEDAOEA4AAMAAYABwFTAU0BKAADAAgABgH0AdsB0QHLAcABvgE4ATMACAABAAgABgBHG0uwClBYQUMAuwC4AJ8AiAAEAAUAAAC9AAEAAwAFAI8AAQACAAMA2gDTAG0AWQBRAEIAPgAzACAAGQAKAAcAAgGeAZgBlgGMAYsBegF1AWUBYwEDAOEA4AAMAAYABwFTAU0BKAADAAgABgH0AdsB0QHLAcABvgE4ATMACAABAAgABwBHAJYAAQAFAAEARhtBPAC9ALsAuACfAJYAiAAGAAMAAACPAAEAAgADANoA0wBtAFkAUQBCAD4AMwAgABkACgAHAAIBngGYAZYBjAGLAXoBdQFlAWMBAwDhAOAADAAGAAcBUwFNASgAAwAIAAYB9AHbAdEBywHAAb4BOAEzAAgAAQAIAAYAR1lZS7AJUFhANQACAwcDAgdtAAcGAwcGawAGCAMGCGsACAEDCAFrAAEBbgkBAAMDAFQJAQAAA1gFBAIDAANMG0uwClBYQDoEAQMFAgUDZQACBwUCB2sABwYFBwZrAAYIBQYIawAIAQUIAWsAAQFuCQEABQUAVAkBAAAFVgAFAAVKG0A1AAIDBwMCB20ABwYDBwZrAAYIAwYIawAIAQMIAWsAAQFuCQEAAwMAVAkBAAADWAUEAgMAA0xZWUEZAAEAAAHYAdYBuQG3AVcBVgDHAMUAtQC0ALEArgB5AHYABwAGAAAADAABAAwACgAFABQrATIeARQOASIuAj4BAQ4BBzI+ATU+ATc2FyY2PwE2PwEGJjUUBzQmBjUuBC8BJjQvAQcGFCoBFCIGIgc2JyYjNiYnMy4CJy4BBwYUHwEWBh4BBwYPAQYWFxYUBiIPAQYmJyYnJgcmJyYHMiYHPgEjNj8BNicWPwE2NzYyFjMWNCcyJyYnJgcGFyIPAQYvASYnIgc2JiM2JyYiDwEGHgEyFxYHIgYiBhYHLgEnFicjIgYiJyY3NBcnBgcyNj8BNhc3FyYHBgcWBycuASciBwYHHgIUNxYHMhcWFxYHJyYGFjMiDwEGHwEGFjcGHwMeAhcGFgciBjUeAhQWNzYnLgI1MzIfAQYeAjMeAQcyHgQfAxYyPwE2FhcWNyIfAR4BFR4BFzY1BhYzNjUGLwEmNCY2FzI2LgInBiYnFAYVIzY0PwE2LwEmByIHDgMmJy4BND8BNic2PwE2OwEyNDYmIxY2FxY3JyY3FjceAh8BFjY3FhceAT4BJjUnNS4BNjc0Nj8BNicyNycmIjc2Jz4BMxY2Jz4BNxY2Jj4BFTc2IxY3Nic2JiczMjU2JyYDNjcmIi8BNiYvASYvASYPASIPARUmJyIuAQ4BDwEmNiYGDwEGNgYVDgEVLgE3HgEXFgcGBwYXFAYWAa10xnJyxujIbgZ6vAETAggDAQIEAxEVEwoBDAIIBgMBBwYEBAoFBgQBCAECAQMDBAQEBAYBBgIICQUEBgIEAwEIDAEFHAQDAgIBCAEOAQIHCQMEBAEEAgMBBwoCBAUNAwMUDhMECAYBAgECBQkCARMJBgQCBQYKAwgEBwUCAwYJBAYBBQkEBQMDAgUEAQ4HCw8EEAMDAQgECAEIAwEIBAMCAgMEAgQSBQMMDAEDAwIMGRsDBgUFEwUDCwQNCwEEAgYECAQJBFEyBAUCBgUDARgKAQIHBQQDBAQEAQIBAQECCgcHEgQHCQQDCAQCDgEBAgIOAgQCAg8IAwQDAgMFAQQKCgEECAQFDAcCAwgDCQcWBgYFCAgQBBQKAQIEAgYDDgMEAQoFCBEKAgICAgEFAgQBCgIDDAMCCAECCAMBAwIHCwQBAgIIFAMICgECAQQCAwUCAQMCAQMBBBgDCQMBAQEDDQIOBAIDAQQDBQIGCAQCAgEIBAQHCAUHDAQEAgICBgEFBAMCAwUMBAISAQQCAgUOCQICCggFCQIGBgcFCQwKaXNQAQwBDQEEAxUBAwUCAwICAQUMCAMGBgYGAQEECAQKAQcGAgoCBAEMAQECAgQLDwECCQoBAxJ0xOrEdHTE6sR0/t0BCAIGBgEECAMFCwEMAQMCAgwBCgcCAwQCBAECBgwFBgMDAgQBAQMDBAIEAQMDAgIIBAIGBAEDBAEEBAYHAwgHCgcEBQYFDAMBAgQCAQMMCQ4DBAUHCAUDEQIDDggFDAMBAwkJBgQDBgEOBAoEAQIFAgIGCgQHBwcBCQUIBwgDAgcDAgQCBgIEBQoDAw4CBQICBQQHAgEKCA8CAwMHAwIOAwIDBAYEBgQEAQEtTwQBCAQDBAYPCgIGBAUEBQ4JFAsCAQYaAgEXBQQGAwUUAwMQBQIBBAgFCAQBCxgNBQwCAgQEDAgOBA4BCgsUBwgBBQMNAgECARIDCgQECQUGAgMKAwIDBQwCEAgSAwMEBAYCBAoHDgEFAgQBBAICEAUPBQIFAwILAggEBAICBBgOCQ4FCQEEBgECAwIBBAMGBwYFAg8KAQQBAgMBAgMIBRcEAggIAwUOAgoKBQECAwQLCQUCAgICBgIKBgoEBAQDAQQKBAYBBwIBBwYFBAIDAQUEAv4NFVUCAgUEBgIPAQECAQIBAQMCCgMGAgIFBgcDDgYCAQUEAggBAggCAgICBRwIEQkOCQwCBBAHAAIAAP+lA48DJAAMABcAIkAfFAEBAhEFAgABAkcAAgECbwABAAFvAAAAZhsWIgMFFyslFAYnIic+ASc0NjIWARYUBwEuAScBNjIB0K57UUREUgFYelgBniAh/sIUUjgBPiBe0XywASgnilI9WFgB9SBeIP7CN1QUAT4gAAAD//X/uAPzA1kADwAhADMAZEAMGxECAwIJAQIBAAJHS7AkUFhAHQACBQMFAgNtAAMAAAEDAGAAAQAEAQRcAAUFDAVJG0AiAAUCBW8AAgMCbwADAAABAwBgAAEEBAFUAAEBBFgABAEETFlACRc4JycmIwYFGislNTQmKwEiBh0BFBYXMzI2JxM0JyYrASIHBhUXFBY3MzI2AwEWBw4BByEiJicmNwE+ATIWAjsKB2wHCgoHbAcKAQoFBwd6BggFCQwHZwgMCAGsFBUJIhL8phIiCRUUAa0JIiYiWmoICgoIaggKAQzXAQEGBAYGBAj/BQgBBgIQ/O4jIxESARQQIyMDEhEUFAAAAAABAAAAAAMSAxIAIwApQCYABAMEbwABAAFwBQEDAAADVAUBAwMAWAIBAAMATCMzJSMzIwYFGisBFRQGJyMVFAYHIyImNzUjIiYnNTQ2NzM1NDY7ATIWFxUzMhYDEiAW6CAWaxYgAegXHgEgFugeF2sXHgHoFx4BvmsWIAHpFh4BIBXpHhdrFx4B6BYgIBboIAAC//3/cQPrA1kAJwBQALBADiQWBgMBAkxCNAMEAwJHS7AhUFhAJgABAgMCAQNtBwEDBAIDBGsAAgIAWAYBAAAMSAAEBAVYAAUFDQVJG0uwJFBYQCMAAQIDAgEDbQcBAwQCAwRrAAQABQQFXAACAgBYBgEAAAwCSRtAKQABAgMCAQNtBwEDBAIDBGsGAQAAAgEAAmAABAUFBFQABAQFWAAFBAVMWVlAFykoAQBHRTEvKFApUBQSDAoAJwEnCAUUKwEiBwYHBgcUFh8BMzI1Njc2NzYzMhYXBwYWHwEWPgEvAS4BDwEmJyYBIhUGBwYHBiMiJyYnNzYmLwEmDgEfAR4BPwEWFxYzMjc2NzY3NCYvAQHug3FtQ0UFBQQEVBMFNTNTV2NPjjQ6CQIM9wsUCgQ6AhIJQURaXAEzEwU1M1NWY1BIRTU7CAIL+AsUCgQ6AhIKQERaXWaCcW5CRQUFBAQDWUA+a26BCAkCARJiU1EvMT44OQkTAzIDCRYQ4wgLBjxGJij+BBJiU1EvMSAeODkJEwMyAwkWEOMICwY8RiYoQD5rboIICAIBAAAAAAL///9iA+oDWQAfAEEASUAKBAECAAFHMQEBREuwJFBYQBMAAgABAAIBbQABAW4DAQAADABJG0APAwEAAgBvAAIBAm8AAQFmWUANAQAhIBQTAB8BHwQFFCsBIgcGBzE2NzYXFhcWFxYGBwYXHgE3PgE3NiYnLgEnJgEiBwYHBgcGFhcWFxYXFjc2NzEGBwYnJicmJyY2NzYmJyYB8ldRVERWbGpnak9CISEGJQ4aEDMRAwoCIwElJpBeW/4FGA8EBAYBJAIkJkhbe3d5fWFWbGpna09CISAFJQgGDhIDWR0eOUUVFB4gT0JWU7NRKRsQAREDDwZaw1ldkCYl/u4QBAYIBlrDWV1IWyQiGBlRRRUUHiBPQlZTs1EVIQ4SAAAAAAIAAAAAA+gDWQAnAD8AfUATKAEBBhEBAgE3LgIEAiEBBQQER0uwJFBYQCQABAIFAgQFbQAFAwIFA2sAAQACBAECYAADAAADAFwABgYMBkkbQCwABgEGbwAEAgUCBAVtAAUDAgUDawABAAIEAQJgAAMAAANUAAMDAFgAAAMATFlACjobJTU2JTMHBRsrARUUBiMhIiY1ETQ2NyEyFh0BFAYjISIGBxEUFhchMjY9ATQ2OwEyFhMRFA4BLwEBBiIvASY0NwEnJjQ2MyEyFgMSXkP+MENeXkMBiQcKCgf+dyU0ATYkAdAlNAoIJAgK1hYcC2L+lAUQBEAGBgFsYgsWDgEdDxQBU7JDXl5DAdBCXgEKCCQICjQl/jAlNAE2JLIICgoB2v7jDxQCDGL+lAYGQAUOBgFsYgscFhYAAAACAAD/uANZAxIAGAAoADJALxIJAgIAAUcAAgABAAIBbQAEAAACBABgAAEDAwFUAAEBA1gAAwEDTDU3FBkzBQUZKwERNCYnISIGHwEBBhQfARYyNwEXFjMyNzYTERQGByEiJjURNDY3ITIWAsoUD/70GBMSUP7WCws5CxwLASpRCg8GCBWPXkP96UNeXkMCF0NeAVMBDA8UAS0QUP7WCx4KOQoKASpQCwMKATX96EJeAWBBAhhCXgFgAAAAAAMAAAAAA1oCywAPAB8ALwA3QDQoAQQFCAACAAECRwAFAAQDBQRgAAMAAgEDAmAAAQAAAVQAAQEAWAAAAQBMJjUmNSYzBgUaKyUVFAYHISImJzU0NjchMhYDFRQGJyEiJic1NDYXITIWAxUUBiMhIiYnNTQ2FyEyFgNZFBD87w8UARYOAxEPFgEUEPzvDxQBFg4DEQ8WARQQ/O8PFAEWDgMRDxZrRw8UARYORw8UARYBEEgOFgEUD0gOFgEUAQ5HDhYWDkcPFgEUAAAAAAL///+4A+kCygAZADgALUAqCQACAgMBRwADAgNvAAIBAm8AAQAAAVQAAQEAWAAAAQBMNzQmJDozBAUWKwERFAYHISImNxEWFxYXHgI3MzI+ATc2NzY3FAYHBg8BDgInIyImLwEuAS8BJicuASc0NjMhMhYD6DQl/MokNgEZH8pMICZEGwIcQigfX7cgGDYp0jQ1DCIeDQIMHhEeDSIGk2ASIzwBLisDNiQ2Ac3+RSU0ATYkAbsbFok3GBocARocF0R8Fr8sUB2SIycJEgwBCgoSCBwDZUIOF1IkKzo0AAAAAgAA/3ED6ALKABcAPQBiQAw0CAIBACYLAgMCAkdLsCFQWEAXAAQFAQABBABgAAEAAgMBAmAAAwMNA0kbQB4AAwIDcAAEBQEAAQQAYAABAgIBVAABAQJYAAIBAkxZQBEBADs6JCIdGxIQABcBFwYFFCsBIg4BBxQWHwEHBgc2PwEXFjMyPgIuAQEUDgEjIicGBwYHIyImJzUmNiY/ATY/AT4CPwEuASc0PgEgHgEB9HLGdAFQSTAPDRpVRRggJiJyxnQCeMIBgIbmiCcqbpMbJAMIDgICBAIDDAQNFAcUEAcPWGQBhuYBEOaGAoNOhEw+cikcNTMuJDwVAwVOhJiETv7iYaRgBGEmCAQMCQECCAQDDwUOFggcHBMqMpJUYaRgYKQAAAIAAP+4A1kDEgAjADMAQUA+DQEAAR8BBAMCRwIBAAEDAQADbQUBAwQBAwRrAAcAAQAHAWAABAYGBFQABAQGWAAGBAZMNTUjMxYjJCMIBRwrATU0JgcjNTQmJyMiBgcVIyIGBxUUFjczFRQWOwEyNjc1MzI2ExEUBgchIiY1ETQ2NyEyFgLKFA+zFg5HDxQBsg8UARYOshYORw8UAbMOFo5eQ/3pQ15eQwIXQ14BQUgOFgGzDxQBFg6zFA9IDhYBsw4WFg6zFAE//ehCXgFgQQIYQl4BYAAAAAEAAP+4A+gDNQArAClAJiYBBAMBRwADBANvAAQBBG8AAQIBbwACAAJvAAAAZiMXEz0XBQUZKyUUBw4CBwYiJjU0Njc2NTQuBSsBFRQGIicBJjQ3ATYyFgcVMyAXFgPoRwEKBAUHEQoCAQMUIjg+VlY3fRQgCf7jCwsBHQscGAJ9AY5aHuhdnwQSEAQKDAgFFAMmHzhaQDAeEgaPDhYLAR4KHgoBHgoUD4/hSwABAAAAAAKDA1oAIwBmS7AkUFhAIAAEBQAFBABtAgYCAAEFAAFrAAEBbgAFBQNYAAMDDAVJG0AlAAQFAAUEAG0CBgIAAQUAAWsAAQFuAAMFBQNUAAMDBVgABQMFTFlAEwEAIB8bGBQTEA4JBgAjASMHBRQrATIWFxEUBgchIiYnETQ2FzM1NDYeAQcUBisBIiY1NCYiBhcVAk0XHgEgFv3pFx4BIBYRlMyWAhQPJA4WVHZUAQGsHhf+vhYeASAVAUIWIAGzZ5QCkGkOFhYOO1RUO7MAAAMAAP+4A30DEgAIABgAVQBOQEtKAQgHHxsCAAMAAQEAMRECAgEERwAHCAdvAAgDCG8GAQMAA28AAAEAbwAEAgRwAAECAgFUAAEBAlgFAQIBAkwvLBUkPyY1ExIJBR0rNzQuAQ4BHgE2ExEUBgcjIiYnETQ2FzMyFgUUBxYVFgcWBwYHFgcGByMiLgEnJiciJicRND4CNzY3PgI3PgMzMh4EBhcUDgEHDgIHMzIWjxYdFAEWHRRaFBCgDxQBFg6gDxYClB8JARkJCQkWBSAkSkglVjIqRRMPFAEUGzocJhIKDgYFBAYQFQ8ZKhgUCAYCAgwIDAEIBAObK0BrDxQBFh0UARYBLP6bDxQBFg4BZQ4WARQPMCMZEioiHyMfFT4nKwESDg8YARYOAWUOFgFAIzESCiIUGBYYIhYMEhoYIBINFSwWFAQMDgZAAAAABQAA/3ED6ANZABAAFAAlAC8AOQDbQBczKQIHCCEBBQIdFQ0MBAAFA0cEAQUBRkuwIVBYQC0GDAMLBAEHAgcBAm0AAgUHAgVrAAUABwUAawkBBwcIWAoBCAgMSAQBAAANAEkbS7AkUFhALAYMAwsEAQcCBwECbQACBQcCBWsABQAHBQBrBAEAAG4JAQcHCFgKAQgIDAdJG0AyBgwDCwQBBwIHAQJtAAIFBwIFawAFAAcFAGsEAQAAbgoBCAcHCFQKAQgIB1YJAQcIB0pZWUAgEREAADc1MjEtKygnJCIfHhsZERQRFBMSABAADzcNBRUrAREUBgcRFAYHISImJxETNjMhESMRAREUBgchIiYnESImJxEzMhclFSM1NDY7ATIWBRUjNTQ2OwEyFgGJFg4UEP7jDxQBiwQNAZ+OAjsWDv7jDxQBDxQB7Q0E/j7FCgihCAoBd8UKCKEICgKm/lQPFAH+vw8UARYOAR0B6Az+eAGI/gz+4w8UARYOAUEWDgGsDK19fQgKCgh9fQgKCgAAAAMAAP+4BHgDEwAIACwATwB3QHQsJQIKByAfDgMDAjITAgQIA0cAAQcBbwAHCgdvDgEACg0KAA1tAAsNAg0LAm0MAQoADQsKDWAGAQIFAQMIAgNgAAgEBAhUAAgIBFgJAQQIBEwBAE1LSkhFREE/NjMxLykoJCIcGxcVEhAKCQUEAAgBCA8FFCsBIiY+AR4CBgUzMhYHFRQGKwEVFAYHIyImPQEjIiYnNTQ2NzM1NDYXMzIWFwEUFjczFQYjISImNTQ+BRcyFx4BMjY3NjMyFyMiBhUBiVl+Anq2eAaEAcPEBwwBCgjEDAZrCArFBwoBDAbFCghrBwoB/mUqHY8mOf4YQ1IEDBIeJjohCwssVGRULAsLSTB9HSoBZX6wgAJ8tHpJDAZrCArFBwoBDAbFCghrBwoBxAcMAQoI/r8dLAGFHE5DHjhCNjgiGgIKIiIiIgo2Kh0AAAAAAQAAAAEAAJXXboNfDzz1AAsD6AAAAADYF67NAAAAANgXrs3/9f9iBHgDZwAAAAgAAgAAAAAAAAABAAADWf9xAAAEdv/1//MEeAABAAAAAAAAAAAAAAAAAAAAJAPoAAADEQAAA6AAAAOgAAADoAAAA1n//QPoAAAELwAAA1kAAAOgAAAD6AAAA6v//gQv//8EL///AsoAAALKAAAD6AAAA+gAAAKCAAADWf/9A6AAAAPo//UDEQAAA+j//QPp//8D6AAAA1kAAANZAAAD6P//A+gAAANZAAAD6AAAAoIAAAOgAAAD6AAABHYAAAAAAAAASgDOARIBbAGoAloC4AOiBCQEWgTEBT4GkAbGBvoHMAgECEwMUAyODRINWg4wDsAPXg+8ECIQkhEkEZAR5hJQEvYTvhRpAAAAAQAAACQB+AALAAAAAAACACwAPABzAAAAqgtwAAAAAAAAABIA3gABAAAAAAAAADUAAAABAAAAAAABAAgANQABAAAAAAACAAcAPQABAAAAAAADAAgARAABAAAAAAAEAAgATAABAAAAAAAFAAsAVAABAAAAAAAGAAgAXwABAAAAAAAKACsAZwABAAAAAAALABMAkgADAAEECQAAAGoApQADAAEECQABABABDwADAAEECQACAA4BHwADAAEECQADABABLQADAAEECQAEABABPQADAAEECQAFABYBTQADAAEECQAGABABYwADAAEECQAKAFYBcwADAAEECQALACYByUNvcHlyaWdodCAoQykgMjAxOCBieSBvcmlnaW5hbCBhdXRob3JzIEAgZm9udGVsbG8uY29tZm9udGVsbG9SZWd1bGFyZm9udGVsbG9mb250ZWxsb1ZlcnNpb24gMS4wZm9udGVsbG9HZW5lcmF0ZWQgYnkgc3ZnMnR0ZiBmcm9tIEZvbnRlbGxvIHByb2plY3QuaHR0cDovL2ZvbnRlbGxvLmNvbQBDAG8AcAB5AHIAaQBnAGgAdAAgACgAQwApACAAMgAwADEAOAAgAGIAeQAgAG8AcgBpAGcAaQBuAGEAbAAgAGEAdQB0AGgAbwByAHMAIABAACAAZgBvAG4AdABlAGwAbABvAC4AYwBvAG0AZgBvAG4AdABlAGwAbABvAFIAZQBnAHUAbABhAHIAZgBvAG4AdABlAGwAbABvAGYAbwBuAHQAZQBsAGwAbwBWAGUAcgBzAGkAbwBuACAAMQAuADAAZgBvAG4AdABlAGwAbABvAEcAZQBuAGUAcgBhAHQAZQBkACAAYgB5ACAAcwB2AGcAMgB0AHQAZgAgAGYAcgBvAG0AIABGAG8AbgB0AGUAbABsAG8AIABwAHIAbwBqAGUAYwB0AC4AaAB0AHQAcAA6AC8ALwBmAG8AbgB0AGUAbABsAG8ALgBjAG8AbQAAAAACAAAAAAAAAAoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACQBAgEDAQQBBQEGAQcBCAEJAQoBCwEMAQ0BDgEPARABEQESARMBFAEVARYBFwEYARkBGgEbARwBHQEeAR8BIAEhASIBIwEkASUABmNhbmNlbAZ1cGxvYWQEc3RhcgpzdGFyLWVtcHR5BmFkanVzdAdleWUtb2ZmB3JldHdlZXQDY29nBmxvZ291dAlkb3duLW9wZW4GYXR0YWNoB3BpY3R1cmUFdmlkZW8KcmlnaHQtb3BlbglsZWZ0LW9wZW4HdXAtb3BlbgRiZWxsBGxvY2sFZ2xvYmUFYnJ1c2gJYXR0ZW50aW9uBHBsdXMFc3BpbjMFc3BpbjQIbGluay1leHQMbGluay1leHQtYWx0BG1lbnUIbWFpbC1hbHQNY29tbWVudC1lbXB0eQxwbHVzLXNxdWFyZWQFcmVwbHkNbG9jay1vcGVuLWFsdA10aHVtYnMtdXAtYWx0CmJpbm9jdWxhcnMJdXNlci1wbHVzAAAAAQAB//8ADwAAAAAAAAAAAAAAAAAAAAAAGAAYABgAGANn/2IDZ/9isAAsILAAVVhFWSAgS7gADlFLsAZTWliwNBuwKFlgZiCKVViwAiVhuQgACABjYyNiGyEhsABZsABDI0SyAAEAQ2BCLbABLLAgYGYtsAIsIGQgsMBQsAQmWrIoAQpDRWNFUltYISMhG4pYILBQUFghsEBZGyCwOFBYIbA4WVkgsQEKQ0VjRWFksChQWCGxAQpDRWNFILAwUFghsDBZGyCwwFBYIGYgiophILAKUFhgGyCwIFBYIbAKYBsgsDZQWCGwNmAbYFlZWRuwAStZWSOwAFBYZVlZLbADLCBFILAEJWFkILAFQ1BYsAUjQrAGI0IbISFZsAFgLbAELCMhIyEgZLEFYkIgsAYjQrEBCkNFY7EBCkOwAWBFY7ADKiEgsAZDIIogirABK7EwBSWwBCZRWGBQG2FSWVgjWSEgsEBTWLABKxshsEBZI7AAUFhlWS2wBSywB0MrsgACAENgQi2wBiywByNCIyCwACNCYbACYmawAWOwAWCwBSotsAcsICBFILALQ2O4BABiILAAUFiwQGBZZrABY2BEsAFgLbAILLIHCwBDRUIqIbIAAQBDYEItsAkssABDI0SyAAEAQ2BCLbAKLCAgRSCwASsjsABDsAQlYCBFiiNhIGQgsCBQWCGwABuwMFBYsCAbsEBZWSOwAFBYZVmwAyUjYUREsAFgLbALLCAgRSCwASsjsABDsAQlYCBFiiNhIGSwJFBYsAAbsEBZI7AAUFhlWbADJSNhRESwAWAtsAwsILAAI0KyCwoDRVghGyMhWSohLbANLLECAkWwZGFELbAOLLABYCAgsAxDSrAAUFggsAwjQlmwDUNKsABSWCCwDSNCWS2wDywgsBBiZrABYyC4BABjiiNhsA5DYCCKYCCwDiNCIy2wECxLVFixBGREWSSwDWUjeC2wESxLUVhLU1ixBGREWRshWSSwE2UjeC2wEiyxAA9DVVixDw9DsAFhQrAPK1mwAEOwAiVCsQwCJUKxDQIlQrABFiMgsAMlUFixAQBDYLAEJUKKiiCKI2GwDiohI7ABYSCKI2GwDiohG7EBAENgsAIlQrACJWGwDiohWbAMQ0ewDUNHYLACYiCwAFBYsEBgWWawAWMgsAtDY7gEAGIgsABQWLBAYFlmsAFjYLEAABMjRLABQ7AAPrIBAQFDYEItsBMsALEAAkVUWLAPI0IgRbALI0KwCiOwAWBCIGCwAWG1EBABAA4AQkKKYLESBiuwcisbIlktsBQssQATKy2wFSyxARMrLbAWLLECEystsBcssQMTKy2wGCyxBBMrLbAZLLEFEystsBossQYTKy2wGyyxBxMrLbAcLLEIEystsB0ssQkTKy2wHiwAsA0rsQACRVRYsA8jQiBFsAsjQrAKI7ABYEIgYLABYbUQEAEADgBCQopgsRIGK7ByKxsiWS2wHyyxAB4rLbAgLLEBHistsCEssQIeKy2wIiyxAx4rLbAjLLEEHistsCQssQUeKy2wJSyxBh4rLbAmLLEHHistsCcssQgeKy2wKCyxCR4rLbApLCA8sAFgLbAqLCBgsBBgIEMjsAFgQ7ACJWGwAWCwKSohLbArLLAqK7AqKi2wLCwgIEcgILALQ2O4BABiILAAUFiwQGBZZrABY2AjYTgjIIpVWCBHICCwC0NjuAQAYiCwAFBYsEBgWWawAWNgI2E4GyFZLbAtLACxAAJFVFiwARawLCqwARUwGyJZLbAuLACwDSuxAAJFVFiwARawLCqwARUwGyJZLbAvLCA1sAFgLbAwLACwAUVjuAQAYiCwAFBYsEBgWWawAWOwASuwC0NjuAQAYiCwAFBYsEBgWWawAWOwASuwABa0AAAAAABEPiM4sS8BFSotsDEsIDwgRyCwC0NjuAQAYiCwAFBYsEBgWWawAWNgsABDYTgtsDIsLhc8LbAzLCA8IEcgsAtDY7gEAGIgsABQWLBAYFlmsAFjYLAAQ2GwAUNjOC2wNCyxAgAWJSAuIEewACNCsAIlSYqKRyNHI2EgWGIbIVmwASNCsjMBARUUKi2wNSywABawBCWwBCVHI0cjYbAJQytlii4jICA8ijgtsDYssAAWsAQlsAQlIC5HI0cjYSCwBCNCsAlDKyCwYFBYILBAUVizAiADIBuzAiYDGllCQiMgsAhDIIojRyNHI2EjRmCwBEOwAmIgsABQWLBAYFlmsAFjYCCwASsgiophILACQ2BkI7ADQ2FkUFiwAkNhG7ADQ2BZsAMlsAJiILAAUFiwQGBZZrABY2EjICCwBCYjRmE4GyOwCENGsAIlsAhDRyNHI2FgILAEQ7ACYiCwAFBYsEBgWWawAWNgIyCwASsjsARDYLABK7AFJWGwBSWwAmIgsABQWLBAYFlmsAFjsAQmYSCwBCVgZCOwAyVgZFBYIRsjIVkjICCwBCYjRmE4WS2wNyywABYgICCwBSYgLkcjRyNhIzw4LbA4LLAAFiCwCCNCICAgRiNHsAErI2E4LbA5LLAAFrADJbACJUcjRyNhsABUWC4gPCMhG7ACJbACJUcjRyNhILAFJbAEJUcjRyNhsAYlsAUlSbACJWG5CAAIAGNjIyBYYhshWWO4BABiILAAUFiwQGBZZrABY2AjLiMgIDyKOCMhWS2wOiywABYgsAhDIC5HI0cjYSBgsCBgZrACYiCwAFBYsEBgWWawAWMjICA8ijgtsDssIyAuRrACJUZSWCA8WS6xKwEUKy2wPCwjIC5GsAIlRlBYIDxZLrErARQrLbA9LCMgLkawAiVGUlggPFkjIC5GsAIlRlBYIDxZLrErARQrLbA+LLA1KyMgLkawAiVGUlggPFkusSsBFCstsD8ssDYriiAgPLAEI0KKOCMgLkawAiVGUlggPFkusSsBFCuwBEMusCsrLbBALLAAFrAEJbAEJiAuRyNHI2GwCUMrIyA8IC4jOLErARQrLbBBLLEIBCVCsAAWsAQlsAQlIC5HI0cjYSCwBCNCsAlDKyCwYFBYILBAUVizAiADIBuzAiYDGllCQiMgR7AEQ7ACYiCwAFBYsEBgWWawAWNgILABKyCKimEgsAJDYGQjsANDYWRQWLACQ2EbsANDYFmwAyWwAmIgsABQWLBAYFlmsAFjYbACJUZhOCMgPCM4GyEgIEYjR7ABKyNhOCFZsSsBFCstsEIssDUrLrErARQrLbBDLLA2KyEjICA8sAQjQiM4sSsBFCuwBEMusCsrLbBELLAAFSBHsAAjQrIAAQEVFBMusDEqLbBFLLAAFSBHsAAjQrIAAQEVFBMusDEqLbBGLLEAARQTsDIqLbBHLLA0Ki2wSCywABZFIyAuIEaKI2E4sSsBFCstsEkssAgjQrBIKy2wSiyyAABBKy2wSyyyAAFBKy2wTCyyAQBBKy2wTSyyAQFBKy2wTiyyAABCKy2wTyyyAAFCKy2wUCyyAQBCKy2wUSyyAQFCKy2wUiyyAAA+Ky2wUyyyAAE+Ky2wVCyyAQA+Ky2wVSyyAQE+Ky2wViyyAABAKy2wVyyyAAFAKy2wWCyyAQBAKy2wWSyyAQFAKy2wWiyyAABDKy2wWyyyAAFDKy2wXCyyAQBDKy2wXSyyAQFDKy2wXiyyAAA/Ky2wXyyyAAE/Ky2wYCyyAQA/Ky2wYSyyAQE/Ky2wYiywNysusSsBFCstsGMssDcrsDsrLbBkLLA3K7A8Ky2wZSywABawNyuwPSstsGYssDgrLrErARQrLbBnLLA4K7A7Ky2waCywOCuwPCstsGkssDgrsD0rLbBqLLA5Ky6xKwEUKy2wayywOSuwOystsGwssDkrsDwrLbBtLLA5K7A9Ky2wbiywOisusSsBFCstsG8ssDorsDsrLbBwLLA6K7A8Ky2wcSywOiuwPSstsHIsswkEAgNFWCEbIyFZQiuwCGWwAyRQeLABFTAtAEu4AMhSWLEBAY5ZsAG5CAAIAGNwsQAFQrIAAQAqsQAFQrMKAgEIKrEABUKzDgABCCqxAAZCugLAAAEACSqxAAdCugBAAAEACSqxAwBEsSQBiFFYsECIWLEDZESxJgGIUVi6CIAAAQRAiGNUWLEDAERZWVlZswwCAQwquAH/hbAEjbECAEQAAA==') format('truetype'); } /* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */ /* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */ @@ -17,7 +17,7 @@ @media screen and (-webkit-min-device-pixel-ratio:0) { @font-face { font-family: 'fontello'; - src: url('../font/fontello.svg?12951540#fontello') format('svg'); + src: url('../font/fontello.svg?4112743#fontello') format('svg'); } } */ @@ -56,9 +56,9 @@ .icon-upload:before { content: '\e801'; } /* '' */ .icon-star:before { content: '\e802'; } /* '' */ .icon-star-empty:before { content: '\e803'; } /* '' */ -.icon-retweet:before { content: '\e804'; } /* '' */ +.icon-adjust:before { content: '\e804'; } /* '' */ .icon-eye-off:before { content: '\e805'; } /* '' */ -.icon-plus-squared:before { content: '\e806'; } /* '' */ +.icon-retweet:before { content: '\e806'; } /* '' */ .icon-cog:before { content: '\e807'; } /* '' */ .icon-logout:before { content: '\e808'; } /* '' */ .icon-down-open:before { content: '\e809'; } /* '' */ @@ -69,11 +69,21 @@ .icon-left-open:before { content: '\e80e'; } /* '' */ .icon-up-open:before { content: '\e80f'; } /* '' */ .icon-bell:before { content: '\e810'; } /* '' */ +.icon-lock:before { content: '\e811'; } /* '' */ +.icon-globe:before { content: '\e812'; } /* '' */ +.icon-brush:before { content: '\e813'; } /* '' */ +.icon-attention:before { content: '\e814'; } /* '' */ +.icon-plus:before { content: '\e815'; } /* '' */ .icon-spin3:before { content: '\e832'; } /* '' */ .icon-spin4:before { content: '\e834'; } /* '' */ .icon-link-ext:before { content: '\f08e'; } /* '' */ +.icon-link-ext-alt:before { content: '\f08f'; } /* '' */ .icon-menu:before { content: '\f0c9'; } /* '' */ +.icon-mail-alt:before { content: '\f0e0'; } /* '' */ .icon-comment-empty:before { content: '\f0e5'; } /* '' */ +.icon-plus-squared:before { content: '\f0fe'; } /* '' */ .icon-reply:before { content: '\f112'; } /* '' */ +.icon-lock-open-alt:before { content: '\f13e'; } /* '' */ +.icon-thumbs-up-alt:before { content: '\f164'; } /* '' */ .icon-binoculars:before { content: '\f1e5'; } /* '' */ .icon-user-plus:before { content: '\f234'; } /* '' */
\ No newline at end of file diff --git a/static/font/css/fontello-ie7-codes.css b/static/font/css/fontello-ie7-codes.css index d7a5a523..fa7c1002 100644 --- a/static/font/css/fontello-ie7-codes.css +++ b/static/font/css/fontello-ie7-codes.css @@ -3,9 +3,9 @@ .icon-upload { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-star { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-star-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } -.icon-retweet { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-adjust { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-eye-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } -.icon-plus-squared { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-retweet { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-cog { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-logout { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-down-open { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } @@ -16,11 +16,21 @@ .icon-left-open { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-up-open { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-bell { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-lock { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-globe { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-brush { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-attention { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-link-ext-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-menu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-mail-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-comment-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-plus-squared { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-reply { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-lock-open-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-thumbs-up-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-binoculars { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-user-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
\ No newline at end of file diff --git a/static/font/css/fontello-ie7.css b/static/font/css/fontello-ie7.css index 5ca12cea..b37a63cd 100644 --- a/static/font/css/fontello-ie7.css +++ b/static/font/css/fontello-ie7.css @@ -14,9 +14,9 @@ .icon-upload { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-star { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-star-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } -.icon-retweet { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-adjust { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-eye-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } -.icon-plus-squared { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-retweet { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-cog { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-logout { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-down-open { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } @@ -27,11 +27,21 @@ .icon-left-open { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-up-open { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-bell { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-lock { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-globe { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-brush { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-attention { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-link-ext-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-menu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-mail-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-comment-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-plus-squared { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-reply { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-lock-open-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } +.icon-thumbs-up-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-binoculars { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); } .icon-user-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
\ No newline at end of file diff --git a/static/font/css/fontello.css b/static/font/css/fontello.css index 5cc6f97d..38caec46 100644 --- a/static/font/css/fontello.css +++ b/static/font/css/fontello.css @@ -1,11 +1,11 @@ @font-face { font-family: 'fontello'; - src: url('../font/fontello.eot?47566415'); - src: url('../font/fontello.eot?47566415#iefix') format('embedded-opentype'), - url('../font/fontello.woff2?47566415') format('woff2'), - url('../font/fontello.woff?47566415') format('woff'), - url('../font/fontello.ttf?47566415') format('truetype'), - url('../font/fontello.svg?47566415#fontello') format('svg'); + src: url('../font/fontello.eot?3996201'); + src: url('../font/fontello.eot?3996201#iefix') format('embedded-opentype'), + url('../font/fontello.woff2?3996201') format('woff2'), + url('../font/fontello.woff?3996201') format('woff'), + url('../font/fontello.ttf?3996201') format('truetype'), + url('../font/fontello.svg?3996201#fontello') format('svg'); font-weight: normal; font-style: normal; } @@ -15,7 +15,7 @@ @media screen and (-webkit-min-device-pixel-ratio:0) { @font-face { font-family: 'fontello'; - src: url('../font/fontello.svg?47566415#fontello') format('svg'); + src: url('../font/fontello.svg?3996201#fontello') format('svg'); } } */ @@ -59,9 +59,9 @@ .icon-upload:before { content: '\e801'; } /* '' */ .icon-star:before { content: '\e802'; } /* '' */ .icon-star-empty:before { content: '\e803'; } /* '' */ -.icon-retweet:before { content: '\e804'; } /* '' */ +.icon-adjust:before { content: '\e804'; } /* '' */ .icon-eye-off:before { content: '\e805'; } /* '' */ -.icon-plus-squared:before { content: '\e806'; } /* '' */ +.icon-retweet:before { content: '\e806'; } /* '' */ .icon-cog:before { content: '\e807'; } /* '' */ .icon-logout:before { content: '\e808'; } /* '' */ .icon-down-open:before { content: '\e809'; } /* '' */ @@ -72,11 +72,21 @@ .icon-left-open:before { content: '\e80e'; } /* '' */ .icon-up-open:before { content: '\e80f'; } /* '' */ .icon-bell:before { content: '\e810'; } /* '' */ +.icon-lock:before { content: '\e811'; } /* '' */ +.icon-globe:before { content: '\e812'; } /* '' */ +.icon-brush:before { content: '\e813'; } /* '' */ +.icon-attention:before { content: '\e814'; } /* '' */ +.icon-plus:before { content: '\e815'; } /* '' */ .icon-spin3:before { content: '\e832'; } /* '' */ .icon-spin4:before { content: '\e834'; } /* '' */ .icon-link-ext:before { content: '\f08e'; } /* '' */ +.icon-link-ext-alt:before { content: '\f08f'; } /* '' */ .icon-menu:before { content: '\f0c9'; } /* '' */ +.icon-mail-alt:before { content: '\f0e0'; } /* '' */ .icon-comment-empty:before { content: '\f0e5'; } /* '' */ +.icon-plus-squared:before { content: '\f0fe'; } /* '' */ .icon-reply:before { content: '\f112'; } /* '' */ +.icon-lock-open-alt:before { content: '\f13e'; } /* '' */ +.icon-thumbs-up-alt:before { content: '\f164'; } /* '' */ .icon-binoculars:before { content: '\f1e5'; } /* '' */ .icon-user-plus:before { content: '\f234'; } /* '' */
\ No newline at end of file diff --git a/static/font/demo.html b/static/font/demo.html index d01bd1d6..cb1aa970 100644 --- a/static/font/demo.html +++ b/static/font/demo.html @@ -229,11 +229,11 @@ body { } @font-face { font-family: 'fontello'; - src: url('./font/fontello.eot?34497073'); - src: url('./font/fontello.eot?34497073#iefix') format('embedded-opentype'), - url('./font/fontello.woff?34497073') format('woff'), - url('./font/fontello.ttf?34497073') format('truetype'), - url('./font/fontello.svg?34497073#fontello') format('svg'); + src: url('./font/fontello.eot?15755415'); + src: url('./font/fontello.eot?15755415#iefix') format('embedded-opentype'), + url('./font/fontello.woff?15755415') format('woff'), + url('./font/fontello.ttf?15755415') format('truetype'), + url('./font/fontello.svg?15755415#fontello') format('svg'); font-weight: normal; font-style: normal; } @@ -304,9 +304,9 @@ body { <div class="the-icons span3" title="Code: 0xe803"><i class="demo-icon icon-star-empty"></i> <span class="i-name">icon-star-empty</span><span class="i-code">0xe803</span></div> </div> <div class="row"> - <div class="the-icons span3" title="Code: 0xe804"><i class="demo-icon icon-retweet"></i> <span class="i-name">icon-retweet</span><span class="i-code">0xe804</span></div> + <div class="the-icons span3" title="Code: 0xe804"><i class="demo-icon icon-adjust"></i> <span class="i-name">icon-adjust</span><span class="i-code">0xe804</span></div> <div class="the-icons span3" title="Code: 0xe805"><i class="demo-icon icon-eye-off"></i> <span class="i-name">icon-eye-off</span><span class="i-code">0xe805</span></div> - <div class="the-icons span3" title="Code: 0xe806"><i class="demo-icon icon-plus-squared"></i> <span class="i-name">icon-plus-squared</span><span class="i-code">0xe806</span></div> + <div class="the-icons span3" title="Code: 0xe806"><i class="demo-icon icon-retweet"></i> <span class="i-name">icon-retweet</span><span class="i-code">0xe806</span></div> <div class="the-icons span3" title="Code: 0xe807"><i class="demo-icon icon-cog"></i> <span class="i-name">icon-cog</span><span class="i-code">0xe807</span></div> </div> <div class="row"> @@ -323,17 +323,31 @@ body { </div> <div class="row"> <div class="the-icons span3" title="Code: 0xe810"><i class="demo-icon icon-bell"></i> <span class="i-name">icon-bell</span><span class="i-code">0xe810</span></div> + <div class="the-icons span3" title="Code: 0xe811"><i class="demo-icon icon-lock"></i> <span class="i-name">icon-lock</span><span class="i-code">0xe811</span></div> + <div class="the-icons span3" title="Code: 0xe812"><i class="demo-icon icon-globe"></i> <span class="i-name">icon-globe</span><span class="i-code">0xe812</span></div> + <div class="the-icons span3" title="Code: 0xe813"><i class="demo-icon icon-brush"></i> <span class="i-name">icon-brush</span><span class="i-code">0xe813</span></div> + </div> + <div class="row"> + <div class="the-icons span3" title="Code: 0xe814"><i class="demo-icon icon-attention"></i> <span class="i-name">icon-attention</span><span class="i-code">0xe814</span></div> + <div class="the-icons span3" title="Code: 0xe815"><i class="demo-icon icon-plus"></i> <span class="i-name">icon-plus</span><span class="i-code">0xe815</span></div> <div class="the-icons span3" title="Code: 0xe832"><i class="demo-icon icon-spin3 animate-spin"></i> <span class="i-name">icon-spin3</span><span class="i-code">0xe832</span></div> <div class="the-icons span3" title="Code: 0xe834"><i class="demo-icon icon-spin4 animate-spin"></i> <span class="i-name">icon-spin4</span><span class="i-code">0xe834</span></div> - <div class="the-icons span3" title="Code: 0xf08e"><i class="demo-icon icon-link-ext"></i> <span class="i-name">icon-link-ext</span><span class="i-code">0xf08e</span></div> </div> <div class="row"> + <div class="the-icons span3" title="Code: 0xf08e"><i class="demo-icon icon-link-ext"></i> <span class="i-name">icon-link-ext</span><span class="i-code">0xf08e</span></div> + <div class="the-icons span3" title="Code: 0xf08f"><i class="demo-icon icon-link-ext-alt"></i> <span class="i-name">icon-link-ext-alt</span><span class="i-code">0xf08f</span></div> <div class="the-icons span3" title="Code: 0xf0c9"><i class="demo-icon icon-menu"></i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span></div> + <div class="the-icons span3" title="Code: 0xf0e0"><i class="demo-icon icon-mail-alt"></i> <span class="i-name">icon-mail-alt</span><span class="i-code">0xf0e0</span></div> + </div> + <div class="row"> <div class="the-icons span3" title="Code: 0xf0e5"><i class="demo-icon icon-comment-empty"></i> <span class="i-name">icon-comment-empty</span><span class="i-code">0xf0e5</span></div> + <div class="the-icons span3" title="Code: 0xf0fe"><i class="demo-icon icon-plus-squared"></i> <span class="i-name">icon-plus-squared</span><span class="i-code">0xf0fe</span></div> <div class="the-icons span3" title="Code: 0xf112"><i class="demo-icon icon-reply"></i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div> - <div class="the-icons span3" title="Code: 0xf1e5"><i class="demo-icon icon-binoculars"></i> <span class="i-name">icon-binoculars</span><span class="i-code">0xf1e5</span></div> + <div class="the-icons span3" title="Code: 0xf13e"><i class="demo-icon icon-lock-open-alt"></i> <span class="i-name">icon-lock-open-alt</span><span class="i-code">0xf13e</span></div> </div> <div class="row"> + <div class="the-icons span3" title="Code: 0xf164"><i class="demo-icon icon-thumbs-up-alt"></i> <span class="i-name">icon-thumbs-up-alt</span><span class="i-code">0xf164</span></div> + <div class="the-icons span3" title="Code: 0xf1e5"><i class="demo-icon icon-binoculars"></i> <span class="i-name">icon-binoculars</span><span class="i-code">0xf1e5</span></div> <div class="the-icons span3" title="Code: 0xf234"><i class="demo-icon icon-user-plus"></i> <span class="i-name">icon-user-plus</span><span class="i-code">0xf234</span></div> </div> </div> diff --git a/static/font/font/fontello.eot b/static/font/font/fontello.eot Binary files differindex d15e8391..7dfef262 100644 --- a/static/font/font/fontello.eot +++ b/static/font/font/fontello.eot diff --git a/static/font/font/fontello.svg b/static/font/font/fontello.svg index be07ddae..6e5616a1 100644 --- a/static/font/font/fontello.svg +++ b/static/font/font/fontello.svg @@ -4,57 +4,77 @@ <metadata>Copyright (C) 2018 by original authors @ fontello.com</metadata> <defs> <font id="fontello" horiz-adv-x="1000" > -<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" /> +<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="857" descent="-143" /> <missing-glyph horiz-adv-x="1000" /> -<glyph glyph-name="cancel" unicode="" d="M724 112q0-22-15-38l-76-76q-16-15-38-15t-38 15l-164 165-164-165q-16-15-38-15t-38 15l-76 76q-16 16-16 38t16 38l164 164-164 164q-16 16-16 38t16 38l76 76q16 16 38 16t38-16l164-164 164 164q16 16 38 16t38-16l76-76q15-15 15-38t-15-38l-164-164 164-164q15-15 15-38z" horiz-adv-x="785.7" /> +<glyph glyph-name="cancel" unicode="" d="M724 119q0-22-15-38l-76-76q-16-15-38-15t-38 15l-164 165-164-165q-16-15-38-15t-38 15l-76 76q-16 16-16 38t16 38l164 164-164 164q-16 16-16 38t16 38l76 76q16 16 38 16t38-16l164-164 164 164q16 16 38 16t38-16l76-76q15-15 15-38t-15-38l-164-164 164-164q15-15 15-38z" horiz-adv-x="785.7" /> -<glyph glyph-name="upload" unicode="" d="M714 29q0 14-10 25t-25 10-25-10-11-25 11-25 25-11 25 11 10 25z m143 0q0 14-10 25t-26 10-25-10-10-25 10-25 25-11 26 11 10 25z m72 125v-179q0-22-16-38t-38-16h-821q-23 0-38 16t-16 38v179q0 22 16 38t38 15h238q12-31 39-51t62-20h143q34 0 61 20t40 51h238q22 0 38-15t16-38z m-182 361q-9-22-33-22h-143v-250q0-15-10-25t-25-11h-143q-15 0-25 11t-11 25v250h-143q-23 0-33 22-9 22 8 39l250 250q10 10 25 10t25-10l250-250q18-17 8-39z" horiz-adv-x="928.6" /> +<glyph glyph-name="upload" unicode="" d="M714 36q0 14-10 25t-25 10-25-10-11-25 11-25 25-11 25 11 10 25z m143 0q0 14-10 25t-26 10-25-10-10-25 10-25 25-11 26 11 10 25z m72 125v-179q0-22-16-38t-38-16h-821q-23 0-38 16t-16 38v179q0 22 16 38t38 15h238q12-31 39-51t62-20h143q34 0 61 20t40 51h238q22 0 38-15t16-38z m-182 361q-9-22-33-22h-143v-250q0-15-10-25t-25-11h-143q-15 0-25 11t-11 25v250h-143q-23 0-33 22-9 22 8 39l250 250q10 10 25 10t25-10l250-250q18-17 8-39z" horiz-adv-x="928.6" /> -<glyph glyph-name="star" unicode="" d="M929 489q0-12-15-27l-202-197 48-279q0-4 0-12 0-11-6-19t-17-9q-10 0-22 7l-251 132-250-132q-12-7-23-7-11 0-17 9t-6 19q0 4 1 12l48 279-203 197q-14 15-14 27 0 21 31 26l280 40 126 254q11 23 27 23t28-23l125-254 280-40q32-5 32-26z" horiz-adv-x="928.6" /> +<glyph glyph-name="star" unicode="" d="M929 496q0-12-15-27l-202-197 48-279q0-4 0-12 0-11-6-19t-17-9q-10 0-22 7l-251 132-250-132q-12-7-23-7-11 0-17 9t-6 19q0 4 1 12l48 279-203 197q-14 15-14 27 0 21 31 26l280 40 126 254q11 23 27 23t28-23l125-254 280-40q32-5 32-26z" horiz-adv-x="928.6" /> -<glyph glyph-name="star-empty" unicode="" d="M635 290l170 166-235 34-106 213-105-213-236-34 171-166-41-235 211 111 211-111z m294 199q0-12-15-27l-202-197 48-279q0-4 0-12 0-28-23-28-10 0-22 7l-251 132-250-132q-12-7-23-7-11 0-17 9t-6 19q0 4 1 12l48 279-203 197q-14 15-14 27 0 21 31 26l280 40 126 254q11 23 27 23t28-23l125-254 280-40q32-5 32-26z" horiz-adv-x="928.6" /> +<glyph glyph-name="star-empty" unicode="" d="M635 297l170 166-235 34-106 213-105-213-236-34 171-166-41-235 211 111 211-111z m294 199q0-12-15-27l-202-197 48-279q0-4 0-12 0-28-23-28-10 0-22 7l-251 132-250-132q-12-7-23-7-11 0-17 9t-6 19q0 4 1 12l48 279-203 197q-14 15-14 27 0 21 31 26l280 40 126 254q11 23 27 23t28-23l125-254 280-40q32-5 32-26z" horiz-adv-x="928.6" /> -<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" /> +<glyph glyph-name="adjust" unicode="" d="M429 53v608q-83 0-153-41t-110-111-41-152 41-152 110-111 153-41z m428 304q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" /> -<glyph glyph-name="eye-off" unicode="" d="M310 105l43 79q-48 35-76 88t-27 114q0 67 34 125-128-65-213-197 94-144 239-209z m217 424q0 11-8 19t-19 7q-70 0-120-50t-50-119q0-11 8-19t19-8 19 8 8 19q0 48 34 82t82 34q11 0 19 8t8 19z m202 106q0-4 0-5-59-105-176-316t-176-316l-28-50q-5-9-15-9-7 0-75 39-9 6-9 16 0 7 25 49-80 36-147 96t-117 137q-11 17-11 38t11 39q86 131 212 207t277 76q50 0 100-10l31 54q5 9 15 9 3 0 10-3t18-9 18-10 18-10 10-7q9-5 9-15z m21-249q0-78-44-142t-117-91l157 280q4-25 4-47z m250-72q0-19-11-38-22-36-61-81-84-96-194-149t-234-53l41 74q119 10 219 76t169 171q-65 100-158 164l35 63q53-36 102-85t81-103q11-19 11-39z" horiz-adv-x="1000" /> +<glyph glyph-name="eye-off" unicode="" d="M310 112l43 79q-48 35-76 88t-27 114q0 67 34 125-128-65-213-197 94-144 239-209z m217 424q0 11-8 19t-19 7q-70 0-120-50t-50-119q0-11 8-19t19-8 19 8 8 19q0 48 34 82t82 34q11 0 19 8t8 19z m202 106q0-4 0-5-59-105-176-316t-176-316l-28-50q-5-9-15-9-7 0-75 39-9 6-9 16 0 7 25 49-80 36-147 96t-117 137q-11 17-11 38t11 39q86 131 212 207t277 76q50 0 100-10l31 54q5 9 15 9 3 0 10-3t18-9 18-10 18-10 10-7q9-5 9-15z m21-249q0-78-44-142t-117-91l157 280q4-25 4-47z m250-72q0-19-11-38-22-36-61-81-84-96-194-149t-234-53l41 74q119 10 219 76t169 171q-65 100-158 164l35 63q53-36 102-85t81-103q11-19 11-39z" horiz-adv-x="1000" /> -<glyph glyph-name="plus-squared" unicode="" d="M700 750q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-600 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l600 0z m-50-450l0 100-200 0 0 200-100 0 0-200-200 0 0-100 200 0 0-200 100 0 0 200 200 0z" horiz-adv-x="800" /> +<glyph glyph-name="retweet" unicode="" d="M714 18q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" /> -<glyph glyph-name="cog" unicode="" d="M571 350q0 59-41 101t-101 42-101-42-42-101 42-101 101-42 101 42 41 101z m286 61v-124q0-7-4-13t-11-7l-104-16q-10-30-21-51 19-27 59-77 6-6 6-13t-5-13q-15-21-55-61t-53-39q-7 0-14 5l-77 60q-25-13-51-21-9-76-16-104-4-16-20-16h-124q-8 0-14 5t-6 12l-16 103q-27 9-50 21l-79-60q-6-5-14-5-8 0-14 6-70 64-92 94-4 5-4 13 0 6 5 12 8 12 28 37t30 40q-15 28-23 55l-102 15q-7 1-11 7t-5 13v124q0 7 5 13t10 7l104 16q8 25 22 51-23 32-60 77-6 7-6 14 0 5 5 12 15 20 55 60t53 40q7 0 15-5l77-60q24 13 50 21 9 76 17 104 3 16 20 16h124q7 0 13-5t7-12l15-103q28-9 51-20l79 59q5 5 13 5 7 0 14-5 72-67 92-95 4-5 4-12 0-7-4-13-9-12-29-37t-30-40q15-28 23-54l102-16q7-1 12-7t4-13z" horiz-adv-x="857.1" /> +<glyph glyph-name="cog" unicode="" d="M571 357q0 59-41 101t-101 42-101-42-42-101 42-101 101-42 101 42 41 101z m286 61v-124q0-7-4-13t-11-7l-104-16q-10-30-21-51 19-27 59-77 6-6 6-13t-5-13q-15-21-55-61t-53-39q-7 0-14 5l-77 60q-25-13-51-21-9-76-16-104-4-16-20-16h-124q-8 0-14 5t-6 12l-16 103q-27 9-50 21l-79-60q-6-5-14-5-8 0-14 6-70 64-92 94-4 5-4 13 0 6 5 12 8 12 28 37t30 40q-15 28-23 55l-102 15q-7 1-11 7t-5 13v124q0 7 5 13t10 7l104 16q8 25 22 51-23 32-60 77-6 7-6 14 0 5 5 12 15 20 55 60t53 40q7 0 15-5l77-60q24 13 50 21 9 76 17 104 3 16 20 16h124q7 0 13-5t7-12l15-103q28-9 51-20l79 59q5 5 13 5 7 0 14-5 72-67 92-95 4-5 4-12 0-7-4-13-9-12-29-37t-30-40q15-28 23-54l102-16q7-1 12-7t4-13z" horiz-adv-x="857.1" /> -<glyph glyph-name="logout" unicode="" d="M357 46q0-2 1-11t0-14-2-14-5-11-12-3h-178q-67 0-114 47t-47 114v392q0 67 47 114t114 47h178q8 0 13-5t5-13q0-2 1-11t0-15-2-13-5-11-12-3h-178q-37 0-63-26t-27-64v-392q0-37 27-63t63-27h174t6 0 7-2 4-3 4-5 1-8z m518 304q0-14-11-25l-303-304q-11-10-25-10t-25 10-11 25v161h-250q-14 0-25 11t-11 25v214q0 15 11 25t25 11h250v161q0 14 11 25t25 10 25-10l303-304q11-10 11-25z" horiz-adv-x="928.6" /> +<glyph glyph-name="logout" unicode="" d="M357 53q0-2 1-11t0-14-2-14-5-10-12-4h-178q-67 0-114 47t-47 114v392q0 67 47 114t114 47h178q8 0 13-5t5-13q0-2 1-11t0-15-2-13-5-11-12-3h-178q-37 0-63-26t-27-64v-392q0-37 27-63t63-27h174t6 0 7-2 4-3 4-5 1-8z m518 304q0-14-11-25l-303-304q-11-10-25-10t-25 10-11 25v161h-250q-14 0-25 11t-11 25v214q0 15 11 25t25 11h250v161q0 14 11 25t25 10 25-10l303-304q11-10 11-25z" horiz-adv-x="928.6" /> -<glyph glyph-name="down-open" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" /> +<glyph glyph-name="down-open" unicode="" d="M939 406l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" /> -<glyph glyph-name="attach" unicode="" d="M244-140q-102 0-170 72-72 70-74 166t84 190l496 496q80 80 174 54 44-12 79-47t47-79q26-96-54-176l-474-474q-40-40-88-46-48-4-80 28-30 24-27 74t47 92l332 334q24 26 50 0t0-50l-332-332q-44-44-20-70 12-8 24-6 24 4 46 26l474 474q50 50 34 108-16 60-76 76-54 14-108-36l-494-494q-66-76-64-143t52-117q50-48 117-50t141 62l496 494q24 24 50 0 26-22 0-48l-496-496q-82-82-186-82z" horiz-adv-x="939" /> +<glyph glyph-name="attach" unicode="" d="M244-133q-102 0-170 72-72 70-74 166t84 190l496 496q80 80 174 54 44-12 79-47t47-79q26-96-54-176l-474-474q-40-40-88-46-48-4-80 28-30 24-27 74t47 92l332 334q24 26 50 0t0-50l-332-332q-44-44-20-70 12-8 24-6 24 4 46 26l474 474q50 50 34 108-16 60-76 76-54 14-108-36l-494-494q-66-76-64-143t52-117q50-48 117-50t141 62l496 494q24 24 50 0 26-22 0-48l-496-496q-82-82-186-82z" horiz-adv-x="939" /> -<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" /> +<glyph glyph-name="picture" unicode="" d="M357 536q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" /> -<glyph glyph-name="video" unicode="" d="M214-43v72q0 14-10 25t-25 10h-72q-14 0-25-10t-11-25v-72q0-14 11-25t25-11h72q14 0 25 11t10 25z m0 214v72q0 14-10 25t-25 11h-72q-14 0-25-11t-11-25v-72q0-14 11-25t25-10h72q14 0 25 10t10 25z m0 215v71q0 15-10 25t-25 11h-72q-14 0-25-11t-11-25v-71q0-15 11-25t25-11h72q14 0 25 11t10 25z m572-429v286q0 14-11 25t-25 11h-429q-14 0-25-11t-10-25v-286q0-14 10-25t25-11h429q15 0 25 11t11 25z m-572 643v71q0 15-10 26t-25 10h-72q-14 0-25-10t-11-26v-71q0-14 11-25t25-11h72q14 0 25 11t10 25z m786-643v72q0 14-11 25t-25 10h-71q-15 0-25-10t-11-25v-72q0-14 11-25t25-11h71q15 0 25 11t11 25z m-214 429v285q0 15-11 26t-25 10h-429q-14 0-25-10t-10-26v-285q0-15 10-25t25-11h429q15 0 25 11t11 25z m214-215v72q0 14-11 25t-25 11h-71q-15 0-25-11t-11-25v-72q0-14 11-25t25-10h71q15 0 25 10t11 25z m0 215v71q0 15-11 25t-25 11h-71q-15 0-25-11t-11-25v-71q0-15 11-25t25-11h71q15 0 25 11t11 25z m0 214v71q0 15-11 26t-25 10h-71q-15 0-25-10t-11-26v-71q0-14 11-25t25-11h71q15 0 25 11t11 25z m71 89v-750q0-37-26-63t-63-26h-893q-36 0-63 26t-26 63v750q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" /> +<glyph glyph-name="video" unicode="" d="M214-36v72q0 14-10 25t-25 10h-72q-14 0-25-10t-11-25v-72q0-14 11-25t25-11h72q14 0 25 11t10 25z m0 214v72q0 14-10 25t-25 11h-72q-14 0-25-11t-11-25v-72q0-14 11-25t25-10h72q14 0 25 10t10 25z m0 215v71q0 15-10 25t-25 11h-72q-14 0-25-11t-11-25v-71q0-15 11-25t25-11h72q14 0 25 11t10 25z m572-429v286q0 14-11 25t-25 11h-429q-14 0-25-11t-10-25v-286q0-14 10-25t25-11h429q15 0 25 11t11 25z m-572 643v71q0 15-10 26t-25 10h-72q-14 0-25-10t-11-26v-71q0-14 11-25t25-11h72q14 0 25 11t10 25z m786-643v72q0 14-11 25t-25 10h-71q-15 0-25-10t-11-25v-72q0-14 11-25t25-11h71q15 0 25 11t11 25z m-214 429v285q0 15-11 26t-25 10h-429q-14 0-25-10t-10-26v-285q0-15 10-25t25-11h429q15 0 25 11t11 25z m214-215v72q0 14-11 25t-25 11h-71q-15 0-25-11t-11-25v-72q0-14 11-25t25-10h71q15 0 25 10t11 25z m0 215v71q0 15-11 25t-25 11h-71q-15 0-25-11t-11-25v-71q0-15 11-25t25-11h71q15 0 25 11t11 25z m0 214v71q0 15-11 26t-25 10h-71q-15 0-25-10t-11-26v-71q0-14 11-25t25-11h71q15 0 25 11t11 25z m71 89v-750q0-37-26-63t-63-26h-893q-36 0-63 26t-26 63v750q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" /> -<glyph glyph-name="right-open" unicode="" d="M618 361l-414-415q-11-10-25-10t-25 10l-93 93q-11 11-11 25t11 25l296 297-296 296q-11 11-11 25t11 25l93 93q10 11 25 11t25-11l414-414q10-11 10-25t-10-25z" horiz-adv-x="714.3" /> +<glyph glyph-name="right-open" unicode="" d="M618 368l-414-415q-11-10-25-10t-25 10l-93 93q-11 11-11 25t11 25l296 297-296 296q-11 11-11 25t11 25l93 93q10 11 25 11t25-11l414-414q10-11 10-25t-10-25z" horiz-adv-x="714.3" /> -<glyph glyph-name="left-open" unicode="" d="M654 682l-297-296 297-297q10-10 10-25t-10-25l-93-93q-11-10-25-10t-25 10l-414 415q-11 10-11 25t11 25l414 414q10 11 25 11t25-11l93-93q10-10 10-25t-10-25z" horiz-adv-x="714.3" /> +<glyph glyph-name="left-open" unicode="" d="M654 689l-297-296 297-297q10-10 10-25t-10-25l-93-93q-11-10-25-10t-25 10l-414 415q-11 10-11 25t11 25l414 414q10 11 25 11t25-11l93-93q10-10 10-25t-10-25z" horiz-adv-x="714.3" /> -<glyph glyph-name="up-open" unicode="" d="M939 107l-92-92q-11-10-26-10t-25 10l-296 297-296-297q-11-10-25-10t-25 10l-93 92q-11 11-11 26t11 25l414 414q11 10 25 10t25-10l414-414q11-11 11-25t-11-26z" horiz-adv-x="1000" /> +<glyph glyph-name="up-open" unicode="" d="M939 114l-92-92q-11-10-26-10t-25 10l-296 297-296-297q-11-10-25-10t-25 10l-93 92q-11 11-11 26t11 25l414 414q11 10 25 10t25-10l414-414q11-11 11-25t-11-26z" horiz-adv-x="1000" /> -<glyph glyph-name="bell" unicode="" d="M509-96q0 8-9 8-33 0-57 24t-23 57q0 9-9 9t-9-9q0-41 29-70t69-28q9 0 9 9z m-372 160h726q-149 168-149 465 0 28-13 58t-39 58-67 45-95 17-95-17-67-45-39-58-13-58q0-297-149-465z m827 0q0-29-21-50t-50-21h-250q0-59-42-101t-101-42-101 42-42 101h-250q-29 0-50 21t-21 50q28 24 51 49t47 67 42 89 27 115 11 145q0 84 66 157t171 89q-5 10-5 21 0 23 16 38t38 16 38-16 16-38q0-11-5-21 106-16 171-89t66-157q0-78 11-145t28-115 41-89 48-67 50-49z" horiz-adv-x="1000" /> +<glyph glyph-name="bell" unicode="" d="M509-89q0 8-9 8-33 0-57 24t-23 57q0 9-9 9t-9-9q0-41 29-70t69-28q9 0 9 9z m-372 160h726q-149 168-149 465 0 28-13 58t-39 58-67 45-95 17-95-17-67-45-39-58-13-58q0-297-149-465z m827 0q0-29-21-50t-50-21h-250q0-59-42-101t-101-42-101 42-42 101h-250q-29 0-50 21t-21 50q28 24 51 49t47 67 42 89 27 115 11 145q0 84 66 157t171 89q-5 10-5 21 0 23 16 38t38 16 38-16 16-38q0-11-5-21 106-16 171-89t66-157q0-78 11-145t28-115 41-89 48-67 50-49z" horiz-adv-x="1000" /> -<glyph glyph-name="spin3" unicode="" d="M494 850c-266 0-483-210-494-472-1-19 13-20 13-20l84 0c16 0 19 10 19 18 10 199 176 358 378 358 107 0 205-45 273-118l-58-57c-11-12-11-27 5-31l247-50c21-5 46 11 37 44l-58 227c-2 9-16 22-29 13l-65-60c-89 91-214 148-352 148z m409-508c-16 0-19-10-19-18-10-199-176-358-377-358-108 0-205 45-274 118l59 57c10 12 10 27-5 31l-248 50c-21 5-46-11-37-44l58-227c2-9 16-22 30-13l64 60c89-91 214-148 353-148 265 0 482 210 493 473 1 18-13 19-13 19l-84 0z" horiz-adv-x="1000" /> +<glyph glyph-name="lock" unicode="" d="M179 428h285v108q0 59-42 101t-101 41-101-41-41-101v-108z m464-53v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h17v108q0 102 74 176t176 74 177-74 73-176v-108h18q23 0 38-15t16-38z" horiz-adv-x="642.9" /> -<glyph glyph-name="spin4" unicode="" d="M498 850c-114 0-228-39-320-116l0 0c173 140 428 130 588-31 134-134 164-332 89-495-10-29-5-50 12-68 21-20 61-23 84 0 3 3 12 15 15 24 71 180 33 393-112 539-99 98-228 147-356 147z m-409-274c-14 0-29-5-39-16-3-3-13-15-15-24-71-180-34-393 112-539 185-185 479-195 676-31l0 0c-173-140-428-130-589 31-134 134-163 333-89 495 11 29 6 50-12 68-11 11-27 17-44 16z" horiz-adv-x="1001" /> +<glyph glyph-name="globe" unicode="" d="M429 786q116 0 215-58t156-156 57-215-57-215-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58z m153-291q-2-1-6-5t-7-6q1 0 2 3t3 6 2 4q3 4 12 8 8 4 29 7 19 5 29-6-1 1 5 7t8 7q2 1 8 3t9 4l1 12q-7-1-10 4t-3 12q0-2-4-5 0 4-2 5t-7-1-5-1q-5 2-8 5t-5 9-2 8q-1 3-5 6t-5 6q-1 1-2 3t-1 4-3 3-3 1-4-3-4-5-2-3q-2 1-4 1t-2-1-3-1-3-2q-1-2-4-2t-5-1q8 3-1 6-5 2-9 2 6 2 5 6t-5 8h3q-1 2-5 5t-10 5-7 3q-5 3-19 5t-18 1q-3-4-3-6t2-8 2-7q1-3-3-7t-3-7q0-4 7-9t6-12q-2-4-9-9t-9-6q-3-5-1-11t6-9q1-1 1-2t-2-3-3-2-4-2l-1-1q-7-3-12 3t-7 15q-4 14-9 17-13 4-16-1-3 7-23 15-14 5-33 2 4 0 0 8-4 9-10 7 1 3 2 10t0 7q2 8 7 13 1 1 4 5t5 7 1 4q19-3 28 6 2 3 6 9t6 10q5 3 8 3t8-3 8-3q8-1 8 6t-4 11q7 0 2 10-2 4-5 5-6 2-15-3-4-2 2-4-1 0-6-6t-9-10-9 3q0 0-3 7t-5 8q-5 0-9-9 1 5-6 9t-14 4q11 7-4 15-4 3-12 3t-11-2q-2-4-3-7t3-4 6-3 6-2 5-2q8-6 5-8-1 0-5-2t-6-2-4-2q-1-3 0-8t-1-8q-3 3-5 10t-4 9q4-5-14-3l-5 0q-3 0-9-1t-12-1-7 5q-3 4 0 11 0 2 2 1-2 2-6 5t-6 5q-25-8-52-23 3 0 6 1 3 1 8 4t5 3q19 7 24 4l3 2q7-9 11-14-4 3-17 1-11-3-12-7 4-6 2-10-2 2-6 6t-8 6-8 3q-9 0-13-1-81-45-131-124 4-4 7-4 2-1 3-5t1-6 6 1q5-4 2-10 1 0 25-15 10-10 11-12 2-6-5-10-1 1-5 5t-5 2q-2-3 0-10t6-7q-4 0-5-9t-2-20 0-13l1-1q-2-6 3-19t12-11q-7-1 11-24 3-4 4-5 2-1 7-4t9-6 5-5q2-3 6-13t8-13q-2-3 5-11t6-13q-1 0-2-1t-1 0q2-4 9-8t8-7q1-2 1-6t2-6 4-1q2 11-13 35-8 13-9 16-2 2-4 8t-2 8q1 0 3 0t5-2 4-3 1-1q-1-4 1-10t7-10 10-11 6-7q4-4 8-11t0-8q5 0 11-5t10-11q3-5 4-15t3-13q1-4 5-8t7-5l9-5t7-3q3-2 10-6t12-7q6-2 9-2t8 1 8 2q8 1 16-8t12-12q20-10 30-6-1 0 1-4t4-9 5-8 3-5q3-3 10-8t10-8q4 2 4 5-1-5 4-11t10-6q8 2 8 18-17-8-27 10 0 0-2 3t-2 5-1 4 0 5 2 1q5 0 6 2t-1 7-2 8q-1 4-6 11t-7 8q-3-5-9-4t-9 5q0-1-1-3t-1-4q-7 0-8 0 1 2 1 10t2 13q1 2 3 6t5 9 2 7-3 5-9 1q-11 0-15-11-1-2-2-6t-2-6-5-4q-4-2-14-1t-13 3q-8 4-13 16t-5 20q0 6 1 15t2 14-3 14q2 1 5 5t5 6q2 1 3 1t3 0 2 1 1 3q0 1-2 2-1 1-2 1 4-1 16 1t15-1q9-6 12 1 0 1-1 6t0 7q3-15 16-5 2-1 9-3t9-2q2-1 4-3t3-3 3 0 5 4q5-8 7-13 6-23 10-25 4-2 6-1t3 5 0 8-1 7l-1 5v10l0 4q-8 2-10 7t0 10 9 10q0 1 4 2t9 4 7 4q12 11 8 20 4 0 6 5 0 0-2 2t-5 2-2 2q5 2 1 8 3 2 4 7t4 5q5-6 12-1 5 5 1 9 2 4 11 6t10 5q4-1 5 1t0 7 2 7q2 2 9 5t7 2l9 7q2 2 0 2 10-1 18 6 5 6-4 11 2 4-1 5t-9 4q2 0 7 0t5 1q9 5-3 9-10 2-24-7z m-91-490q115 21 195 106-1 2-7 2t-7 2q-10 4-13 5 1 4-1 7t-5 5-7 5-6 4q-1 1-4 3t-4 3-4 2-5 2-5-1l-2-1q-2 0-3-1t-3-2-2-1 0-2q-12 10-20 13-3 0-6 3t-6 4-6 0-6-3q-3-3-4-9t-1-7q-4 3 0 10t1 10q-1 3-6 2t-6-2-7-5-5-3-4-3-5-5q-2-2-4-6t-2-6q-1 2-7 3t-5 3q1-5 2-19t3-22q4-17-7-26-15-14-16-23-2-12 7-14 0-4-5-12t-4-12q0-3 2-9z" horiz-adv-x="857.1" /> -<glyph glyph-name="link-ext" unicode="" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" /> +<glyph glyph-name="brush" unicode="" d="M464 209q0-124-87-212t-210-87q-81 0-149 40 68 39 109 108t40 151q0 61 44 105t105 44 105-44 43-105z m415 562q32-32 32-79t-33-79l-318-318q-20 55-61 97t-97 62l318 318q32 32 79 32t80-33z" horiz-adv-x="928" /> -<glyph glyph-name="menu" unicode="" d="M857 100v-71q0-15-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 25t25 11h785q15 0 26-11t10-25z m0 286v-72q0-14-10-25t-26-10h-785q-15 0-25 10t-11 25v72q0 14 11 25t25 10h785q15 0 26-10t10-25z m0 285v-71q0-14-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 26t25 10h785q15 0 26-10t10-26z" horiz-adv-x="857.1" /> +<glyph glyph-name="attention" unicode="" d="M571 90v106q0 8-5 13t-12 5h-108q-7 0-12-5t-5-13v-106q0-8 5-13t12-6h108q7 0 12 6t5 13z m-1 208l10 257q0 6-5 10-7 6-14 6h-122q-6 0-14-6-5-4-5-12l9-255q0-5 6-9t13-3h103q8 0 14 3t5 9z m-7 522l428-786q20-35-1-70-9-17-26-26t-35-10h-858q-18 0-35 10t-26 26q-21 35-1 70l429 786q9 17 26 27t36 10 36-10 27-27z" horiz-adv-x="1000" /> -<glyph glyph-name="comment-empty" unicode="" d="M500 636q-114 0-213-39t-157-105-59-142q0-62 40-119t113-98l48-28-15-53q-13-51-39-97 85 36 154 96l24 21 32-3q38-5 72-5 114 0 213 39t157 105 59 142-59 142-157 105-213 39z m500-286q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12h-3q-8 0-15 6t-9 15v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 97 67 179t182 130 251 48 251-48 182-130 67-179z" horiz-adv-x="1000" /> +<glyph glyph-name="plus" unicode="" d="M786 446v-107q0-22-16-38t-38-15h-232v-233q0-22-16-37t-38-16h-107q-22 0-38 16t-15 37v233h-232q-23 0-38 15t-16 38v107q0 23 16 38t38 16h232v232q0 22 15 38t38 16h107q23 0 38-16t16-38v-232h232q23 0 38-16t16-38z" horiz-adv-x="785.7" /> -<glyph glyph-name="reply" unicode="" d="M1000 225q0-93-71-252-1-4-6-13t-7-17-7-12q-7-10-16-10-8 0-13 6t-5 14q0 5 1 15t2 13q3 38 3 69 0 56-10 101t-27 77-45 56-59 39-74 24-86 12-98 3h-125v-143q0-14-10-25t-26-11-25 11l-285 286q-11 10-11 25t11 25l285 286q11 10 25 10t26-10 10-25v-143h125q398 0 488-225 30-75 30-186z" horiz-adv-x="1000" /> +<glyph glyph-name="spin3" unicode="" d="M494 857c-266 0-483-210-494-472-1-19 13-20 13-20l84 0c16 0 19 10 19 18 10 199 176 358 378 358 107 0 205-45 273-118l-58-57c-11-12-11-27 5-31l247-50c21-5 46 11 37 44l-58 227c-2 9-16 22-29 13l-65-60c-89 91-214 148-352 148z m409-508c-16 0-19-10-19-18-10-199-176-358-377-358-108 0-205 45-274 118l59 57c10 12 10 27-5 31l-248 50c-21 5-46-11-37-44l58-227c2-9 16-22 30-13l64 60c89-91 214-148 353-148 265 0 482 210 493 473 1 18-13 19-13 19l-84 0z" horiz-adv-x="1000" /> -<glyph glyph-name="binoculars" unicode="" d="M393 671v-428q0-15-11-25t-25-11v-321q0-15-10-25t-26-11h-285q-15 0-25 11t-11 25v285l139 488q4 12 17 12h237z m178 0v-392h-142v392h142z m429-500v-285q0-15-11-25t-25-11h-285q-15 0-25 11t-11 25v321q-15 0-25 11t-11 25v428h237q13 0 17-12z m-589 661v-125h-197v125q0 8 5 13t13 5h161q8 0 13-5t5-13z m375 0v-125h-197v125q0 8 5 13t13 5h161q8 0 13-5t5-13z" horiz-adv-x="1000" /> +<glyph glyph-name="spin4" unicode="" d="M498 857c-114 0-228-39-320-116l0 0c173 140 428 130 588-31 134-134 164-332 89-495-10-29-5-50 12-68 21-20 61-23 84 0 3 3 12 15 15 24 71 180 33 393-112 539-99 98-228 147-356 147z m-409-274c-14 0-29-5-39-16-3-3-13-15-15-24-71-180-34-393 112-539 185-185 479-195 676-31l0 0c-173-140-428-130-589 31-134 134-163 333-89 495 11 29 6 50-12 68-11 11-27 17-44 16z" horiz-adv-x="1001" /> -<glyph glyph-name="user-plus" unicode="" d="M393 350q-89 0-152 63t-62 151 62 152 152 63 151-63 63-152-63-151-151-63z m536-71h196q7 0 13-6t5-12v-107q0-8-5-13t-13-5h-196v-197q0-7-6-12t-12-6h-107q-8 0-13 6t-5 12v197h-197q-7 0-12 5t-6 13v107q0 7 6 12t12 6h197v196q0 7 5 13t13 5h107q7 0 12-5t6-13v-196z m-411-125q0-29 21-51t50-21h143v-133q-38-28-95-28h-488q-67 0-108 39t-41 106q0 30 2 58t8 61 15 60 24 55 34 45 48 30 62 11q11 0 22-10 44-34 86-51t92-17 92 17 86 51q11 10 22 10 73 0 121-54h-125q-29 0-50-21t-21-50v-107z" horiz-adv-x="1142.9" /> +<glyph glyph-name="link-ext" unicode="" d="M786 339v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" /> + +<glyph glyph-name="link-ext-alt" unicode="" d="M714 339v268q0 15-10 25t-25 11h-268q-24 0-33-22-10-23 8-39l80-80-298-298q-11-11-11-26t11-25l57-57q11-10 25-10t25 10l298 298 81-80q10-11 25-11 6 0 14 3 21 10 21 33z m143 286v-536q0-66-47-113t-114-48h-535q-67 0-114 48t-47 113v536q0 66 47 113t114 48h535q67 0 114-48t47-113z" horiz-adv-x="857.1" /> + +<glyph glyph-name="menu" unicode="" d="M857 107v-71q0-15-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 25t25 11h785q15 0 26-11t10-25z m0 286v-72q0-14-10-25t-26-10h-785q-15 0-25 10t-11 25v72q0 14 11 25t25 10h785q15 0 26-10t10-25z m0 285v-71q0-14-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 26t25 10h785q15 0 26-10t10-26z" horiz-adv-x="857.1" /> + +<glyph glyph-name="mail-alt" unicode="" d="M1000 461v-443q0-37-26-63t-63-27h-822q-36 0-63 27t-26 63v443q25-27 56-49 202-137 278-192 32-24 51-37t53-27 61-13h2q28 0 61 13t53 27 51 37q95 68 278 192 32 22 56 49z m0 164q0-44-27-84t-68-69q-210-146-262-181-5-4-23-17t-30-22-29-18-32-15-28-5h-2q-12 0-27 5t-32 15-30 18-30 22-23 17q-51 35-147 101t-114 80q-35 23-65 64t-31 77q0 43 23 72t66 29h822q36 0 63-26t26-63z" horiz-adv-x="1000" /> + +<glyph glyph-name="comment-empty" unicode="" d="M500 643q-114 0-213-39t-157-105-59-142q0-62 40-119t113-98l48-28-15-53q-13-51-39-97 85 36 154 96l24 21 32-3q38-5 72-5 114 0 213 39t157 105 59 142-59 142-157 105-213 39z m500-286q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12h-3q-8 0-15 6t-9 15v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 97 67 179t182 130 251 48 251-48 182-130 67-179z" horiz-adv-x="1000" /> + +<glyph glyph-name="plus-squared" unicode="" d="M714 321v72q0 14-10 25t-25 10h-179v179q0 15-11 25t-25 11h-71q-15 0-25-11t-11-25v-179h-178q-15 0-25-10t-11-25v-72q0-14 11-25t25-10h178v-179q0-14 11-25t25-11h71q15 0 25 11t11 25v179h179q14 0 25 10t10 25z m143 304v-536q0-66-47-113t-114-48h-535q-67 0-114 48t-47 113v536q0 66 47 113t114 48h535q67 0 114-48t47-113z" horiz-adv-x="857.1" /> + +<glyph glyph-name="reply" unicode="" d="M1000 232q0-93-71-252-1-4-6-13t-7-17-7-12q-7-10-16-10-8 0-13 6t-5 14q0 5 1 15t2 13q3 38 3 69 0 56-10 101t-27 77-45 56-59 39-74 24-86 12-98 3h-125v-143q0-14-10-25t-26-11-25 11l-285 286q-11 10-11 25t11 25l285 286q11 10 25 10t26-10 10-25v-143h125q398 0 488-225 30-75 30-186z" horiz-adv-x="1000" /> + +<glyph glyph-name="lock-open-alt" unicode="" d="M589 428q23 0 38-15t16-38v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h17v179q0 103 74 177t176 73 177-73 73-177q0-14-10-25t-25-11h-36q-14 0-25 11t-11 25q0 59-42 101t-101 42-101-42-41-101v-179h410z" horiz-adv-x="642.9" /> + +<glyph glyph-name="thumbs-up-alt" unicode="" d="M143 107q0 15-11 25t-25 11q-15 0-25-11t-11-25q0-15 11-25t25-11q15 0 25 11t11 25z m89 286v-357q0-15-10-25t-26-11h-160q-15 0-25 11t-11 25v357q0 14 11 25t25 10h160q15 0 26-10t10-25z m661 0q0-48-31-83 9-25 9-43 1-42-24-76 9-31 0-66-9-31-31-52 5-62-27-101-36-43-110-44h-72q-37 0-80 9t-68 16-67 22q-69 24-88 25-15 0-25 11t-11 25v357q0 14 10 25t24 11q13 1 42 33t57 67q38 49 56 67 10 10 17 27t10 27 8 34q4 22 7 34t11 29 19 28q10 11 25 11 25 0 46-6t33-15 22-22 14-25 7-28 2-25 1-22q0-21-6-43t-10-33-16-31q-1-4-5-10t-6-13-5-13h155q43 0 75-32t32-75z" horiz-adv-x="928.6" /> + +<glyph glyph-name="binoculars" unicode="" d="M393 678v-428q0-15-11-25t-25-11v-321q0-15-10-25t-26-11h-285q-15 0-25 11t-11 25v285l139 488q4 12 17 12h237z m178 0v-392h-142v392h142z m429-500v-285q0-15-11-25t-25-11h-285q-15 0-25 11t-11 25v321q-15 0-25 11t-11 25v428h237q13 0 17-12z m-589 661v-125h-197v125q0 8 5 13t13 5h161q8 0 13-5t5-13z m375 0v-125h-197v125q0 8 5 13t13 5h161q8 0 13-5t5-13z" horiz-adv-x="1000" /> + +<glyph glyph-name="user-plus" unicode="" d="M393 357q-89 0-152 63t-62 151 62 152 152 63 151-63 63-152-63-151-151-63z m536-71h196q7 0 13-6t5-12v-107q0-8-5-13t-13-5h-196v-197q0-7-6-12t-12-6h-107q-8 0-13 6t-5 12v197h-197q-7 0-12 5t-6 13v107q0 7 6 12t12 6h197v196q0 7 5 13t13 5h107q7 0 12-5t6-13v-196z m-411-125q0-29 21-51t50-21h143v-133q-38-28-95-28h-488q-67 0-108 39t-41 106q0 30 2 58t8 61 15 60 24 55 34 45 48 30 62 11q11 0 22-10 44-34 86-51t92-17 92 17 86 51q11 10 22 10 73 0 121-54h-125q-29 0-50-21t-21-50v-107z" horiz-adv-x="1142.9" /> </font> </defs> </svg>
\ No newline at end of file diff --git a/static/font/font/fontello.ttf b/static/font/font/fontello.ttf Binary files differindex 3b08e96b..1fe7c631 100644 --- a/static/font/font/fontello.ttf +++ b/static/font/font/fontello.ttf diff --git a/static/font/font/fontello.woff b/static/font/font/fontello.woff Binary files differindex 167d132d..fc65e325 100644 --- a/static/font/font/fontello.woff +++ b/static/font/font/fontello.woff diff --git a/static/font/font/fontello.woff2 b/static/font/font/fontello.woff2 Binary files differindex 224e9b97..8513d894 100644 --- a/static/font/font/fontello.woff2 +++ b/static/font/font/fontello.woff2 diff --git a/static/styles.json b/static/styles.json index 7116ef20..00ad6ae1 100644 --- a/static/styles.json +++ b/static/styles.json @@ -5,5 +5,11 @@ "bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"], "ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ], "monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ], - "mammal": [ "Mammal", "#272c37", "#444b5d", "#f8f8f8", "#9bacc8", "#7f3142", "#2bd850", "#2b90d9", "#ca8f04" ] + "mammal": [ "Mammal", "#272c37", "#444b5d", "#f8f8f8", "#9bacc8", "#7f3142", "#2bd850", "#2b90d9", "#ca8f04" ], + + "redmond-xx": "/static/themes/redmond-xx.json", + "redmond-xx-se": "/static/themes/redmond-xx-se.json", + "redmond-xxi": "/static/themes/redmond-xxi.json", + "breezy-dark": "/static/themes/breezy-dark.json", + "breezy-light": "/static/themes/breezy-light.json" } diff --git a/static/themes/breezy-dark.json b/static/themes/breezy-dark.json new file mode 100644 index 00000000..6119bf88 --- /dev/null +++ b/static/themes/breezy-dark.json @@ -0,0 +1,139 @@ +{ + "_pleroma_theme_version": 2, + "name": "Breezy Dark (beta)", + "theme": { + "shadows": { + "panel": [ + { + "x": "1", + "y": "2", + "blur": "6", + "spread": 0, + "color": "#000000", + "alpha": 0.6 + } + ], + "button": [ + { + "x": 0, + "y": "0", + "blur": "0", + "spread": "1", + "color": "#ffffff", + "alpha": "0.15", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "1", + "spread": 0, + "color": "#000000", + "alpha": "0.3", + "inset": false + } + ], + "panelHeader": [ + { + "x": 0, + "y": "40", + "blur": "40", + "spread": "-40", + "inset": true, + "color": "#ffffff", + "alpha": "0.1" + } + ], + "buttonHover": [ + { + "x": 0, + "y": "0", + "blur": 0, + "spread": "1", + "color": "--link", + "alpha": "0.3", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "1", + "spread": 0, + "color": "#000000", + "alpha": "0.3", + "inset": false + } + ], + "buttonPressed": [ + { + "x": 0, + "y": 0, + "blur": "0", + "spread": "50", + "color": "--faint", + "alpha": 1, + "inset": true + }, + { + "x": 0, + "y": "0", + "blur": 0, + "spread": "1", + "color": "#ffffff", + "alpha": 0.2, + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": 0, + "spread": 0, + "color": "#000000", + "alpha": "0.3", + "inset": false + } + ], + "input": [ + { + "x": 0, + "y": "0", + "blur": 0, + "spread": "1", + "color": "#FFFFFF", + "alpha": "0.2", + "inset": true + } + ] + }, + "fonts": {}, + "opacity": { + "input": "1", + "panel": "0" + }, + "colors": { + "bg": "#31363b", + "text": "#eff0f1", + "link": "#3daee9", + "fg": "#31363b", + "panel": "#31363b", + "input": "#232629", + "topBarLink": "#eff0f1", + "btn": "#31363b", + "border": "#4c545b", + "cRed": "#da4453", + "cBlue": "#3daee9", + "cGreen": "#27ae60", + "cOrange": "#f67400" + }, + "radii": { + "btn": "2", + "input": "2", + "checkbox": "1", + "panel": "2", + "avatar": "2", + "avatarAlt": "2", + "tooltip": "2", + "attachment": "2" + } + } +} diff --git a/static/themes/breezy-light.json b/static/themes/breezy-light.json new file mode 100644 index 00000000..becf704f --- /dev/null +++ b/static/themes/breezy-light.json @@ -0,0 +1,139 @@ +{ + "_pleroma_theme_version": 2, + "name": "Breezy Light (beta)", + "theme": { + "shadows": { + "panel": [ + { + "x": "1", + "y": "2", + "blur": "6", + "spread": 0, + "color": "#000000", + "alpha": 0.6 + } + ], + "button": [ + { + "x": 0, + "y": "0", + "blur": "0", + "spread": "1", + "color": "#000000", + "alpha": "0.3", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "1", + "spread": 0, + "color": "#000000", + "alpha": "0.3", + "inset": false + } + ], + "panelHeader": [ + { + "x": 0, + "y": "40", + "blur": "40", + "spread": "-40", + "inset": true, + "color": "#ffffff", + "alpha": "0.1" + } + ], + "buttonHover": [ + { + "x": 0, + "y": "0", + "blur": 0, + "spread": "1", + "color": "--link", + "alpha": "0.3", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "1", + "spread": 0, + "color": "#000000", + "alpha": "0.3", + "inset": false + } + ], + "buttonPressed": [ + { + "x": 0, + "y": 0, + "blur": "0", + "spread": "50", + "color": "--faint", + "alpha": 1, + "inset": true + }, + { + "x": 0, + "y": "0", + "blur": 0, + "spread": "1", + "color": "#ffffff", + "alpha": 0.2, + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": 0, + "spread": 0, + "color": "#000000", + "alpha": "0.3", + "inset": false + } + ], + "input": [ + { + "x": 0, + "y": "0", + "blur": 0, + "spread": "1", + "color": "#000000", + "alpha": "0.2", + "inset": true + } + ] + }, + "fonts": {}, + "opacity": { + "input": "1" + }, + "colors": { + "bg": "#eff0f1", + "text": "#232627", + "link": "#2980b9", + "fg": "#bcc2c7", + "panel": "#475057", + "panelText": "#fcfcfc", + "input": "#fcfcfc", + "topBar": "#475057", + "topBarLink": "#eff0f1", + "btn": "#eff0f1", + "cRed": "#da4453", + "cBlue": "#2980b9", + "cGreen": "#27ae60", + "cOrange": "#f67400" + }, + "radii": { + "btn": "2", + "input": "2", + "checkbox": "1", + "panel": "2", + "avatar": "2", + "avatarAlt": "2", + "tooltip": "2", + "attachment": "2" + } + } +} diff --git a/static/themes/redmond-xx-se.json b/static/themes/redmond-xx-se.json new file mode 100644 index 00000000..70ee89d1 --- /dev/null +++ b/static/themes/redmond-xx-se.json @@ -0,0 +1,297 @@ +{ + "_pleroma_theme_version": 2, + "name": "Redmond XX SE", + "theme": { + "shadows": { + "panel": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "panelHeader": [ + { + "x": 0, + "y": 0, + "blur": 0, + "spread": "3", + "inset": true, + "color": "#c0c0c0", + "alpha": 1 + }, + { + "x": "-2200", + "y": 0, + "blur": "200", + "spread": "-2000", + "inset": true, + "color": "#1084d0", + "alpha": 1 + } + ], + "button": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "buttonHover": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "buttonPressed": [ + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "input": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--input", + "alpha": "1", + "inset": true + } + ] + }, + "fonts": {}, + "opacity": { + "input": "1", + "faint": "1" + }, + "colors": { + "bg": "#c0c0c0", + "text": "#000000", + "link": "#0000ff", + "fg": "#c0c0c0", + "panel": "#000080", + "panelFaint": "#c0c0c0", + "input": "#ffffff", + "topBar": "#000080", + "topBarLink": "#ffffff", + "btn": "#c0c0c0", + "faint": "#3f3f3f", + "faintLink": "#404080", + "border": "#808080", + "cRed": "#FF0000", + "cBlue": "#008080", + "cGreen": "#008000", + "cOrange": "#808000" + }, + "radii": { + "btn": "0", + "input": "0", + "checkbox": "0", + "panel": "0", + "avatar": "0", + "avatarAlt": "0", + "tooltip": "0", + "attachment": "0" + } + } +} diff --git a/static/themes/redmond-xx.json b/static/themes/redmond-xx.json new file mode 100644 index 00000000..4fd6a369 --- /dev/null +++ b/static/themes/redmond-xx.json @@ -0,0 +1,288 @@ +{ + "_pleroma_theme_version": 2, + "name": "Redmond XX", + "theme": { + "shadows": { + "panel": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "panelHeader": [ + { + "x": 0, + "y": 0, + "blur": 0, + "spread": "3", + "inset": true, + "color": "#c0c0c0", + "alpha": 1 + } + ], + "button": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "buttonHover": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "buttonPressed": [ + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "input": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#000000", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--input", + "alpha": "1", + "inset": true + } + ] + }, + "fonts": {}, + "opacity": { + "input": "1", + "faint": "1" + }, + "colors": { + "bg": "#c0c0c0", + "text": "#000000", + "link": "#0000ff", + "fg": "#c0c0c0", + "panel": "#000080", + "panelFaint": "#c0c0c0", + "input": "#ffffff", + "topBar": "#000080", + "topBarLink": "#ffffff", + "btn": "#c0c0c0", + "faint": "#3f3f3f", + "faintLink": "#404080", + "border": "#808080", + "cRed": "#FF0000", + "cBlue": "#008080", + "cGreen": "#008000", + "cOrange": "#808000" + }, + "radii": { + "btn": "0", + "input": "0", + "checkbox": "0", + "panel": "0", + "avatar": "0", + "avatarAlt": "0", + "tooltip": "0", + "attachment": "0" + } + } +} diff --git a/static/themes/redmond-xxi.json b/static/themes/redmond-xxi.json new file mode 100644 index 00000000..d10bf138 --- /dev/null +++ b/static/themes/redmond-xxi.json @@ -0,0 +1,270 @@ +{ + "_pleroma_theme_version": 2, + "name": "Redmond XXI", + "theme": { + "shadows": { + "panel": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#404040", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#dfdfdf", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "panelHeader": [ + { + "x": 0, + "y": 0, + "blur": 0, + "spread": "3", + "inset": true, + "color": "#d6d6ce", + "alpha": 1 + }, + { + "x": "-2200", + "y": 0, + "blur": "200", + "spread": "-2000", + "inset": true, + "color": "#a5cef7", + "alpha": 1 + } + ], + "button": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#404040", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "buttonHover": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#404040", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "buttonPressed": [ + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#404040", + "alpha": "1", + "inset": true + }, + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--bg", + "alpha": "1", + "inset": true + } + ], + "input": [ + { + "x": "-1", + "y": "-1", + "blur": "0", + "spread": 0, + "color": "#FFFFFF", + "alpha": "1", + "inset": true + }, + { + "x": "1", + "y": "1", + "blur": "0", + "spread": 0, + "color": "#848484", + "alpha": "1", + "inset": true + }, + { + "x": "-2", + "y": "-2", + "blur": "0", + "spread": 0, + "color": "#d4d0c8", + "alpha": "1", + "inset": true + }, + { + "x": "2", + "y": "2", + "blur": "0", + "spread": 0, + "color": "#404040", + "alpha": "1", + "inset": true + }, + { + "x": "0", + "y": "0", + "blur": "0", + "spread": "3", + "color": "--input", + "alpha": "1", + "inset": true + } + ] + }, + "fonts": {}, + "opacity": { + "input": "1", + "faint": "1" + }, + "colors": { + "bg": "#d6d6ce", + "text": "#000000", + "link": "#0000ff", + "fg": "#d6d6ce", + "panel": "#042967", + "panelFaint": "#FFFFFF", + "input": "#ffffff", + "topBar": "#042967", + "topBarLink": "#ffffff", + "btn": "#d6d6ce", + "faint": "#3f3f3f", + "faintLink": "#404080", + "border": "#808080", + "cRed": "#c42726", + "cBlue": "#6699cc", + "cGreen": "#669966", + "cOrange": "#cc6633" + }, + "radii": { + "btn": "0", + "input": "0", + "checkbox": "0", + "panel": "0", + "avatar": "0", + "avatarAlt": "0", + "tooltip": "0", + "attachment": "0" + } + } +} diff --git a/static/timeago-ca.json b/static/timeago-ca.json new file mode 100644 index 00000000..ef782caf --- /dev/null +++ b/static/timeago-ca.json @@ -0,0 +1,10 @@ +[ + "ara mateix", + ["fa %s s", "fa %s s"], + ["fa %s min", "fa %s min"], + ["fa %s h", "fa %s h"], + ["fa %s dia", "fa %s dies"], + ["fa %s setm.", "fa %s setm."], + ["fa %s mes", "fa %s mesos"], + ["fa %s any", "fa %s anys"] +] diff --git a/static/timeago-ga.json b/static/timeago-ga.json new file mode 100644 index 00000000..bdb7b6c4 --- /dev/null +++ b/static/timeago-ga.json @@ -0,0 +1,10 @@ +[ + "Anois", + ["%s s", "%s s"], + ["%s n", "%s nóimeád"], + ["%s u", "%s uair"], + ["%s l", "%s lá"], + ["%s se", "%s seachtaine"], + ["%s m", "%s mí"], + ["%s b", "%s bliainta"] +]
\ No newline at end of file diff --git a/static/timeago-oc.json b/static/timeago-oc.json new file mode 100644 index 00000000..a6b3932f --- /dev/null +++ b/static/timeago-oc.json @@ -0,0 +1,10 @@ +[ + "ara meteis", + ["fa %s s", "fa %s s"], + ["fa %s min", "fa %s min"], + ["fa %s h", "fa %s h"], + ["fa %s jorn", "fa %s jorns"], + ["fa %s setm.", "fa %s setm."], + ["fa %s mes", "fa %s meses"], + ["fa %s an", "fa %s ans"] +] diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js index 372d1aaa..7d403312 100644 --- a/test/unit/specs/modules/statuses.spec.js +++ b/test/unit/specs/modules/statuses.spec.js @@ -247,7 +247,7 @@ describe('The Statuses module', () => { in_reply_to_status_id: '1', // The API uses strings here... uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00', text: 'a favorited something by b', - user: {} + user: { id: 99 } } mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) @@ -264,7 +264,7 @@ describe('The Statuses module', () => { expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1) expect(state.timelines.public.maxId).to.eq(favorite.id) - // If something is favorited by the current user, it also sets the 'favorited' property + // If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request. const user = { id: 1 } @@ -281,45 +281,11 @@ describe('The Statuses module', () => { mutations.addNewStatuses(state, { statuses: [ownFavorite], showImmediately: true, timeline: 'public', user }) expect(state.timelines.public.visibleStatuses.length).to.eql(1) - expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(2) + expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1) expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true) }) describe('notifications', () => { - it('adds a notfications for retweets if you are the retweetet', () => { - const user = { id: 1 } - const state = cloneDeep(defaultState) - const status = makeMockStatus({id: 1}) - status.user = user - const retweet = makeMockStatus({id: 2, is_post_verb: false}) - retweet.retweeted_status = status - - mutations.addNewStatuses(state, { statuses: [retweet], user }) - - expect(state.notifications.length).to.eql(1) - expect(state.notifications[0].status).to.eql(status) - expect(state.notifications[0].action).to.eql(retweet) - expect(state.notifications[0].type).to.eql('repeat') - }) - - it('adds a notification when you are mentioned', () => { - const user = { id: 1 } - const state = cloneDeep(defaultState) - const status = makeMockStatus({id: 1}) - const mentionedStatus = makeMockStatus({id: 2}) - mentionedStatus.attentions = [user] - - mutations.addNewStatuses(state, { statuses: [status], user }) - - expect(state.notifications.length).to.eql(0) - - mutations.addNewStatuses(state, { statuses: [mentionedStatus], user }) - expect(state.notifications.length).to.eql(1) - expect(state.notifications[0].status).to.eql(mentionedStatus) - expect(state.notifications[0].action).to.eql(mentionedStatus) - expect(state.notifications[0].type).to.eql('mention') - }) - it('removes a notification when the notice gets removed', () => { const user = { id: 1 } const state = cloneDeep(defaultState) @@ -335,92 +301,39 @@ describe('The Statuses module', () => { deletion.uri = 'xxx' mutations.addNewStatuses(state, { statuses: [status, otherStatus], user }) - - expect(state.notifications.length).to.eql(1) + mutations.addNewNotifications( + state, + { + notifications: [{ + ntype: 'mention', + status: otherStatus, + notice: otherStatus, + is_seen: false + }] + }) + + expect(state.notifications.data.length).to.eql(1) + mutations.addNewNotifications( + state, + { + notifications: [{ + ntype: 'mention', + status: mentionedStatus, + notice: mentionedStatus, + is_seen: false + }] + }) mutations.addNewStatuses(state, { statuses: [mentionedStatus], user }) expect(state.allStatuses.length).to.eql(3) - expect(state.notifications.length).to.eql(2) - expect(state.notifications[1].status).to.eql(mentionedStatus) - expect(state.notifications[1].action).to.eql(mentionedStatus) - expect(state.notifications[1].type).to.eql('mention') + expect(state.notifications.data.length).to.eql(2) + expect(state.notifications.data[1].status).to.eql(mentionedStatus) + expect(state.notifications.data[1].action).to.eql(mentionedStatus) + expect(state.notifications.data[1].type).to.eql('mention') mutations.addNewStatuses(state, { statuses: [deletion], user }) expect(state.allStatuses.length).to.eql(2) - expect(state.notifications.length).to.eql(1) - }) - - it('adds the message to mentions when you are mentioned', () => { - const user = { id: 1 } - const state = cloneDeep(defaultState) - const status = makeMockStatus({id: 1}) - const mentionedStatus = makeMockStatus({id: 2}) - mentionedStatus.attentions = [user] - - mutations.addNewStatuses(state, { statuses: [status], user }) - - expect(state.timelines.mentions.statuses).to.have.length(0) - - mutations.addNewStatuses(state, { statuses: [mentionedStatus], user }) - expect(state.timelines.mentions.statuses).to.have.length(1) - expect(state.timelines.mentions.statuses).to.eql([mentionedStatus]) - }) - - it('adds a notfication when one of the user\'s status is favorited', () => { - const state = cloneDeep(defaultState) - const status = makeMockStatus({id: 1}) - const user = {id: 1} - status.user = user - - const favorite = { - id: 2, - is_post_verb: false, - in_reply_to_status_id: '1', // The API uses strings here... - uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00', - text: 'a favorited something by b', - user: {} - } - - mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public', user }) - mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public', user }) - - expect(state.notifications).to.have.length(1) - }) - - it('adds a notification when the user is followed', () => { - const state = cloneDeep(defaultState) - const user = {id: 1, screen_name: 'b'} - const follower = {id: 2, screen_name: 'a'} - - const follow = { - id: 3, - is_post_verb: false, - activity_type: 'follow', - text: 'a started following b', - user: follower - } - - mutations.addNewStatuses(state, { statuses: [follow], showImmediately: true, timeline: 'public', user }) - - expect(state.notifications).to.have.length(1) - }) - - it('does not add a notification when an other user is followed', () => { - const state = cloneDeep(defaultState) - const user = {id: 1, screen_name: 'b'} - const follower = {id: 2, screen_name: 'a'} - - const follow = { - id: 3, - is_post_verb: false, - activity_type: 'follow', - text: 'a started following b@shitposter.club', - user: follower - } - - mutations.addNewStatuses(state, { statuses: [follow], showImmediately: true, timeline: 'public', user }) - - expect(state.notifications).to.have.length(0) + expect(state.notifications.data.length).to.eql(1) }) }) }) @@ -434,6 +434,10 @@ babel-helper-replace-supers@^6.24.1: babel-traverse "^6.24.1" babel-types "^6.24.1" +babel-helper-vue-jsx-merge-props@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz#22aebd3b33902328e513293a8e4992b384f9f1b6" + babel-helpers@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2" @@ -500,6 +504,10 @@ babel-plugin-syntax-exponentiation-operator@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de" +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -516,7 +524,7 @@ babel-plugin-transform-async-generator-functions@^6.24.1: babel-plugin-syntax-async-generators "^6.5.0" babel-runtime "^6.22.0" -babel-plugin-transform-async-to-generator@^6.24.1: +babel-plugin-transform-async-to-generator@^6.22.0, babel-plugin-transform-async-to-generator@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761" dependencies: @@ -555,7 +563,7 @@ babel-plugin-transform-es2015-block-scoped-functions@^6.22.0: dependencies: babel-runtime "^6.22.0" -babel-plugin-transform-es2015-block-scoping@^6.24.1: +babel-plugin-transform-es2015-block-scoping@^6.23.0, babel-plugin-transform-es2015-block-scoping@^6.24.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz#d70f5299c1308d05c12f463813b0a09e73b1895f" dependencies: @@ -565,7 +573,7 @@ babel-plugin-transform-es2015-block-scoping@^6.24.1: babel-types "^6.26.0" lodash "^4.17.4" -babel-plugin-transform-es2015-classes@^6.24.1: +babel-plugin-transform-es2015-classes@^6.23.0, babel-plugin-transform-es2015-classes@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz#5a4c58a50c9c9461e564b4b2a3bfabc97a2584db" dependencies: @@ -579,33 +587,33 @@ babel-plugin-transform-es2015-classes@^6.24.1: babel-traverse "^6.24.1" babel-types "^6.24.1" -babel-plugin-transform-es2015-computed-properties@^6.24.1: +babel-plugin-transform-es2015-computed-properties@^6.22.0, babel-plugin-transform-es2015-computed-properties@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz#6fe2a8d16895d5634f4cd999b6d3480a308159b3" dependencies: babel-runtime "^6.22.0" babel-template "^6.24.1" -babel-plugin-transform-es2015-destructuring@^6.22.0: +babel-plugin-transform-es2015-destructuring@^6.22.0, babel-plugin-transform-es2015-destructuring@^6.23.0: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz#997bb1f1ab967f682d2b0876fe358d60e765c56d" dependencies: babel-runtime "^6.22.0" -babel-plugin-transform-es2015-duplicate-keys@^6.24.1: +babel-plugin-transform-es2015-duplicate-keys@^6.22.0, babel-plugin-transform-es2015-duplicate-keys@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz#73eb3d310ca969e3ef9ec91c53741a6f1576423e" dependencies: babel-runtime "^6.22.0" babel-types "^6.24.1" -babel-plugin-transform-es2015-for-of@^6.22.0: +babel-plugin-transform-es2015-for-of@^6.22.0, babel-plugin-transform-es2015-for-of@^6.23.0: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz#f47c95b2b613df1d3ecc2fdb7573623c75248691" dependencies: babel-runtime "^6.22.0" -babel-plugin-transform-es2015-function-name@^6.24.1: +babel-plugin-transform-es2015-function-name@^6.22.0, babel-plugin-transform-es2015-function-name@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz#834c89853bc36b1af0f3a4c5dbaa94fd8eacaa8b" dependencies: @@ -619,7 +627,7 @@ babel-plugin-transform-es2015-literals@^6.22.0: dependencies: babel-runtime "^6.22.0" -babel-plugin-transform-es2015-modules-amd@^6.24.1: +babel-plugin-transform-es2015-modules-amd@^6.22.0, babel-plugin-transform-es2015-modules-amd@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz#3b3e54017239842d6d19c3011c4bd2f00a00d154" dependencies: @@ -627,6 +635,15 @@ babel-plugin-transform-es2015-modules-amd@^6.24.1: babel-runtime "^6.22.0" babel-template "^6.24.1" +babel-plugin-transform-es2015-modules-commonjs@^6.23.0: + version "6.26.2" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz#58a793863a9e7ca870bdc5a881117ffac27db6f3" + dependencies: + babel-plugin-transform-strict-mode "^6.24.1" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-types "^6.26.0" + babel-plugin-transform-es2015-modules-commonjs@^6.24.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz#0d8394029b7dc6abe1a97ef181e00758dd2e5d8a" @@ -636,7 +653,7 @@ babel-plugin-transform-es2015-modules-commonjs@^6.24.1: babel-template "^6.26.0" babel-types "^6.26.0" -babel-plugin-transform-es2015-modules-systemjs@^6.24.1: +babel-plugin-transform-es2015-modules-systemjs@^6.23.0, babel-plugin-transform-es2015-modules-systemjs@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz#ff89a142b9119a906195f5f106ecf305d9407d23" dependencies: @@ -644,7 +661,7 @@ babel-plugin-transform-es2015-modules-systemjs@^6.24.1: babel-runtime "^6.22.0" babel-template "^6.24.1" -babel-plugin-transform-es2015-modules-umd@^6.24.1: +babel-plugin-transform-es2015-modules-umd@^6.23.0, babel-plugin-transform-es2015-modules-umd@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz#ac997e6285cd18ed6176adb607d602344ad38468" dependencies: @@ -652,14 +669,14 @@ babel-plugin-transform-es2015-modules-umd@^6.24.1: babel-runtime "^6.22.0" babel-template "^6.24.1" -babel-plugin-transform-es2015-object-super@^6.24.1: +babel-plugin-transform-es2015-object-super@^6.22.0, babel-plugin-transform-es2015-object-super@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz#24cef69ae21cb83a7f8603dad021f572eb278f8d" dependencies: babel-helper-replace-supers "^6.24.1" babel-runtime "^6.22.0" -babel-plugin-transform-es2015-parameters@^6.24.1: +babel-plugin-transform-es2015-parameters@^6.23.0, babel-plugin-transform-es2015-parameters@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz#57ac351ab49caf14a97cd13b09f66fdf0a625f2b" dependencies: @@ -670,7 +687,7 @@ babel-plugin-transform-es2015-parameters@^6.24.1: babel-traverse "^6.24.1" babel-types "^6.24.1" -babel-plugin-transform-es2015-shorthand-properties@^6.24.1: +babel-plugin-transform-es2015-shorthand-properties@^6.22.0, babel-plugin-transform-es2015-shorthand-properties@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz#24f875d6721c87661bbd99a4622e51f14de38aa0" dependencies: @@ -683,7 +700,7 @@ babel-plugin-transform-es2015-spread@^6.22.0: dependencies: babel-runtime "^6.22.0" -babel-plugin-transform-es2015-sticky-regex@^6.24.1: +babel-plugin-transform-es2015-sticky-regex@^6.22.0, babel-plugin-transform-es2015-sticky-regex@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz#00c1cdb1aca71112cdf0cf6126c2ed6b457ccdbc" dependencies: @@ -697,13 +714,13 @@ babel-plugin-transform-es2015-template-literals@^6.22.0: dependencies: babel-runtime "^6.22.0" -babel-plugin-transform-es2015-typeof-symbol@^6.22.0: +babel-plugin-transform-es2015-typeof-symbol@^6.22.0, babel-plugin-transform-es2015-typeof-symbol@^6.23.0: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz#dec09f1cddff94b52ac73d505c84df59dcceb372" dependencies: babel-runtime "^6.22.0" -babel-plugin-transform-es2015-unicode-regex@^6.24.1: +babel-plugin-transform-es2015-unicode-regex@^6.22.0, babel-plugin-transform-es2015-unicode-regex@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz#d38b12f42ea7323f729387f18a7c5ae1faeb35e9" dependencies: @@ -711,7 +728,7 @@ babel-plugin-transform-es2015-unicode-regex@^6.24.1: babel-runtime "^6.22.0" regexpu-core "^2.0.0" -babel-plugin-transform-exponentiation-operator@^6.24.1: +babel-plugin-transform-exponentiation-operator@^6.22.0, babel-plugin-transform-exponentiation-operator@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e" dependencies: @@ -726,7 +743,7 @@ babel-plugin-transform-object-rest-spread@^6.22.0: babel-plugin-syntax-object-rest-spread "^6.8.0" babel-runtime "^6.26.0" -babel-plugin-transform-regenerator@^6.24.1: +babel-plugin-transform-regenerator@^6.22.0, babel-plugin-transform-regenerator@^6.24.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f" dependencies: @@ -745,6 +762,47 @@ babel-plugin-transform-strict-mode@^6.24.1: babel-runtime "^6.22.0" babel-types "^6.24.1" +babel-plugin-transform-vue-jsx@3: + version "3.7.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-3.7.0.tgz#d40492e6692a36b594f7e9a1928f43e969740960" + dependencies: + esutils "^2.0.2" + +babel-preset-env@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.7.0.tgz#dea79fa4ebeb883cd35dab07e260c1c9c04df77a" + dependencies: + babel-plugin-check-es2015-constants "^6.22.0" + babel-plugin-syntax-trailing-function-commas "^6.22.0" + babel-plugin-transform-async-to-generator "^6.22.0" + babel-plugin-transform-es2015-arrow-functions "^6.22.0" + babel-plugin-transform-es2015-block-scoped-functions "^6.22.0" + babel-plugin-transform-es2015-block-scoping "^6.23.0" + babel-plugin-transform-es2015-classes "^6.23.0" + babel-plugin-transform-es2015-computed-properties "^6.22.0" + babel-plugin-transform-es2015-destructuring "^6.23.0" + babel-plugin-transform-es2015-duplicate-keys "^6.22.0" + babel-plugin-transform-es2015-for-of "^6.23.0" + babel-plugin-transform-es2015-function-name "^6.22.0" + babel-plugin-transform-es2015-literals "^6.22.0" + babel-plugin-transform-es2015-modules-amd "^6.22.0" + babel-plugin-transform-es2015-modules-commonjs "^6.23.0" + babel-plugin-transform-es2015-modules-systemjs "^6.23.0" + babel-plugin-transform-es2015-modules-umd "^6.23.0" + babel-plugin-transform-es2015-object-super "^6.22.0" + babel-plugin-transform-es2015-parameters "^6.23.0" + babel-plugin-transform-es2015-shorthand-properties "^6.22.0" + babel-plugin-transform-es2015-spread "^6.22.0" + babel-plugin-transform-es2015-sticky-regex "^6.22.0" + babel-plugin-transform-es2015-template-literals "^6.22.0" + babel-plugin-transform-es2015-typeof-symbol "^6.23.0" + babel-plugin-transform-es2015-unicode-regex "^6.22.0" + babel-plugin-transform-exponentiation-operator "^6.22.0" + babel-plugin-transform-regenerator "^6.22.0" + browserslist "^3.2.6" + invariant "^2.2.2" + semver "^5.3.0" + babel-preset-es2015@^6.0.0: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz#d44050d6bc2c9feea702aaf38d727a0210538939" @@ -996,6 +1054,13 @@ browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7.6: caniuse-db "^1.0.30000639" electron-to-chromium "^1.2.7" +browserslist@^3.2.6: + version "3.2.8" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-3.2.8.tgz#b0005361d6471f0f5952797a76fc985f1f978fc6" + dependencies: + caniuse-lite "^1.0.30000844" + electron-to-chromium "^1.3.47" + buffer@^4.9.0: version "4.9.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" @@ -1069,6 +1134,10 @@ caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639: version "1.0.30000801" resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000801.tgz#a1d49def94c4e5aca5ccf1d58812e4668fac19d4" +caniuse-lite@^1.0.30000844: + version "1.0.30000878" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000878.tgz#c644c39588dd42d3498e952234c372e5a40a4123" + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -1136,6 +1205,10 @@ chokidar@^1.0.0, chokidar@^1.4.1: optionalDependencies: fsevents "^1.0.0" +chromatism@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chromatism/-/chromatism-3.0.0.tgz#a7249d353c1e4f3577e444ac41171c4e2e624b12" + chromedriver@^2.21.2: version "2.35.0" resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-2.35.0.tgz#c103ba2fb3d1671f666058159f5cbaa816902e4d" @@ -1789,6 +1862,10 @@ electron-to-chromium@^1.2.7: version "1.3.32" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.32.tgz#11d0684c0840e003c4be8928f8ac5f35dbc2b4e6" +electron-to-chromium@^1.3.47: + version "1.3.61" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.61.tgz#a8ac295b28d0f03d85e37326fd16b6b6b17a1795" + emojis-list@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" @@ -3081,6 +3158,10 @@ isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" +iso-639-1@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/iso-639-1/-/iso-639-1-2.0.3.tgz#72dd3448ac5629c271628c5ac566369428d6ccd0" + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -3844,7 +3925,7 @@ mime@^1.3.4, mime@^1.5.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" -"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2: +"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" dependencies: @@ -5208,6 +5289,12 @@ serve-static@1.13.1: parseurl "~1.3.2" send "0.16.1" +serviceworker-webpack-plugin@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/serviceworker-webpack-plugin/-/serviceworker-webpack-plugin-0.2.3.tgz#1873ed6fc83c873ac8240fac443c615d374feeb2" + dependencies: + minimatch "^3.0.3" + set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -5897,6 +5984,10 @@ vue@^2.1.10, vue@^2.5.13: version "2.5.13" resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.13.tgz#95bd31e20efcf7a7f39239c9aa6787ce8cf578e1" +vuelidate@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/vuelidate/-/vuelidate-0.7.4.tgz#5a0e54be09ac0192f1aa3387d74b92e0945bf8aa" + vuex@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.0.1.tgz#e761352ebe0af537d4bb755a9b9dc4be3df7efd2" |
