forked from AkkomaGang/akkoma-fe
Merge branch 'develop' into 'master'
`master` refresh with `develop` See merge request pleroma/pleroma-fe!1028
This commit is contained in:
commit
3ab128e739
333 changed files with 25194 additions and 8126 deletions
4
.babelrc
4
.babelrc
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"presets": ["es2015", "stage-2", "env"],
|
||||
"plugins": ["transform-runtime", "lodash", "transform-vue-jsx"],
|
||||
"presets": ["@babel/preset-env"],
|
||||
"plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-transform-vue-jsx"],
|
||||
"comments": false
|
||||
}
|
||||
|
|
12
.eslintrc.js
12
.eslintrc.js
|
@ -1,14 +1,17 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: 'babel-eslint',
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
sourceType: 'module'
|
||||
},
|
||||
// https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
|
||||
extends: 'standard',
|
||||
extends: [
|
||||
'standard',
|
||||
'plugin:vue/recommended'
|
||||
],
|
||||
// required to lint *.vue files
|
||||
plugins: [
|
||||
'html'
|
||||
'vue'
|
||||
],
|
||||
// add your custom rules here
|
||||
rules: {
|
||||
|
@ -17,6 +20,7 @@ module.exports = {
|
|||
// allow async-await
|
||||
'generator-star-spacing': 0,
|
||||
// allow debugger during development
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
|
||||
'vue/require-prop-types': 0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
# This file is a template, and might need editing before it works on your project.
|
||||
# Official framework image. Look for the different tagged releases at:
|
||||
# https://hub.docker.com/r/library/node/tags/
|
||||
image: node:7
|
||||
image: node:8
|
||||
|
||||
stages:
|
||||
- lint
|
||||
- build
|
||||
- test
|
||||
- deploy
|
||||
|
||||
lint:
|
||||
stage: lint
|
||||
|
@ -16,9 +17,14 @@ lint:
|
|||
|
||||
test:
|
||||
stage: test
|
||||
variables:
|
||||
APT_CACHE_DIR: apt-cache
|
||||
script:
|
||||
- mkdir -pv $APT_CACHE_DIR && apt-get -qq update
|
||||
- apt install firefox-esr -y --no-install-recommends
|
||||
- firefox --version
|
||||
- yarn
|
||||
- npm run unit
|
||||
- yarn unit
|
||||
|
||||
build:
|
||||
stage: build
|
||||
|
@ -28,3 +34,13 @@ build:
|
|||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
|
||||
docs-deploy:
|
||||
stage: deploy
|
||||
image: alpine:latest
|
||||
only:
|
||||
- develop@pleroma/pleroma-fe
|
||||
before_script:
|
||||
- apk add curl
|
||||
script:
|
||||
- curl -X POST -F"token=$DOCS_PIPELINE_TRIGGER" -F'ref=master' https://git.pleroma.social/api/v4/projects/673/trigger/pipeline
|
||||
|
|
13
BREAKING_CHANGES.md
Normal file
13
BREAKING_CHANGES.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
# v1.0
|
||||
## Removed features/radically changed behavior
|
||||
### formattingOptionsEnabled
|
||||
as of !833 `formattingOptionsEnabled` is no longer available and instead FE check for available post formatting options and enables formatting control if there's more than one option.
|
||||
|
||||
### minimalScopesMode
|
||||
As of !633, `scopeOptions` is no longer available and instead is changed for `minimalScopesMode` (default: `false`)
|
||||
|
||||
Reasoning is that scopeOptions option originally existed mostly as a backwards-compatibility with GNU Social which only had `public` scope available and using scope selector would''t work. Since at some point we dropped GNU Social support, this option was mostly a nuisance (being default `false`'), however some people think scopes are an annoyance to a certain degree and want as less of that feature as possible.
|
||||
|
||||
Solution - to only show minimal set among: *Direct*, *User default* and *Scope of post replying to*. This also makes it impossible to reply to a DM with a non-DM post from UI.
|
||||
|
||||
*This setting is admin-default, user-configurable. Admin can choose different default for their instance but user can override it.*
|
35
CHANGELOG
35
CHANGELOG
|
@ -1,35 +0,0 @@
|
|||
## 2017-02-20
|
||||
|
||||
- Overall CSS styling fixes
|
||||
- Current theme is displayed in theme selector
|
||||
- Theme selector is moved to the settings page
|
||||
- Oembed attachments will now display correctly
|
||||
- Styling changes to the user info cards
|
||||
- Notification count in title
|
||||
- Better Notification handling (persistance, mark as read)
|
||||
- Post statuses with ctrl+enter
|
||||
- Links in statuses open in a new tab
|
||||
- Optimized mobile view
|
||||
- Fix crash on persistance failure
|
||||
- Compress persisted state
|
||||
- Sync mutes with backend (SEE NOTE BELOW)
|
||||
|
||||
Pleroma will now try to get the current mutes from the backend. Sadly, a bug in
|
||||
Qvitter will not allow getting the mutes from the endpoint, because it will
|
||||
ignore HTTP Basic authentication. Mutes will still persist in Pleroma through
|
||||
localstorage, but the mutes from Qvitter won't be picked up if the call fails.
|
||||
|
||||
The patch for Qvitter:
|
||||
|
||||
--- a/actions/apiqvittermutes.php
|
||||
+++ b/actions/apiqvittermutes.php
|
||||
@@ -74,7 +74,7 @@ class ApiQvitterMutesAction extends ApiPrivateAuthAction
|
||||
{
|
||||
parent::handle();
|
||||
|
||||
- $this->target = Profile::current();
|
||||
+ $this->target = $this->scoped;
|
||||
|
||||
if(!$this->target instanceof Profile) {
|
||||
$this->clientError(_('You have to be logged in to view your mutes.'), 403);
|
||||
|
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
|
@ -0,0 +1,33 @@
|
|||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Icons in nav panel
|
||||
- Private mode support
|
||||
- Support for 'Move' type notifications
|
||||
- Pleroma AMOLED dark theme
|
||||
### Changed
|
||||
- Captcha now resets on failed registrations
|
||||
- Notifications column now cleans itself up to optimize performance when tab is left open for a long time
|
||||
- 403 messaging
|
||||
### Fixed
|
||||
- Single notifications left unread when hitting read on another device/tab
|
||||
- Registration fixed
|
||||
- Deactivation of remote accounts from frontend
|
||||
|
||||
## [1.1.7 and earlier] - 2019-12-14
|
||||
### Added
|
||||
- Ability to hide/show repeats from user
|
||||
- User profile button clutter organized into a menu
|
||||
- Emoji picker
|
||||
- Started changelog anew
|
||||
- Ability to change user's email
|
||||
- About page
|
||||
- Added remote user redirect
|
||||
### Changed
|
||||
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
|
||||
### Fixed
|
||||
- improved hotkey behavior on autocomplete popup
|
|
@ -41,7 +41,7 @@ FE Build process also leaves current commit hash in global variable `___pleromaf
|
|||
|
||||
# Configuration
|
||||
|
||||
Edit config.json for configuration. scopeOptionsEnabled gives you input fields for CWs and the scope settings.
|
||||
Edit config.json for configuration.
|
||||
|
||||
## Options
|
||||
|
||||
|
|
|
@ -31,8 +31,13 @@ var hotMiddleware = require('webpack-hot-middleware')(compiler)
|
|||
// force page reload when html-webpack-plugin template changes
|
||||
compiler.plugin('compilation', function (compilation) {
|
||||
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
|
||||
hotMiddleware.publish({ action: 'reload' })
|
||||
cb()
|
||||
// FIXME: This supposed to reload whole page when index.html is changed,
|
||||
// however now it reloads entire page on every breath, i suppose the order
|
||||
// of plugins changed or something. It's a minor thing and douesn't hurt
|
||||
// disabling it, constant reloads hurt much more
|
||||
|
||||
// hotMiddleware.publish({ action: 'reload' })
|
||||
// cb()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -1,61 +1,63 @@
|
|||
var path = require('path')
|
||||
var config = require('../config')
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin')
|
||||
var sass = require('sass')
|
||||
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
|
||||
exports.assetsPath = function (_path) {
|
||||
var assetsSubDirectory = process.env.NODE_ENV === 'production'
|
||||
? config.build.assetsSubDirectory
|
||||
: config.dev.assetsSubDirectory
|
||||
? config.build.assetsSubDirectory
|
||||
: config.dev.assetsSubDirectory
|
||||
return path.posix.join(assetsSubDirectory, _path)
|
||||
}
|
||||
|
||||
exports.cssLoaders = function (options) {
|
||||
options = options || {}
|
||||
// generate loader string to be used with extract text plugin
|
||||
function generateLoaders (loaders) {
|
||||
var sourceLoader = loaders.map(function (loader) {
|
||||
var extraParamChar
|
||||
if (/\?/.test(loader)) {
|
||||
loader = loader.replace(/\?/, '-loader?')
|
||||
extraParamChar = '&'
|
||||
} else {
|
||||
loader = loader + '-loader'
|
||||
extraParamChar = '?'
|
||||
}
|
||||
return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '')
|
||||
}).join('!')
|
||||
|
||||
function generateLoaders (loaders) {
|
||||
// Extract CSS when that option is specified
|
||||
// (which is the case during production build)
|
||||
if (options.extract) {
|
||||
return ExtractTextPlugin.extract('vue-style-loader', sourceLoader)
|
||||
return [MiniCssExtractPlugin.loader].concat(loaders)
|
||||
} else {
|
||||
return ['vue-style-loader', sourceLoader].join('!')
|
||||
return ['vue-style-loader'].concat(loaders)
|
||||
}
|
||||
}
|
||||
|
||||
// http://vuejs.github.io/vue-loader/configurations/extract-css.html
|
||||
return {
|
||||
css: generateLoaders(['css']),
|
||||
postcss: generateLoaders(['css']),
|
||||
less: generateLoaders(['css', 'less']),
|
||||
sass: generateLoaders(['css', 'sass?indentedSyntax']),
|
||||
scss: generateLoaders(['css', 'sass']),
|
||||
stylus: generateLoaders(['css', 'stylus']),
|
||||
styl: generateLoaders(['css', 'stylus'])
|
||||
}
|
||||
return [
|
||||
{
|
||||
test: /\.(post)?css$/,
|
||||
use: generateLoaders(['css-loader', 'postcss-loader']),
|
||||
},
|
||||
{
|
||||
test: /\.less$/,
|
||||
use: generateLoaders(['css-loader', 'postcss-loader', 'less-loader']),
|
||||
},
|
||||
{
|
||||
test: /\.sass$/,
|
||||
use: generateLoaders([
|
||||
'css-loader',
|
||||
'postcss-loader',
|
||||
{
|
||||
loader: 'sass-loader',
|
||||
options: {
|
||||
indentedSyntax: true
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: generateLoaders(['css-loader', 'postcss-loader', 'sass-loader'])
|
||||
},
|
||||
{
|
||||
test: /\.styl(us)?$/,
|
||||
use: generateLoaders(['css-loader', 'postcss-loader', 'stylus-loader']),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Generate loaders for standalone style files (outside of .vue)
|
||||
exports.styleLoaders = function (options) {
|
||||
var output = []
|
||||
var loaders = exports.cssLoaders(options)
|
||||
for (var extension in loaders) {
|
||||
var loader = loaders[extension]
|
||||
output.push({
|
||||
test: new RegExp('\\.' + extension + '$'),
|
||||
loader: loader
|
||||
})
|
||||
}
|
||||
return output
|
||||
return exports.cssLoaders(options)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ var config = require('../config')
|
|||
var utils = require('./utils')
|
||||
var projectRoot = path.resolve(__dirname, '../')
|
||||
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
|
||||
var FontelloPlugin = require("fontello-webpack-plugin")
|
||||
|
||||
var env = process.env.NODE_ENV
|
||||
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the
|
||||
|
@ -11,6 +12,8 @@ var cssSourceMapDev = (env === 'development' && config.dev.cssSourceMap)
|
|||
var cssSourceMapProd = (env === 'production' && config.build.productionSourceMap)
|
||||
var useCssSourceMap = cssSourceMapDev || cssSourceMapProd
|
||||
|
||||
var now = Date.now()
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: './src/main.js'
|
||||
|
@ -20,9 +23,16 @@ module.exports = {
|
|||
publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
|
||||
filename: '[name].js'
|
||||
},
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all'
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['', '.js', '.vue'],
|
||||
fallback: [path.join(__dirname, '../node_modules')],
|
||||
extensions: ['.js', '.vue'],
|
||||
modules: [
|
||||
path.join(__dirname, '../node_modules')
|
||||
],
|
||||
alias: {
|
||||
'vue$': 'vue/dist/vue.runtime.common',
|
||||
'src': path.resolve(__dirname, '../src'),
|
||||
|
@ -30,73 +40,67 @@ module.exports = {
|
|||
'components': path.resolve(__dirname, '../src/components')
|
||||
}
|
||||
},
|
||||
resolveLoader: {
|
||||
fallback: [path.join(__dirname, '../node_modules')]
|
||||
},
|
||||
module: {
|
||||
noParse: /node_modules\/localforage\/dist\/localforage.js/,
|
||||
preLoaders: [
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'eslint',
|
||||
enforce: 'pre',
|
||||
test: /\.(js|vue)$/,
|
||||
include: projectRoot,
|
||||
exclude: /node_modules/
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'eslint-loader',
|
||||
options: {
|
||||
formatter: require('eslint-friendly-formatter'),
|
||||
sourceMap: config.build.productionSourceMap,
|
||||
extract: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'eslint',
|
||||
include: projectRoot,
|
||||
exclude: /node_modules/
|
||||
}
|
||||
],
|
||||
loaders: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'vue'
|
||||
use: 'vue-loader'
|
||||
},
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
loader: 'babel',
|
||||
include: projectRoot,
|
||||
exclude: /node_modules\/(?!tributejs)/
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
loader: 'json'
|
||||
exclude: /node_modules\/(?!tributejs)/,
|
||||
use: 'babel-loader'
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
|
||||
loader: 'url',
|
||||
query: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('img/[name].[hash:7].[ext]')
|
||||
use: {
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('img/[name].[hash:7].[ext]')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
|
||||
loader: 'url',
|
||||
query: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
|
||||
use: {
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
eslint: {
|
||||
formatter: require('eslint-friendly-formatter')
|
||||
},
|
||||
vue: {
|
||||
loaders: utils.cssLoaders({ sourceMap: useCssSourceMap }),
|
||||
postcss: [
|
||||
require('autoprefixer')({
|
||||
browsers: ['last 2 versions']
|
||||
})
|
||||
},
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new ServiceWorkerWebpackPlugin({
|
||||
entry: path.join(__dirname, '..', 'src/sw.js'),
|
||||
filename: 'sw-pleroma.js'
|
||||
}),
|
||||
new FontelloPlugin({
|
||||
config: require('../static/fontello.json'),
|
||||
name: 'fontello',
|
||||
output: {
|
||||
css: 'static/[name].' + now + '.css', // [hash] is not supported. Use the current timestamp instead for versioning.
|
||||
font: 'static/font/[name].' + now + '.[ext]'
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
@ -12,8 +12,9 @@ Object.keys(baseWebpackConfig.entry).forEach(function (name) {
|
|||
|
||||
module.exports = merge(baseWebpackConfig, {
|
||||
module: {
|
||||
loaders: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
|
||||
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
|
||||
},
|
||||
mode: 'development',
|
||||
// eval-source-map is faster for development
|
||||
devtool: '#eval-source-map',
|
||||
plugins: [
|
||||
|
@ -23,9 +24,7 @@ module.exports = merge(baseWebpackConfig, {
|
|||
'DEV_OVERRIDES': JSON.stringify(config.dev.settings)
|
||||
}),
|
||||
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
|
||||
new webpack.optimize.OccurenceOrderPlugin(),
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoErrorsPlugin(),
|
||||
// https://github.com/ampedandwired/html-webpack-plugin
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
|
|
|
@ -4,7 +4,7 @@ var utils = require('./utils')
|
|||
var webpack = require('webpack')
|
||||
var merge = require('webpack-merge')
|
||||
var baseWebpackConfig = require('./webpack.base.conf')
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin')
|
||||
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
var env = process.env.NODE_ENV === 'testing'
|
||||
? require('../config/test.env')
|
||||
|
@ -13,23 +13,23 @@ var env = process.env.NODE_ENV === 'testing'
|
|||
let commitHash = require('child_process')
|
||||
.execSync('git rev-parse --short HEAD')
|
||||
.toString();
|
||||
console.log(commitHash)
|
||||
|
||||
var webpackConfig = merge(baseWebpackConfig, {
|
||||
mode: 'production',
|
||||
module: {
|
||||
loaders: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true })
|
||||
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true })
|
||||
},
|
||||
devtool: config.build.productionSourceMap ? '#source-map' : false,
|
||||
optimization: {
|
||||
minimize: true,
|
||||
splitChunks: {
|
||||
chunks: 'all'
|
||||
}
|
||||
},
|
||||
output: {
|
||||
path: config.build.assetsRoot,
|
||||
filename: utils.assetsPath('js/[name].[chunkhash].js'),
|
||||
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
|
||||
},
|
||||
vue: {
|
||||
loaders: utils.cssLoaders({
|
||||
sourceMap: config.build.productionSourceMap,
|
||||
extract: true
|
||||
})
|
||||
chunkFilename: utils.assetsPath('js/[name].[chunkhash].js')
|
||||
},
|
||||
plugins: [
|
||||
// http://vuejs.github.io/vue-loader/workflow/production.html
|
||||
|
@ -38,14 +38,10 @@ var webpackConfig = merge(baseWebpackConfig, {
|
|||
'COMMIT_HASH': JSON.stringify(commitHash),
|
||||
'DEV_OVERRIDES': JSON.stringify(undefined)
|
||||
}),
|
||||
new webpack.optimize.UglifyJsPlugin({
|
||||
compress: {
|
||||
warnings: false
|
||||
}
|
||||
}),
|
||||
new webpack.optimize.OccurenceOrderPlugin(),
|
||||
// extract css into its own file
|
||||
new ExtractTextPlugin(utils.assetsPath('css/[name].[contenthash].css')),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: utils.assetsPath('css/[name].[contenthash].css')
|
||||
}),
|
||||
// generate dist index.html with correct asset hash for caching.
|
||||
// you can customize output by editing /index.html
|
||||
// see https://github.com/ampedandwired/html-webpack-plugin
|
||||
|
@ -67,25 +63,11 @@ var webpackConfig = merge(baseWebpackConfig, {
|
|||
chunksSortMode: 'dependency'
|
||||
}),
|
||||
// split vendor js into its own file
|
||||
new webpack.optimize.CommonsChunkPlugin({
|
||||
name: 'vendor',
|
||||
minChunks: function (module, count) {
|
||||
// any required modules inside node_modules are extracted to vendor
|
||||
return (
|
||||
module.resource &&
|
||||
/\.js$/.test(module.resource) &&
|
||||
module.resource.indexOf(
|
||||
path.join(__dirname, '../node_modules')
|
||||
) === 0
|
||||
)
|
||||
}
|
||||
}),
|
||||
// extract webpack runtime and module manifest to its own file in order to
|
||||
// prevent vendor hash from being updated whenever app bundle is updated
|
||||
new webpack.optimize.CommonsChunkPlugin({
|
||||
name: 'manifest',
|
||||
chunks: ['vendor']
|
||||
})
|
||||
// new webpack.optimize.SplitChunksPlugin({
|
||||
// name: ['app', 'vendor']
|
||||
// }),
|
||||
]
|
||||
})
|
||||
|
||||
|
|
|
@ -48,6 +48,11 @@ module.exports = {
|
|||
changeOrigin: true,
|
||||
cookieDomainRewrite: 'localhost',
|
||||
ws: true
|
||||
},
|
||||
'/oauth/revoke': {
|
||||
target,
|
||||
changeOrigin: true,
|
||||
cookieDomainRewrite: 'localhost'
|
||||
}
|
||||
},
|
||||
// CSS Sourcemaps off by default because relative paths are "buggy"
|
||||
|
|
104
docs/CONFIGURATION.md
Normal file
104
docs/CONFIGURATION.md
Normal file
|
@ -0,0 +1,104 @@
|
|||
# Pleroma-FE configuration and customization for instance administrators
|
||||
|
||||
* *For user configuration, see [Pleroma-FE user guide](USER_GUIDE.md)*
|
||||
* *For local development server configuration, see [Hacking, tweaking, contributing](HACKING.md)*
|
||||
|
||||
## Where configuration is stored
|
||||
|
||||
PleromaFE gets its configuration from several sources, in order of preference (the one above overrides ones below it)
|
||||
|
||||
1. `/api/statusnet/config.json` - this is generated on Backend and contains multiple things including instance name, char limit etc. It also contains FE/Client-specific data, PleromaFE uses `pleromafe` field of it. For more info on changing config on BE, look [here](https://docs-develop.pleroma.social/config.html#frontend_configurations)
|
||||
2. `/static/config.json` - this is a static FE-provided file, containing only FE specific configuration. This file is completely optional and could be removed but is useful as a fallback if some configuration JSON property isn't present in BE-provided config. It's also a reference point to check what default configuration are and what JSON properties even exist. In local dev mode it could be used to override BE configuration, more about that in HACKING.md. File is located [here](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/static/config.json).
|
||||
3. Built-in defaults. Those are hard-coded defaults that are used when `/static/config.json` is not available and BE-provided configuration JSON is missing some JSON properties. ( [Code](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/modules/instance.js) )
|
||||
|
||||
## Instance-defaults
|
||||
|
||||
Important note that some configurations are treated as "instance default" - it means user is able to change this configuration for themselves. Currently, defaults are only applied for new visitors and people who haven't changed the option in question. If you change some instance default option, there is a chance it won't affect some users.
|
||||
|
||||
There's currently no mechanism for user-settings synchronization across several browsers, *user* essentially means *visitor*, most user settings are stored in local storage/IndexedDB and not tied to an account in any way.
|
||||
|
||||
## Options
|
||||
|
||||
### `theme`
|
||||
Default theme used for new users. De-facto instance-default, user can change theme.
|
||||
|
||||
### `background`
|
||||
Default image background. Be aware of using too big images as they may take longer to load. Currently image is fitted with `background-size: cover` which means "scaled and cropped", currently left-aligned. De-facto instance default, user can choose their own background, if they remove their own background, instance default will be used instead.
|
||||
|
||||
### `logo`, `logoMask`, `logoMargin`
|
||||
Instance `logo`, could be any image, including svg. By default it assumes logo used will be monochrome-with-alpha one, this is done to be compatible with both light and dark themes, so that white logo designed with dark theme in mind won't be invisible over light theme, this is done via [CSS3 Masking](https://www.html5rocks.com/en/tutorials/masking/adobe/). Basically - it will take alpha channel of the image and fill non-transparent areas of it with solid color. If you really want colorful logo - it can be done by setting `logoMask` to `false`.
|
||||
|
||||
`logoMargin` allows you to adjust vertical margins between logo boundary and navbar borders. The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.
|
||||
|
||||
### `redirectRootNoLogin`, `redirectRootLogin`
|
||||
These two settings should point to where FE should redirect visitor when they login/open up website root
|
||||
|
||||
### `chatDisabled`
|
||||
hides the chat (TODO: even if it's enabled on backend)
|
||||
|
||||
### `showInstanceSpecificPanel`
|
||||
This allows you to include arbitrary HTML content in a panel below navigation menu. PleromaFE looks for an html page `instance/panel.html`, by default it's not provided in FE, but BE bundles some [default one](https://git.pleroma.social/pleroma/pleroma/blob/develop/priv/static/instance/panel.html). De-facto instance-defaults, since user can hide instance-specific panel.
|
||||
|
||||
### `collapseMessageWithSubject`
|
||||
Collapse post content when post has a subject line (content warning). Instance-default.
|
||||
|
||||
### `scopeCopy`
|
||||
Copy post scope (visibility) when replying to a post. Instance-default.
|
||||
|
||||
### `subjectLineBehavior`
|
||||
How to handle subject line (CW) when replying to a post.
|
||||
* `"email"` - like EMail - prepend `re: ` to subject line if it doesn't already start with it.
|
||||
* `"masto"` - like Mastodon - copy it as is.
|
||||
* `"noop"` - do not copy
|
||||
Instance-default.
|
||||
|
||||
### `postContentType`
|
||||
Default post formatting option (markdown/bbcode/plaintext/etc...)
|
||||
|
||||
### `alwaysShowSubjectInput`
|
||||
`true` - will always show subject line input, `false` - only show when it's not empty (i.e. replying). To hide subject line input completely, set it to `false` and `subjectLineBehavior` to `"noop"`
|
||||
|
||||
### `hidePostStats` and `hideUserStats`
|
||||
Hide counters for posts and users respectively, i.e. hiding repeats/favorites counts for posts, hiding followers/friends counts for users. This is just cosmetic and aimed to ease pressure and bias imposed by stat numbers of people and/or posts. (as an example: so that people care less about how many followers someone has since they can't see that info)
|
||||
|
||||
### `loginMethod`
|
||||
`"password"` - show simple password field
|
||||
`"token"` - show button to log in with external method (will redirect to login form, more details in BE documentation)
|
||||
|
||||
### `webPushNotifications`
|
||||
Enables [PushAPI](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) - based notifications for users. Instance-default.
|
||||
|
||||
### `noAttachmentLinks`
|
||||
**TODO Currently doesn't seem to be doing anything code-wise**, but implication is to disable adding links for attachments, which looks nicer but breaks compatibility with old GNU/Social servers.
|
||||
|
||||
### `nsfwCensorImage`
|
||||
Use custom image for NSFW'd images
|
||||
|
||||
### `showFeaturesPanel`
|
||||
Show panel showcasing instance features/settings to logged-out visitors
|
||||
|
||||
### `hideSitename`
|
||||
Hide instance name in header
|
||||
|
||||
## Indirect configuration
|
||||
Some features are configured depending on how backend is configured. In general the approach is "if backend allows it there's no need to hide it, if backend doesn't allow it there's no need to show it.
|
||||
|
||||
### Chat
|
||||
**TODO somewhat broken, see: chatDisabled** chat can be disabled by disabling it in backend
|
||||
|
||||
### Rich text formatting in post formatting
|
||||
Rich text formatting options are displayed depending on how many formatting options are enabled on backend, if you don't want your users to use rich text at all you can only allow "text/plain" one, frontend then will only display post text format as a label instead of dropdown (just so that users know for example if you only allow Markdown, only BBCode or only Plain text)
|
||||
|
||||
### Who to follow
|
||||
This is a panel intended for users to find people to follow based on randomness or on post contents. Being potentially privacy unfriendly feature it needs to be enabled and configured in backend to be enabled.
|
||||
|
||||
### Safe DM message display
|
||||
|
||||
Setting this will change the warning text that is displayed for direct messages.
|
||||
|
||||
ATTENTION: If you actually want the behavior to change. You will need to set the appropriate option at the backend. See the backend documentation for information about that.
|
||||
|
||||
DO NOT activate this without checking the backend configuration first!
|
||||
|
||||
### Private Mode
|
||||
If the `private` instance setting is enabled in the backend, features that are not accessible without authentication, such as the timelines and search will be disabled for unauthenticated users.
|
100
docs/HACKING.md
Normal file
100
docs/HACKING.md
Normal file
|
@ -0,0 +1,100 @@
|
|||
# Hacking, tweaking, contributing
|
||||
|
||||
## What PleromaFE even is, how it works
|
||||
|
||||
PleromaFE is an SPA (Single-Page Application) backed by [Vue](https://vuejs.org/) framework. It means that it's just a nearly-empty HTML page with bunch of JavaScript that actually generates and controls DOM (i.e. html elements) in Runtime. Currently, there's no way around it - you have to have Javascript enabled in the browser to make it work, there is a theoretical possibility to generate some HTML server-side but it's not implemented yet.
|
||||
|
||||
You can serve static html page and everything from any HTTP(S) server but currently it will try to access /api/ path at same domain it's running on, meaning that as of right now you cannot put it on one domain and access the other without proxying requests.
|
||||
|
||||
Development server does exactly that - it serves static html page with javascript and all other assets, adds some code to automatically reload when changes to code are made and proxies requests to some other server.
|
||||
|
||||
## Setting up develop server
|
||||
|
||||
Setting up development server is fairly straight-forward.
|
||||
|
||||
1. On your system you must have **[Node.js](https://nodejs.org/) version 8** and newer installed. For older systems or systems that do not package node you can try [NodeSource](https://github.com/nodesource/distributions) repositories. *Windows support theoretically possible but isn't tested.*
|
||||
2. For fetching dependencies and running basic tasks you will *[Yarn](https://yarnpkg.com/)* installed.
|
||||
3. Clone the repository, `cd` into it and run `yarn` to fetch dependencies.
|
||||
4. If you want to point development server at some instance you will need to copy `config/local.example.json` to `config/local.json` and change the `target` to point at instance you want, otherwise it will point to `localhost:4000` which is default address for locally-run Pleroma Backend
|
||||
5. Run `yarn dev` - it will start the server.
|
||||
6. Open `localhost:8080` in your browser, it might take a while initially until everything is built first time.
|
||||
|
||||
## Setting up production build
|
||||
|
||||
This could be a bit trickier, you basically need steps 1-4 from *develop build* instructions, and run `yarn build` which will compile and copy eveything needed for production into `dist` folder. As said before, this technically could be used anywhere with some details.
|
||||
|
||||
### Replacing your instance's frontend with custom FE build
|
||||
|
||||
This is the most easiest way to use and test FE build: you just need to copy or symlink contents of `dist` folder into backend's [static directory](https://docs.pleroma.social/static_dir.html), by default it is located in `instance/static`, or in `/var/lib/pleroma/static` for OTP release installations, create it if it doesn't exist already. Be aware that running `yarn build` wipes the contents of `dist` folder.
|
||||
|
||||
### Running production build locally or on a separate server
|
||||
|
||||
This is **highly experimental** and only tried once, with no actual simple solution available yet
|
||||
|
||||
You will need an HTTP server that can proxy requests for `/api`, `/instance`, `/nodeinfo` and show index.html for every 404 page.
|
||||
|
||||
For nginx you'll probably need something like this:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80 default_server;
|
||||
|
||||
index index.html index.htm index.nginx-debian.html;
|
||||
|
||||
root /var/www/html
|
||||
|
||||
location /api {
|
||||
proxy_pass https://example.tld;
|
||||
}
|
||||
|
||||
location /instance {
|
||||
proxy_pass https://example.tld;
|
||||
}
|
||||
|
||||
location /nodeinfo {
|
||||
proxy_pass https://example.tld;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(ed. note: this is close to what i used last time i had to do it, it may not work and need additions, i basically adjusted default nginx server in debian)
|
||||
|
||||
## Basic architecture
|
||||
|
||||
### API, Data, Operations
|
||||
|
||||
In 99% cases PleromaFE uses [MastoAPI](https://docs.joinmastodon.org/api/) with [Pleroma Extensions](https://docs-develop.pleroma.social/differences_in_mastoapi_responses.html) to fetch the data. The rest is either QvitterAPI leftovers or pleroma-exclusive APIs. QvitterAPI doesn't exactly have documentation and uses different JSON structure and sometimes different parameters and workflows, [this](https://twitter-api.readthedocs.io/en/latest/index.html) could be a good reference though. Some pleroma-exclusive API may still be using QvitterAPI JSON structure.
|
||||
|
||||
PleromaFE supports both formats by transforming them into internal format which is basically QvitterAPI one with some additions and renaming. All data is passed trough [Entity Normalizer](/src/services/entity_normalizer/entity_normalizer.service.js) which can serve as a reference of API and what's actually used, it's also a host for all the hacks and data transformation.
|
||||
|
||||
For most part, PleromaFE tries to store all the info it can get in global vuex store - every user and post are passed trough updating mechanism where data is either added or merged with existing data, reactively updating the information throughout UI, so if in newest request user's post counter increased, it will be instantly updated in open user profile cards. This is also used to find users, posts and sometimes to build timelines and/or request parameters.
|
||||
|
||||
PleromaFE also tries to persist this store, however only stable data is stored, such as user authentication and preferences, user highlights. Persistence is performed by saving and loading chunk of vuex store in browser's LocalStorage/IndexedDB.
|
||||
|
||||
TODO: Refactor API code and document it here
|
||||
|
||||
### Themes
|
||||
|
||||
PleromaFE uses custom theme "framework" which is pretty much just a style tag rendered by vue which only contains CSS3 variables. Every color used in UI should be derived from theme. Theme is stored in a JSON object containing color, opacity, shadow and font information, with most of it being optional.
|
||||
|
||||
The most basic theme can consist of 4 to 8 "basic colors", which is also what previous version of themes allowed, with all other colors being derived from those basic colors, i.e. "light background" will be "background" color lightened/darkened, "panel header" will be same as "foreground". The idea is that you can specify just basic color palette and everything else will be generated automatically, but if you really need to tweak some specific color - you can.
|
||||
|
||||
As said before - older version only allowed 4 to 8 colors, it also used arrays instead of objects, we still support that. The basic colors are: background, foreground, text, links, red, orange, blue, green. First 4 are mandatory, last 4 have default fallbacks since ever more ancient theme formats only had 4 colors.
|
||||
|
||||
Note that with older version themes used different internal naming when persisting state.
|
||||
|
||||
Themes are meant to be backwards and somewhat forwards compatible - new colors should properly inherit from some existing one, making it compatible with older versions. When loading newer version of theme all unrecognized colors will be ignored, which for most part should be fine, however adding new features (gradients, masks, whatever it might be) might be breaky.
|
||||
|
||||
Lastly, pleroma provides some contrast information and generates readable text color automatically, which is done by tracking background/text color pairs and their contrast - if contrast too low it will try to use background color with inverted lightness, if it's still unacceptable it will fall back to pure black/white.
|
||||
|
||||
### Still Image
|
||||
|
||||
Most images are wrapped in a component called StillImage, which does one simple thing - tries to detect if image is a GIF and if it is (and user has enabled relevant setting) it will show `<canvas>` with that image instead of actual image. It uses standard method to render an image into canvas which renders first frame of a GIF if it's animated (obviously because canvas by itself isn't animated and you'd need to animate it yourself in JS), it will show actual image on hover. Statuses also allow playing animated avatars when you hover over a post, not just image itself.
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to contribute, most preferred way is by starting a Merge Request in GitLab. Please try to use descriptive names for your branches and merge requests, avoid naming them "fix-issue-777" "777" and so on.
|
209
docs/USER_GUIDE.md
Normal file
209
docs/USER_GUIDE.md
Normal file
|
@ -0,0 +1,209 @@
|
|||
# Pleroma-FE user guide
|
||||
|
||||
> Be prepared for breaking changes, unexpected behavior and this user guide becoming obsolete and wrong.
|
||||
|
||||
> If there was no insanity
|
||||
>
|
||||
> it would be necessary to create it.
|
||||
>
|
||||
> --Catbag
|
||||
|
||||
Pleroma-FE user interface is modeled after Qvitter which is modeled after older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options.
|
||||
|
||||
## Posting, reading, basic functions.
|
||||
|
||||
After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column.
|
||||
|
||||
Posts will contain the text you are posting, but some content will be modified:
|
||||
|
||||
1. Mentions: Mentions have the form of @user or @user<span></span>@instance.tld. These will become links to the user's profile. In addition, the mentioned user will always get a notification about the post they have been mentioned in, so only mention users that you want to receive this message.
|
||||
2. URLs: URLs like `http://example.com` will be automatically be turned into a clickable links.
|
||||
3. Hashtags: Hashtags like #cofe will also be turned into links.
|
||||
|
||||
**Depending on your instance some of the options might not be available or have different defaults**
|
||||
|
||||
Let's clear up some basic stuff. When you post something it's called a **post** or it could be called a **status** or even a **toot** or a **prööt** depending on whom you ask. Post has body/content but it also has some other stuff in it - from attachments, visibility scope, subject line.
|
||||
* **Emoji** are small images embedded in text, there are two major types of emoji: [unicode emoji](https://en.wikipedia.org/wiki/Emoji) and custom emoji. While unicode emoji are universal and standardized, they can appear differently depending on where you are using them or may not appear at all on older systems. Custom emoji are more *fun* kind - instance administrator can define many images as *custom emoji* for their users. This works very simple - custom emoji is defined by its *shortcode* and an image, so that any shortcode enclosed in colons get replaced with image if such shortcode exist.
|
||||
Let's say there's `:pleroma:` emoji defined on instance. That means
|
||||
> First time using :pleroma: pleroma!
|
||||
|
||||
will become
|
||||
> First time using ![pleroma](./example_emoji.png) pleroma!
|
||||
|
||||
Note that you can only use emoji defined on your instance, you cannot "copy" someone else's emoji, and will have to ask your administrator to copy emoji from other instance to yours.
|
||||
Lastly, there's two convenience options for emoji: an emoji picker (smiley face to the right of "submit" button) and autocomplete suggestions - when you start typing :shortcode: it will automatically try to suggest you emoj and complete the shortcode for you if you select one. **Note** that if emoji doesn't show up in suggestions nor in emoji picker it means there's no such emoji on your instance, if shortcode doesn't match any defined emoji it will appear as text.
|
||||
* **Attachments** are fairly simple - you can attach any file to a post as long as the file is within maximum size limits. If you're uploading explicit material you can mark all of your attachments as sensitive (or add `#nsfw` tag) - it will hide the images and videos behind a warning so that it won't be displayed instantly.
|
||||
* **Subject line** also known as **CW** (Content Warning) could be used as a header to the post and/or to warn others about contents of the post having something that might upset somebody or something among those lines. Several applications allow to hide post content leaving only subject line visible. As a side-effect using subject line will also mark your images as sensitive (see above).
|
||||
* **Visiblity scope** controls who will be able to see your posts. There are four scopes available:
|
||||
|
||||
1. `Public`: This is the default, and some fediverse software like GNU Social only supports this. This means that your post is accessible by anyone and will be shown in the public timelines.
|
||||
2. `Unlisted`: This is the same as public, but your post won't appear in the public timelines. The post will still be accessible by anyone who comes across it (for example, by looking at your profile) or by direct linking. They will also appear in public searches.
|
||||
3. `Followers only`: This will show your post only to your followers. Only they will be able to interact with it. Be careful: When somebody follows you, they will be able to see all your previous `followers only` posts as well! If you want to restrict who can follow you, consider [locking your account down to only approved followers](#profle).
|
||||
4. `Direct`: This will only send the message to the people explicitly mentioned in the post.
|
||||
|
||||
A few things to consider about the security and usage of these scopes:
|
||||
|
||||
- None of these options will change the fact that the messages are all saved in the database unencrypted. They will be visible to your server admin and to any other admin of a server who receives this post. Do not share information that you would consider secret or dangerous. Use encrypted messaging systems for these things.
|
||||
- Follower-only posts can lead to fragmented conversations. If you post a follower-only post and somebody else replies to it with a follower-only post, only people following both of you will see the whole conversation thread. Everybody else will only see half of it. Keep this in mind and keep conversations public if possible.
|
||||
- Changing scopes during a thread or adding people to a direct message will not retroactively make them see the whole conversation. If you add someone to a direct message conversation, they will not see the post that happened before they were mentioned.
|
||||
* **Reply-to** if you are replying to someone, your post will also contain a note that your post is referring to the post you're replying to. Person you're replying to will receive a notification *even* if you remove them from mentioned people. You won't receive notifications when replying to your own posts, but it's useful to reply to your own posts to provide people some context if it's a follow-up to a previous post. There's a small "Reply to ..." label under post author's name which you can hover on to see what post it's referring to.
|
||||
|
||||
Sometimes you may encounter posts that seem different than what they are supposed to. For example, you might see a direct message without any mentions in the text. This can happen because internally, the Fediverse has a different addressing mechanism similar to email, with `to` and `cc` fields. While these are not directly accessible in PleromaFE, other software in the Fediverse might generate those posts. Do not worry in these cases, these are normal and not a bug.
|
||||
|
||||
#### Rich text
|
||||
|
||||
By default new posts you make are plaintext, meaning you can't make text **bold** or add custom links or make lists or anything like that. However if your instance allows it you can use Markdown or BBCode or HTML to spice up your text, however there are certain limitations to what HTML tags and what features of Markdown you can use.
|
||||
|
||||
this section will be expanded later
|
||||
|
||||
### Other actions
|
||||
|
||||
In addition to posting you can also *favorite* post also known as *liking* them and *repeat* posts (also known as *retweeting*, *boosting* and even *reprööting*). Favoriting a post increments a counter on it, notifies post author of your affection towards that post and also adds that post to your "favorited" posts list (in your own profile, "Favorites" tab). Reprööting a post does all that and also repeats this post to your followers and your profile page with a note "*user* repeated post".
|
||||
|
||||
Your own posts can be deleted, but this will only reliably delete the post from your own instance. Other instances will receive a deletion notice, but there's no way to force them to actually delete a post. In addition, not all instances that contain the message might even receive the deletion notice, because they might be offline or not known to have the post because they received it through a repeat. Lastly, deletion notice might not reach certain frontends and clients - post will be visible for them until page refresh or cache clear, they probably won't be able to interact with it apart from replying to it (which will have reply-to mark missing).
|
||||
|
||||
If you are a moderator, you can also delete posts by other people. If those people are on your instance, it will delete the post and send out the deletion notice to other servers. If they are not on your instance, it will just remove the post from your local instance.
|
||||
|
||||
There's also an option to report a user via a post (if the feature is available on your instance) which could be used to notify your (and probably other instance's) admin that someone is being naughty.
|
||||
|
||||
## Users
|
||||
|
||||
When you see someone, you can click on their user picture to view their profile, and click on the userpic in that to see *full* profile. You can *follow* them, *mute* and *block* them. Following is self-explanatory, it adds them t your Home Timeline, lists you as a follower and gives you access to follower-only posts if they have any. Muting makes posts and notifications made by them very tiny, giving you an option to see the post if you're curious. However on clients other than PleromaFE their posts will be completely removed. *Blocking* a user removes them from your timeline and notifications and prevents them from following you (automatically unfollows them from you).
|
||||
|
||||
Please note that some users can be "locked", meaning instead of following them you send a follow request they need to approve for you to become their follower.
|
||||
|
||||
## Timelines
|
||||
|
||||
Currently you have several timelines to browse trough:
|
||||
* **Timeline** aka Home Timeline - this timeline contains all posts by people you follow and your own posts, as well as posts mentioning you directly.
|
||||
* **Interactions** all interactions you've had with people on the network, basically same as notifications except grouped in convenient way - mentions separate from favorites with repeats separate from follows
|
||||
* **Direct Messages** all posts with `direct` scope addressed to you or mentioning you.
|
||||
* **Public Timelines** all posts made by users on instance you're on
|
||||
* **The Whole Known Network** also known as **TWKN** or **Federated Timeline** - all posts on the network by everyone, almost. Due to nature of the network your instance may not know *all** the instances on the network, so only posts originating from known instances are shown there.
|
||||
|
||||
## Your profile
|
||||
|
||||
By clicking wrench icon above the post form you can access the profile edit or "user settings" screen.
|
||||
|
||||
### Profle
|
||||
|
||||
Here you can set up how you appear to other users among with some other settings:
|
||||
|
||||
- Name: this is text that displays next to your avatar in posts. Please note that you **cannot** change your *@<span></span>handle*
|
||||
- Bio: this will be displayed under your profile - you can put anything you want there you want for everyone to see.
|
||||
- Restrict your account to approved followers only: this makes your account "locked", when people follow you - you have to approve or deny their follow requests, this gives more control over who sees your followers only posts.
|
||||
- Default visibility scope: this chooses your default post scope for new posts
|
||||
- Strip rich text from all posts: this strips rich text formatting (bold/italics/lists etc) from all incoming posts. Will only affect newly fetched posts.
|
||||
|
||||
If you're admin or moderator on your instance you also get "Show [role] badge in my profile" - this controls whether to show "Admin" or "Moderator** label on your profile page.
|
||||
|
||||
**For all options mentioned above you have to click "Submit" button for changes to take place**
|
||||
|
||||
- Avatar: this changes picture next to your posts. Your avatar shouldn't exceed 2 MiB (2097152 bytes) or it could cause problems with certain instances.
|
||||
- Banner: this changes background on your profile card. Same as avatar it shouldn't exceed 2 MiB limit.
|
||||
- Profile Background: this changes background picture for UI. It isn't shown to anyone else **yet**, but some time later it will be shown when viewing your profile.
|
||||
|
||||
### Security
|
||||
|
||||
Here you can change your password, revoke access tokens, configure 2-factor authentication (if available).
|
||||
|
||||
### Notifications
|
||||
|
||||
This screen allows more fine-grained control over what notifications to show to you based on whom it comes from
|
||||
|
||||
### Data Import/Export
|
||||
|
||||
This allows you to export and import a list of people you follow, in case instance's database gets reverted or if you want to move to another server. Note that you **CANNOT export/import list of people who *follow you***, they'll just need to follow you back after you move.
|
||||
|
||||
### Blocks and Mutes
|
||||
|
||||
These screens give access to full list of people you block/mute, useful for *un*blocking/*un*muting people because blocking/muting them most likely removes them out of your sight completely.
|
||||
|
||||
## Other stuff
|
||||
|
||||
By default you can see **ALL** posts made by other users on your Home Timeline, this contrast behavior of Twitter and Mastodon, which shows you only non-reply posts and replies to people you follow. You can set it up to replicate the said behavior, however the option is currently broken.
|
||||
|
||||
You can view other people's profiles and search for users (top-right corner, person with a plus icon). Tag search is possible but not implemented properly yet, right now you can click on tag link in a post to see posts tagged with that post.
|
||||
|
||||
You can also view posts you've favorited on your own profile, but you cannot see favorites by other people.
|
||||
|
||||
Due to nature of how Pleroma (backend) operates you might see old posts appear as if they are new, this is because instance just learned about that post (i.e. your instance is younger that some other ones) and someone interacted with old post. Posts are sorted by date of when they are received, not date they have been posted because it's very easy to spoof the date, so a post claiming it "was" made in year 2077 could hand at top of your TL forever.
|
||||
|
||||
# Customization and configuration
|
||||
|
||||
Clicking on the cog icon in the upper right will go to the settings screen.
|
||||
|
||||
## General
|
||||
|
||||
### Interface
|
||||
|
||||
- Language: Here you can set the interface language. The default language is the one that you set in your browser settings.
|
||||
- Hide instance-specific panel: This hides the panel in the lower left that usually contains general information about the server.
|
||||
|
||||
### Timeline
|
||||
|
||||
- Hide posts of muted users: If this is set, 'muting' a user will completely hide their posts instead of collapsing them.
|
||||
- Collapse posts with subjects: This will collapse posts that contain a subject, hiding their content. Subjects are also sometimes called content warnings.
|
||||
- Enable automatic streaming of new posts when scrolled to the top: With this enabled, new posts will automatically stream in when you are scrolled to the top. Otherwise, you will see a button on the timeline that will let you display the new posts.
|
||||
- Pause streaming when tab is not focused: This pauses the automatic streaming that the previous option enables when the tab is out of focus. This is useful if you don't want to miss any new posts.
|
||||
- Enable automatic loading when scrolled to the bottom: When this is disabled, a button will be shown on the bottom of the timeline that will let you load older posts.
|
||||
- Enable reply-link preview on hover: Status posts in the timeline and notifications contain links to replies and to the post they are a reply to. If this setting is enabled, hovering over that link will display that linked post in a small hovering overlay.
|
||||
|
||||
### Composing
|
||||
|
||||
- Copy scope when replying: When this is activated, the scope of a reply will be the same as the scope of the post it is replying to. This is useful to prevent accidentally moving private discussions to public, or vice versa.
|
||||
- Always show subject field: Whether or not to display the 'subject' input field in the post form. If you do not want to use subjects, you can deactivate this.
|
||||
- Copy subject when replying: This controls if the subject of a post will be copied from the post it is replying to.
|
||||
- Post status content type: Selects the default content type of your post. The options are: Plain text, HTML, BBCode and Markdown.
|
||||
- Minimize scope selection options: Selecting this will reduce the visibility scopes to 'direct', your default post scope and post scope of post you're replying to.
|
||||
- Automatically hide New Post button: Mobile interface only: hide floating "New post" button when scrolling
|
||||
|
||||
### Attachments
|
||||
|
||||
- Hide attachments in timeline: Do not display attachments in timelines. They will still display in expanded conversations. This is useful to save bandwidth and for browsing in public.
|
||||
- Hide attachments in conversations: Also hide attachments in expanded conversations.
|
||||
- Maximum amount of thumbnails per post: Exactly that :)
|
||||
- Enable clickthrough NSFW attachment hiding: Hide attachments that are marked as NSFW/sensitive behind a click-through image.`
|
||||
- Preload images: This will preload the hidden images so that they display faster when clicking through.
|
||||
- Open NSFW attachments with just one click: Directly open NSFW attachments in a maximised state instead of revealing the image thumbnail.
|
||||
- Play-on-hover GIFs: With this activated, GIFs images and avatars will only be animated on mouse hover. Otherwise, they will be always animated. This is very useful if your timeline looks too flashy from people's animated avatars and eases the CPU load.
|
||||
- Loop videos: Whether to loop videos indefinitely.
|
||||
- Loop only videos without sound: Some instances will use videos without sounds instead of GIFs. This will make only those videos autoplay.
|
||||
- Play videos directly in the media viewer: Play videos right in the timeline instead of opening it in a modal
|
||||
- Don't crop the attachment in thumbnails: if enabled, images in attachments will be fit entirely inside the container instead of being zoomed in and cropped.
|
||||
|
||||
### Notifications
|
||||
|
||||
- Enable web push notifications: this enables Web Push notifications, to allow receiving notifications even when the page isn't opened, doesn't affect regular notifications.
|
||||
|
||||
## Theme
|
||||
|
||||
You can change the look and feel of Pleroma Frontend here. You can choose from several instance-provided presets and you can load one from file and save current theme to file. Before you apply new theme you can see what it will look like approximately in preview section.
|
||||
|
||||
Themes engine was made to be easy to use while giving an option for powerful in-depth customization - you can just tweak colors on "Common" tab and leave everything else as is.
|
||||
|
||||
If there's a little check box next to a color picker it means that color is optional and unless checked will be automatically picked based on some other color or defaults.
|
||||
|
||||
For some features you can also adjust transparency of it by changing its opacity, you just need to tick checkbox next to it, otherwise it will be using default opacity.
|
||||
|
||||
Contrast information is also provided - you can see how readable text is based on contrast between text color and background, icons under color pickers represent contrast rating based on [WCAG](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#visual-audio-contrast-contrast) - thumbs up means AAA rating (good), half-filled circle means AA rating (acceptable) and warning icon means it doesn't pass the minimal contrast requirement and probably will be less readable, especially for vision-challenged people, you can hover over icon to see more detailed information. *Please note* that if background is not opaque (opacity != 1) contrast will be measured based on "worst case scenario", i.e. behind semi-transparent background lies some solid color that makes text harder to read, this however is still inaccurate because it doesn't account that background can be noisy/busy, making text even harder to read.
|
||||
|
||||
Apart from colors you can also tweak shadow and lighting, which is used mostly to give buttons proper relief based on their state, give panes their shade, make things glow etc. It's quite powerful, and basically provides somewhat convenient interface for [CSS Shadows](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow).
|
||||
|
||||
Another thing you can tweak is theme's roundness - some people like sharp edges, some want things more rounded. This is also used if you want circled or square avatars.
|
||||
|
||||
Lastly, you can redefine fonts used in UI without changing fonts in your browser or system, this however requires you to enter font's full name and having that font installed on your system.
|
||||
|
||||
## Filtering
|
||||
|
||||
- Types of notifications to show: This controls what kind of notifications will appear in notification column and which notifications to get in your system outside the web page
|
||||
- Replies in timeline: You may know that other social networks like Twitter will often not display replies to other people in your timeline, even if you are following the poster. Pleroma usually will show these posts to you to encourage conversation. If you do not like this behavior, you can change it here.
|
||||
- Hide post statistics: This hides the number of favorites, number of replies, etc.
|
||||
- Hide user statistics: This hides the number of followers, friends, etc.
|
||||
- Muted words: A list of words that will be muted (i.e. displayed in a collapsed state) on the timeline and in notifications. An easy way to tune down noise in your timeline. Posts can always be expanded when you actually want to see them.
|
||||
- Hide filtered statuses: Selecting this will hide the filtered / muted posts completely instead of collapsing them.
|
||||
|
||||
|
||||
## Version
|
||||
|
||||
Just displays the backend and frontend version. Useful to mention in bug reports.
|
BIN
docs/example_emoji.png
Normal file
BIN
docs/example_emoji.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 491 B |
|
@ -2,14 +2,13 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
|
||||
<title>Pleroma</title>
|
||||
<!--server-generated-meta-->
|
||||
<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>
|
||||
<body style="display: none">
|
||||
<body class="hidden">
|
||||
<noscript>To use Pleroma, please enable JavaScript.</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
|
|
83
package.json
83
package.json
|
@ -11,98 +11,101 @@
|
|||
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
|
||||
"e2e": "node test/e2e/runner.js",
|
||||
"test": "npm run unit && npm run e2e",
|
||||
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
|
||||
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
|
||||
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
|
||||
},
|
||||
"dependencies": {
|
||||
"babel-plugin-add-module-exports": "^0.2.1",
|
||||
"babel-plugin-lodash": "^3.2.11",
|
||||
"@babel/runtime": "^7.7.6",
|
||||
"@chenfengyuan/vue-qrcode": "^1.0.0",
|
||||
"body-scroll-lock": "^2.6.4",
|
||||
"chromatism": "^3.0.0",
|
||||
"cropperjs": "^1.4.3",
|
||||
"diff": "^3.0.1",
|
||||
"karma-mocha-reporter": "^2.2.1",
|
||||
"localforage": "^1.5.0",
|
||||
"node-sass": "^3.10.1",
|
||||
"object-path": "^0.11.3",
|
||||
"phoenix": "^1.3.0",
|
||||
"portal-vue": "^2.1.4",
|
||||
"sanitize-html": "^1.13.0",
|
||||
"sass-loader": "^4.0.2",
|
||||
"v-click-outside": "^2.1.1",
|
||||
"v-tooltip": "^2.0.2",
|
||||
"vue": "^2.5.13",
|
||||
"vue-chat-scroll": "^1.2.1",
|
||||
"vue-compose": "^0.7.1",
|
||||
"vue-i18n": "^7.3.2",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/polyfill": "^7.0.0",
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.6",
|
||||
"@babel/register": "^7.7.4",
|
||||
"@vue/babel-helper-vue-jsx-merge-props": "^1.0.0",
|
||||
"@vue/babel-plugin-transform-vue-jsx": "^1.1.2",
|
||||
"@vue/test-utils": "^1.0.0-beta.26",
|
||||
"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",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"chai": "^3.5.0",
|
||||
"chalk": "^1.1.3",
|
||||
"chromedriver": "^2.21.2",
|
||||
"connect-history-api-fallback": "^1.1.0",
|
||||
"cross-spawn": "^4.0.2",
|
||||
"css-loader": "^0.25.0",
|
||||
"eslint": "^3.7.1",
|
||||
"eslint-config-standard": "^6.1.0",
|
||||
"css-loader": "^0.28.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-config-standard": "^12.0.0",
|
||||
"eslint-friendly-formatter": "^2.0.5",
|
||||
"eslint-loader": "^1.5.0",
|
||||
"eslint-plugin-html": "^1.5.5",
|
||||
"eslint-plugin-promise": "^2.0.1",
|
||||
"eslint-plugin-standard": "^2.0.1",
|
||||
"eslint-loader": "^2.1.0",
|
||||
"eslint-plugin-import": "^2.13.0",
|
||||
"eslint-plugin-node": "^7.0.0",
|
||||
"eslint-plugin-promise": "^4.0.0",
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"eslint-plugin-vue": "^5.2.2",
|
||||
"eventsource-polyfill": "^0.9.6",
|
||||
"express": "^4.13.3",
|
||||
"extract-text-webpack-plugin": "^1.0.1",
|
||||
"file-loader": "^0.9.0",
|
||||
"file-loader": "^3.0.1",
|
||||
"fontello-webpack-plugin": "https://github.com/w3geo/fontello-webpack-plugin.git#6149eac8f2672ab6da089e8802fbfcac98487186",
|
||||
"function-bind": "^1.0.2",
|
||||
"html-webpack-plugin": "^2.8.1",
|
||||
"html-webpack-plugin": "^3.0.0",
|
||||
"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",
|
||||
"karma": "^3.0.0",
|
||||
"karma-coverage": "^1.1.1",
|
||||
"karma-firefox-launcher": "^1.1.0",
|
||||
"karma-mocha": "^1.2.0",
|
||||
"karma-phantomjs-launcher": "^1.0.0",
|
||||
"karma-sinon-chai": "^1.2.0",
|
||||
"karma-sinon-chai": "^2.0.2",
|
||||
"karma-sourcemap-loader": "^0.3.7",
|
||||
"karma-spec-reporter": "0.0.26",
|
||||
"karma-webpack": "^1.7.0",
|
||||
"karma-webpack": "^4.0.0-rc.3",
|
||||
"lodash": "^4.16.4",
|
||||
"lolex": "^1.4.0",
|
||||
"mini-css-extract-plugin": "^0.5.0",
|
||||
"mocha": "^3.1.0",
|
||||
"nightwatch": "^0.9.8",
|
||||
"opn": "^4.0.2",
|
||||
"ora": "^0.3.0",
|
||||
"phantomjs-prebuilt": "^2.1.3",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"raw-loader": "^0.5.1",
|
||||
"sass": "^1.17.3",
|
||||
"sass-loader": "git://github.com/webpack-contrib/sass-loader",
|
||||
"selenium-server": "2.53.1",
|
||||
"semver": "^5.3.0",
|
||||
"serviceworker-webpack-plugin": "0.2.3",
|
||||
"serviceworker-webpack-plugin": "^1.0.0",
|
||||
"shelljs": "^0.7.4",
|
||||
"sinon": "^1.17.3",
|
||||
"sinon": "^2.1.0",
|
||||
"sinon-chai": "^2.8.0",
|
||||
"url-loader": "^0.5.7",
|
||||
"vue-loader": "^11.1.0",
|
||||
"vue-style-loader": "^2.0.0",
|
||||
"webpack": "^1.13.2",
|
||||
"webpack-dev-middleware": "^1.8.3",
|
||||
"url-loader": "^1.1.2",
|
||||
"vue-loader": "^14.0.0",
|
||||
"vue-style-loader": "^4.0.0",
|
||||
"webpack": "^4.0.0",
|
||||
"webpack-dev-middleware": "^3.6.0",
|
||||
"webpack-hot-middleware": "^2.12.2",
|
||||
"webpack-merge": "^0.14.1"
|
||||
},
|
||||
|
|
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
plugins: [
|
||||
require('autoprefixer')
|
||||
]
|
||||
}
|
53
src/App.js
53
src/App.js
|
@ -1,15 +1,18 @@
|
|||
import UserPanel from './components/user_panel/user_panel.vue'
|
||||
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 SearchBar from './components/search_bar/search_bar.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'
|
||||
import MediaModal from './components/media_modal/media_modal.vue'
|
||||
import SideDrawer from './components/side_drawer/side_drawer.vue'
|
||||
import MobilePostStatusModal from './components/mobile_post_status_modal/mobile_post_status_modal.vue'
|
||||
import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils'
|
||||
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
|
||||
import MobileNav from './components/mobile_nav/mobile_nav.vue'
|
||||
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
|
||||
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
|
||||
import { windowWidth } from './services/window_utils/window_utils'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
|
@ -17,18 +20,21 @@ export default {
|
|||
UserPanel,
|
||||
NavPanel,
|
||||
Notifications,
|
||||
UserFinder,
|
||||
SearchBar,
|
||||
InstanceSpecificPanel,
|
||||
FeaturesPanel,
|
||||
WhoToFollowPanel,
|
||||
ChatPanel,
|
||||
MediaModal,
|
||||
SideDrawer,
|
||||
MobilePostStatusModal
|
||||
MobilePostStatusButton,
|
||||
MobileNav,
|
||||
UserReportingModal,
|
||||
PostStatusModal
|
||||
},
|
||||
data: () => ({
|
||||
mobileActivePanel: 'timeline',
|
||||
finderHidden: true,
|
||||
searchBarHidden: true,
|
||||
supportsMask: window.CSS && window.CSS.supports && (
|
||||
window.CSS.supports('mask-size', 'contain') ||
|
||||
window.CSS.supports('-webkit-mask-size', 'contain') ||
|
||||
|
@ -39,7 +45,11 @@ export default {
|
|||
}),
|
||||
created () {
|
||||
// Load the locale from the storage
|
||||
this.$i18n.locale = this.$store.state.config.interfaceLanguage
|
||||
this.$i18n.locale = this.$store.getters.mergedConfig.interfaceLanguage
|
||||
window.addEventListener('resize', this.updateMobileState)
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('resize', this.updateMobileState)
|
||||
},
|
||||
computed: {
|
||||
currentUser () { return this.$store.state.users.currentUser },
|
||||
|
@ -62,7 +72,7 @@ export default {
|
|||
logoBgStyle () {
|
||||
return Object.assign({
|
||||
'margin': `${this.$store.state.instance.logoMargin} 0`,
|
||||
opacity: this.finderHidden ? 1 : 0
|
||||
opacity: this.searchBarHidden ? 1 : 0
|
||||
}, this.enableMask ? {} : {
|
||||
'background-color': this.enableMask ? '' : 'transparent'
|
||||
})
|
||||
|
@ -80,15 +90,16 @@ export default {
|
|||
},
|
||||
sitename () { return this.$store.state.instance.name },
|
||||
chat () { return this.$store.state.chat.channel.state === 'joined' },
|
||||
hideSitename () { return this.$store.state.instance.hideSitename },
|
||||
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
|
||||
showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel },
|
||||
unseenNotifications () {
|
||||
return unseenNotificationsFromStore(this.$store)
|
||||
showInstanceSpecificPanel () {
|
||||
return this.$store.state.instance.showInstanceSpecificPanel &&
|
||||
!this.$store.getters.mergedConfig.hideISP &&
|
||||
this.$store.state.instance.instanceSpecificPanelContent
|
||||
},
|
||||
unseenNotificationsCount () {
|
||||
return this.unseenNotifications.length
|
||||
},
|
||||
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
|
||||
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
|
||||
isMobileLayout () { return this.$store.state.interface.mobileLayout },
|
||||
privateMode () { return this.$store.state.instance.private }
|
||||
},
|
||||
methods: {
|
||||
scrollToTop () {
|
||||
|
@ -98,11 +109,15 @@ export default {
|
|||
this.$router.replace('/main/public')
|
||||
this.$store.dispatch('logout')
|
||||
},
|
||||
onFinderToggled (hidden) {
|
||||
this.finderHidden = hidden
|
||||
onSearchBarToggled (hidden) {
|
||||
this.searchBarHidden = hidden
|
||||
},
|
||||
toggleMobileSidebar () {
|
||||
this.$refs.sideDrawer.toggleDrawer()
|
||||
updateMobileState () {
|
||||
const mobileLayout = windowWidth() <= 800
|
||||
const changed = mobileLayout !== this.isMobileLayout
|
||||
if (changed) {
|
||||
this.$store.dispatch('setMobileLayout', mobileLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
310
src/App.scss
310
src/App.scss
|
@ -10,13 +10,14 @@
|
|||
position: fixed;
|
||||
z-index: -1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
right: -20px;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 50%;
|
||||
}
|
||||
|
||||
i {
|
||||
i[class^='icon-'] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
@ -38,15 +39,24 @@ h4 {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
font-family: var(--interfaceFont, sans-serif);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -101,6 +111,14 @@ button {
|
|||
background-color: $fallback--bg;
|
||||
background-color: var(--bg, $fallback--bg)
|
||||
}
|
||||
|
||||
&.danger {
|
||||
// TODO: add better color variable
|
||||
color: $fallback--text;
|
||||
color: var(--alertErrorPanelText, $fallback--text);
|
||||
background-color: $fallback--alertError;
|
||||
background-color: var(--alertError, $fallback--alertError);
|
||||
}
|
||||
}
|
||||
|
||||
label.select {
|
||||
|
@ -121,6 +139,7 @@ input, textarea, .select {
|
|||
font-family: sans-serif;
|
||||
font-family: var(--inputFont, sans-serif);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
padding: 8px .5em;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
|
@ -154,7 +173,7 @@ input, textarea, .select {
|
|||
background: transparent;
|
||||
border: none;
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
color: var(--inputText, --text, $fallback--text);
|
||||
margin: 0;
|
||||
padding: 0 2em 0 .2em;
|
||||
font-family: sans-serif;
|
||||
|
@ -174,7 +193,44 @@ input, textarea, .select {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
&[type=radio],
|
||||
&[type=radio] {
|
||||
display: none;
|
||||
&:checked + label::before {
|
||||
box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset;
|
||||
box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset;
|
||||
background-color: var(--link, $fallback--link);
|
||||
}
|
||||
&:disabled {
|
||||
&,
|
||||
& + label,
|
||||
& + label::before {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
+ label::before {
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
content: '';
|
||||
transition: box-shadow 200ms;
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
border-radius: 100%; // Radio buttons should always be circle
|
||||
box-shadow: 0px 0px 2px black inset;
|
||||
box-shadow: var(--inputShadow);
|
||||
margin-right: .5em;
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--input, $fallback--fg);
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
line-height: 1.1em;
|
||||
font-size: 1.1em;
|
||||
box-sizing: border-box;
|
||||
color: transparent;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
&[type=checkbox] {
|
||||
display: none;
|
||||
&:checked + label::before {
|
||||
|
@ -189,6 +245,7 @@ input, textarea, .select {
|
|||
}
|
||||
}
|
||||
+ label::before {
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
content: '✔';
|
||||
transition: color 200ms;
|
||||
|
@ -220,11 +277,45 @@ option {
|
|||
background-color: var(--bg, $fallback--bg);
|
||||
}
|
||||
|
||||
.hide-number-spinner {
|
||||
-moz-appearance: textfield;
|
||||
&[type=number]::-webkit-inner-spin-button,
|
||||
&[type=number]::-webkit-outer-spin-button {
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
i[class*=icon-] {
|
||||
color: $fallback--icon;
|
||||
color: var(--icon, $fallback--icon)
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
|
@ -260,6 +351,7 @@ i[class*=icon-] {
|
|||
align-items: center;
|
||||
position: fixed;
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
|
@ -299,6 +391,7 @@ i[class*=icon-] {
|
|||
}
|
||||
|
||||
.inner-nav {
|
||||
position: relative;
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding-left: 10px;
|
||||
|
@ -371,6 +464,7 @@ main-router {
|
|||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
flex: none;
|
||||
border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
|
||||
border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
|
||||
background-size: cover;
|
||||
|
@ -465,41 +559,6 @@ nav {
|
|||
color: var(--faint, $fallback--faint);
|
||||
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
|
||||
box-shadow: var(--topBarShadow);
|
||||
|
||||
.back-button {
|
||||
display: block;
|
||||
max-width: 99px;
|
||||
transition-property: opacity, max-width;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease-out;
|
||||
|
||||
i {
|
||||
margin: 0 1em;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
max-width: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
border-radius: 100%;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
position: absolute;
|
||||
left: calc(50% - 4px);
|
||||
top: calc(50% - 4px);
|
||||
margin-left: 6px;
|
||||
margin-top: -6px;
|
||||
background-color: $fallback--cRed;
|
||||
background-color: var(--badgeNotification, $fallback--cRed);
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
|
@ -530,31 +589,11 @@ nav {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.panel-switcher {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
|
||||
button {
|
||||
display: block;
|
||||
flex: 1;
|
||||
max-height: 32px;
|
||||
margin: 0.5em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 800px) {
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
nav {
|
||||
.back-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-bounds {
|
||||
overflow: hidden;
|
||||
max-height: 100vh;
|
||||
|
@ -622,6 +661,18 @@ nav {
|
|||
color: var(--alertErrorPanelText, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background-color: $fallback--alertWarning;
|
||||
background-color: var(--alertWarning, $fallback--alertWarning);
|
||||
color: $fallback--text;
|
||||
color: var(--alertWarningText, $fallback--text);
|
||||
|
||||
.panel-heading & {
|
||||
color: $fallback--text;
|
||||
color: var(--alertWarningPanelText, $fallback--text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.faint {
|
||||
|
@ -648,21 +699,6 @@ nav {
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
.visibility-tray {
|
||||
font-size: 1.2em;
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
.selected {
|
||||
color: $fallback--lightText;
|
||||
color: var(--lightText, $fallback--lightText);
|
||||
}
|
||||
|
||||
div {
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-notice {
|
||||
padding: .5em;
|
||||
border: 1px solid $fallback--faint;
|
||||
|
@ -671,29 +707,17 @@ nav {
|
|||
border-radius: var(--inputRadius, $fallback--inputRadius);
|
||||
}
|
||||
|
||||
@keyframes modal-background-fadein {
|
||||
from {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
to {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
.notice-dismissible {
|
||||
padding-right: 4rem;
|
||||
position: relative;
|
||||
|
||||
.modal-view {
|
||||
z-index: 1000;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
animation-duration: 0.2s;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
animation-name: modal-background-fadein;
|
||||
.dismiss {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: .5em;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
|
@ -750,6 +774,70 @@ nav {
|
|||
}
|
||||
}
|
||||
|
||||
.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%;
|
||||
max-width: 100%;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.unavailable,
|
||||
.unavailable i {
|
||||
color: var(--cRed, $fallback--cRed);
|
||||
color: $fallback--cRed;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 28px;
|
||||
min-width: 10em;
|
||||
padding: 0 2em;
|
||||
}
|
||||
|
||||
.number-input {
|
||||
max-width: 6em;
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
|
||||
|
@ -767,3 +855,31 @@ nav {
|
|||
.btn.btn-default {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 2s infinite linear;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(359deg);
|
||||
}
|
||||
}
|
||||
|
||||
.new-status-notification {
|
||||
position:relative;
|
||||
margin-top: -1px;
|
||||
font-size: 1.1em;
|
||||
border-width: 1px 0 0 0;
|
||||
border-style: solid;
|
||||
border-color: var(--border, $fallback--border);
|
||||
padding: 10px;
|
||||
z-index: 1;
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--panel, $fallback--fg);
|
||||
}
|
||||
|
|
136
src/App.vue
136
src/App.vue
|
@ -1,56 +1,128 @@
|
|||
<template>
|
||||
<div id="app" v-bind:style="bgAppStyle">
|
||||
<div class="app-bg-wrapper" v-bind:style="bgStyle"></div>
|
||||
<nav class='nav-bar container' @click="scrollToTop()" id="nav">
|
||||
<div class='logo' :style='logoBgStyle'>
|
||||
<div class='mask' :style='logoMaskStyle'></div>
|
||||
<img :src='logo' :style='logoStyle'>
|
||||
</div>
|
||||
<div class='inner-nav'>
|
||||
<div class='item'>
|
||||
<a href="#" class="menu-button" @click.stop.prevent="toggleMobileSidebar()">
|
||||
<i class="button-icon icon-menu"></i>
|
||||
<div class="alert-dot" v-if="unseenNotificationsCount"></div>
|
||||
</a>
|
||||
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
|
||||
<div
|
||||
id="app"
|
||||
:style="bgAppStyle"
|
||||
>
|
||||
<div
|
||||
id="app_bg_wrapper"
|
||||
class="app-bg-wrapper"
|
||||
:style="bgStyle"
|
||||
/>
|
||||
<MobileNav v-if="isMobileLayout" />
|
||||
<nav
|
||||
v-else
|
||||
id="nav"
|
||||
class="nav-bar container"
|
||||
@click="scrollToTop()"
|
||||
>
|
||||
<div class="inner-nav">
|
||||
<div
|
||||
class="logo"
|
||||
:style="logoBgStyle"
|
||||
>
|
||||
<div
|
||||
class="mask"
|
||||
:style="logoMaskStyle"
|
||||
/>
|
||||
<img
|
||||
:src="logo"
|
||||
:style="logoStyle"
|
||||
>
|
||||
</div>
|
||||
<div class='item right'>
|
||||
<user-finder class="button-icon nav-icon mobile-hidden" @toggled="onFinderToggled"></user-finder>
|
||||
<router-link class="mobile-hidden" :to="{ name: 'settings'}"><i class="button-icon icon-cog nav-icon" :title="$t('nav.preferences')"></i></router-link>
|
||||
<a href="#" class="mobile-hidden" v-if="currentUser" @click.prevent="logout"><i class="button-icon icon-logout nav-icon" :title="$t('login.logout')"></i></a>
|
||||
<div class="item">
|
||||
<router-link
|
||||
v-if="!hideSitename"
|
||||
class="site-name"
|
||||
:to="{ name: 'root' }"
|
||||
active-class="home"
|
||||
>
|
||||
{{ sitename }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="item right">
|
||||
<search-bar
|
||||
v-if="currentUser || !privateMode"
|
||||
class="nav-icon mobile-hidden"
|
||||
@toggled="onSearchBarToggled"
|
||||
@click.stop.native
|
||||
/>
|
||||
<router-link
|
||||
class="mobile-hidden"
|
||||
:to="{ name: 'settings'}"
|
||||
>
|
||||
<i
|
||||
class="button-icon icon-cog nav-icon"
|
||||
:title="$t('nav.preferences')"
|
||||
/>
|
||||
</router-link>
|
||||
<a
|
||||
v-if="currentUser && currentUser.role === 'admin'"
|
||||
href="/pleroma/admin/#/login-pleroma"
|
||||
class="mobile-hidden"
|
||||
target="_blank"
|
||||
><i
|
||||
class="button-icon icon-gauge nav-icon"
|
||||
:title="$t('nav.administration')"
|
||||
/></a>
|
||||
<a
|
||||
v-if="currentUser"
|
||||
href="#"
|
||||
class="mobile-hidden"
|
||||
@click.prevent="logout"
|
||||
><i
|
||||
class="button-icon icon-logout nav-icon"
|
||||
:title="$t('login.logout')"
|
||||
/></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div v-if="" class="container" id="content">
|
||||
<side-drawer ref="sideDrawer" :logout="logout"></side-drawer>
|
||||
<div
|
||||
id="content"
|
||||
class="container"
|
||||
>
|
||||
<div class="sidebar-flexer mobile-hidden">
|
||||
<div class="sidebar-bounds">
|
||||
<div class="sidebar-scroller">
|
||||
<div class="sidebar">
|
||||
<user-panel></user-panel>
|
||||
<nav-panel></nav-panel>
|
||||
<instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel>
|
||||
<features-panel v-if="!currentUser && showFeaturesPanel"></features-panel>
|
||||
<who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel>
|
||||
<notifications v-if="currentUser"></notifications>
|
||||
<user-panel />
|
||||
<div v-if="!isMobileLayout">
|
||||
<nav-panel />
|
||||
<instance-specific-panel v-if="showInstanceSpecificPanel" />
|
||||
<features-panel v-if="!currentUser && showFeaturesPanel" />
|
||||
<who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
|
||||
<notifications v-if="currentUser" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div v-if="!currentUser" class="login-hint panel panel-default">
|
||||
<router-link :to="{ name: 'login' }" class="panel-body">
|
||||
<div
|
||||
v-if="!currentUser"
|
||||
class="login-hint panel panel-default"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'login' }"
|
||||
class="panel-body"
|
||||
>
|
||||
{{ $t("login.hint") }}
|
||||
</router-link>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<router-view></router-view>
|
||||
<router-view />
|
||||
</transition>
|
||||
</div>
|
||||
<media-modal></media-modal>
|
||||
<media-modal />
|
||||
</div>
|
||||
<chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
|
||||
<MobilePostStatusModal />
|
||||
<chat-panel
|
||||
v-if="currentUser && chat"
|
||||
:floating="true"
|
||||
class="floating-chat mobile-hidden"
|
||||
/>
|
||||
<MobilePostStatusButton />
|
||||
<UserReportingModal />
|
||||
<PostStatusModal />
|
||||
<portal-target name="modal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ $fallback--cGreen: #0fa00f;
|
|||
$fallback--cOrange: orange;
|
||||
|
||||
$fallback--alertError: rgba(211,16,20,.5);
|
||||
$fallback--alertWarning: rgba(111,111,20,.5);
|
||||
|
||||
$fallback--panelRadius: 10px;
|
||||
$fallback--checkboxRadius: 2px;
|
||||
|
|
|
@ -1,19 +1,23 @@
|
|||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
import routes from './routes'
|
||||
|
||||
import App from '../App.vue'
|
||||
import { windowWidth } from '../services/window_utils/window_utils'
|
||||
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
|
||||
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
|
||||
|
||||
const afterStoreSetup = ({ store, i18n }) => {
|
||||
window.fetch('/api/statusnet/config.json')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site
|
||||
const getStatusnetConfig = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/api/statusnet/config.json')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = 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 })
|
||||
store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' })
|
||||
|
||||
// TODO: default values for this stuff, added if to not make it break on
|
||||
// my dev config out of the box.
|
||||
|
@ -28,153 +32,285 @@ const afterStoreSetup = ({ store, i18n }) => {
|
|||
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
|
||||
}
|
||||
|
||||
var apiConfig = data.site.pleromafe
|
||||
return data.site.pleromafe
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Could not load statusnet config, potentially fatal')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const getStaticConfig = async () => {
|
||||
try {
|
||||
const res = await window.fetch('/static/config.json')
|
||||
if (res.ok) {
|
||||
return res.json()
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load static/config.json, continuing without it.')
|
||||
console.warn(error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
const setSettings = async ({ apiConfig, staticConfig, store }) => {
|
||||
const overrides = window.___pleromafe_dev_overrides || {}
|
||||
const env = window.___pleromafe_mode.NODE_ENV
|
||||
|
||||
const copyInstanceOption = (name) => {
|
||||
store.dispatch('setInstanceOption', {name, value: config[name]})
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
copyInstanceOption('nsfwCensorImage')
|
||||
copyInstanceOption('background')
|
||||
copyInstanceOption('hidePostStats')
|
||||
copyInstanceOption('hideUserStats')
|
||||
copyInstanceOption('hideFilteredStatuses')
|
||||
copyInstanceOption('logo')
|
||||
const copyInstanceOption = (name) => {
|
||||
store.dispatch('setInstanceOption', { name, value: config[name] })
|
||||
}
|
||||
|
||||
store.dispatch('setInstanceOption', {
|
||||
name: 'logoMask',
|
||||
value: typeof config.logoMask === 'undefined'
|
||||
? true
|
||||
: config.logoMask
|
||||
})
|
||||
copyInstanceOption('nsfwCensorImage')
|
||||
copyInstanceOption('background')
|
||||
copyInstanceOption('hidePostStats')
|
||||
copyInstanceOption('hideUserStats')
|
||||
copyInstanceOption('hideFilteredStatuses')
|
||||
copyInstanceOption('logo')
|
||||
|
||||
store.dispatch('setInstanceOption', {
|
||||
name: 'logoMargin',
|
||||
value: typeof config.logoMargin === 'undefined'
|
||||
? 0
|
||||
: config.logoMargin
|
||||
})
|
||||
store.dispatch('setInstanceOption', {
|
||||
name: 'logoMask',
|
||||
value: typeof config.logoMask === 'undefined'
|
||||
? true
|
||||
: config.logoMask
|
||||
})
|
||||
|
||||
copyInstanceOption('redirectRootNoLogin')
|
||||
copyInstanceOption('redirectRootLogin')
|
||||
copyInstanceOption('showInstanceSpecificPanel')
|
||||
copyInstanceOption('scopeOptionsEnabled')
|
||||
copyInstanceOption('formattingOptionsEnabled')
|
||||
copyInstanceOption('collapseMessageWithSubject')
|
||||
copyInstanceOption('loginMethod')
|
||||
copyInstanceOption('scopeCopy')
|
||||
copyInstanceOption('subjectLineBehavior')
|
||||
copyInstanceOption('postContentType')
|
||||
copyInstanceOption('alwaysShowSubjectInput')
|
||||
copyInstanceOption('noAttachmentLinks')
|
||||
copyInstanceOption('showFeaturesPanel')
|
||||
store.dispatch('setInstanceOption', {
|
||||
name: 'logoMargin',
|
||||
value: typeof config.logoMargin === 'undefined'
|
||||
? 0
|
||||
: config.logoMargin
|
||||
})
|
||||
store.commit('authFlow/setInitialStrategy', config.loginMethod)
|
||||
|
||||
if (config.chatDisabled) {
|
||||
store.dispatch('disableChat')
|
||||
}
|
||||
copyInstanceOption('redirectRootNoLogin')
|
||||
copyInstanceOption('redirectRootLogin')
|
||||
copyInstanceOption('showInstanceSpecificPanel')
|
||||
copyInstanceOption('minimalScopesMode')
|
||||
copyInstanceOption('hideMutedPosts')
|
||||
copyInstanceOption('collapseMessageWithSubject')
|
||||
copyInstanceOption('scopeCopy')
|
||||
copyInstanceOption('subjectLineBehavior')
|
||||
copyInstanceOption('postContentType')
|
||||
copyInstanceOption('alwaysShowSubjectInput')
|
||||
copyInstanceOption('noAttachmentLinks')
|
||||
copyInstanceOption('showFeaturesPanel')
|
||||
copyInstanceOption('hideSitename')
|
||||
|
||||
return store.dispatch('setTheme', config['theme'])
|
||||
})
|
||||
.then(() => {
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
routes: routes(store),
|
||||
scrollBehavior: (to, _from, savedPosition) => {
|
||||
if (to.matched.some(m => m.meta.dontScroll)) {
|
||||
return false
|
||||
}
|
||||
return savedPosition || { x: 0, y: 0 }
|
||||
}
|
||||
})
|
||||
return store.dispatch('setTheme', config['theme'])
|
||||
}
|
||||
|
||||
/* 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) => {
|
||||
const getTOS = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/static/terms-of-service.html')
|
||||
if (res.ok) {
|
||||
const html = await res.text()
|
||||
store.dispatch('setInstanceOption', { name: 'tos', value: html })
|
||||
})
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Can't load TOS")
|
||||
console.warn(e)
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
const getInstancePanel = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/instance/panel.html')
|
||||
if (res.ok) {
|
||||
const html = await res.text()
|
||||
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Can't load instance panel")
|
||||
console.warn(e)
|
||||
}
|
||||
}
|
||||
|
||||
const getStickers = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/static/stickers.json')
|
||||
if (res.ok) {
|
||||
const values = await res.json()
|
||||
const stickers = (await Promise.all(
|
||||
Object.entries(values).map(async ([name, path]) => {
|
||||
const resPack = await window.fetch(path + 'pack.json')
|
||||
var meta = {}
|
||||
if (resPack.ok) {
|
||||
meta = await resPack.json()
|
||||
}
|
||||
return {
|
||||
pack: name,
|
||||
path,
|
||||
meta
|
||||
}
|
||||
})
|
||||
)).sort((a, b) => {
|
||||
return a.meta.title.localeCompare(b.meta.title)
|
||||
})
|
||||
store.dispatch('setInstanceOption', { name: 'stickers', value: stickers })
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Can't load stickers")
|
||||
console.warn(e)
|
||||
}
|
||||
}
|
||||
|
||||
const getAppSecret = async ({ store }) => {
|
||||
const { state, commit } = store
|
||||
const { oauth, instance } = state
|
||||
return getOrCreateApp({ ...oauth, instance: instance.server, commit })
|
||||
.then((app) => getClientToken({ ...app, instance: instance.server }))
|
||||
.then((token) => {
|
||||
commit('setAppToken', token.access_token)
|
||||
commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
|
||||
})
|
||||
}
|
||||
|
||||
window.fetch('/nodeinfo/2.0.json')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const resolveStaffAccounts = async ({ store, accounts }) => {
|
||||
const backendInteractor = store.state.api.backendInteractor
|
||||
let nicknames = accounts.map(uri => uri.split('/').pop())
|
||||
.map(id => backendInteractor.fetchUser({ id }))
|
||||
nicknames = await Promise.all(nicknames)
|
||||
|
||||
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
|
||||
}
|
||||
|
||||
const getNodeInfo = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/nodeinfo/2.0.json')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
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') })
|
||||
|
||||
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
|
||||
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
|
||||
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
|
||||
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
|
||||
|
||||
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
|
||||
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
|
||||
|
||||
const suggestions = metadata.suggestions
|
||||
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
|
||||
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
|
||||
|
||||
const software = data.software
|
||||
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
|
||||
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: software.name === 'pleroma' })
|
||||
|
||||
const priv = metadata.private
|
||||
store.dispatch('setInstanceOption', { name: 'private', value: priv })
|
||||
|
||||
const frontendVersion = window.___pleromafe_commit_hash
|
||||
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
|
||||
store.dispatch('setInstanceOption', { name: 'tagPolicyAvailable', value: metadata.federation.mrf_policies.includes('TagPolicy') })
|
||||
|
||||
const federation = metadata.federation
|
||||
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
|
||||
store.dispatch('setInstanceOption', {
|
||||
name: 'federating',
|
||||
value: typeof federation.enabled === 'undefined'
|
||||
? true
|
||||
: federation.enabled
|
||||
})
|
||||
|
||||
const accounts = metadata.staffAccounts
|
||||
await resolveStaffAccounts({ store, accounts })
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not load nodeinfo')
|
||||
console.warn(e)
|
||||
}
|
||||
}
|
||||
|
||||
const setConfig = async ({ store }) => {
|
||||
// apiConfig, staticConfig
|
||||
const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()])
|
||||
const apiConfig = configInfos[0]
|
||||
const staticConfig = configInfos[1]
|
||||
|
||||
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
|
||||
}
|
||||
|
||||
const checkOAuthToken = async ({ store }) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if (store.getters.getUserToken()) {
|
||||
try {
|
||||
await store.dispatch('loginUser', store.getters.getUserToken())
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
const afterStoreSetup = async ({ store, i18n }) => {
|
||||
if (store.state.config.customTheme) {
|
||||
// This is a hack to deal with async loading of config.json and themes
|
||||
// See: style_setter.js, setPreset()
|
||||
window.themeLoaded = true
|
||||
store.dispatch('setOption', {
|
||||
name: 'customTheme',
|
||||
value: store.state.config.customTheme
|
||||
})
|
||||
}
|
||||
|
||||
const width = windowWidth()
|
||||
store.dispatch('setMobileLayout', width <= 800)
|
||||
|
||||
// Now we can try getting the server settings and logging in
|
||||
await Promise.all([
|
||||
checkOAuthToken({ store }),
|
||||
setConfig({ store }),
|
||||
getTOS({ store }),
|
||||
getInstancePanel({ store }),
|
||||
getStickers({ store }),
|
||||
getNodeInfo({ store })
|
||||
])
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
routes: routes(store),
|
||||
scrollBehavior: (to, _from, savedPosition) => {
|
||||
if (to.matched.some(m => m.meta.dontScroll)) {
|
||||
return false
|
||||
}
|
||||
return savedPosition || { x: 0, y: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
/* eslint-disable no-new */
|
||||
return new Vue({
|
||||
router,
|
||||
store,
|
||||
i18n,
|
||||
el: '#app',
|
||||
render: h => h(App)
|
||||
})
|
||||
}
|
||||
|
||||
export default afterStoreSetup
|
||||
|
|
|
@ -3,50 +3,71 @@ import PublicAndExternalTimeline from 'components/public_and_external_timeline/p
|
|||
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 Interactions from 'components/interactions/interactions.vue'
|
||||
import DMs from 'components/dm_timeline/dm_timeline.vue'
|
||||
import UserProfile from 'components/user_profile/user_profile.vue'
|
||||
import Search from 'components/search/search.vue'
|
||||
import Settings from 'components/settings/settings.vue'
|
||||
import Registration from 'components/registration/registration.vue'
|
||||
import PasswordReset from 'components/password_reset/password_reset.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'
|
||||
import Notifications from 'components/notifications/notifications.vue'
|
||||
import LoginForm from 'components/login_form/login_form.vue'
|
||||
import AuthForm from 'components/auth_form/auth_form.js'
|
||||
import ChatPanel from 'components/chat_panel/chat_panel.vue'
|
||||
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
|
||||
import About from 'components/about/about.vue'
|
||||
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
|
||||
|
||||
export default (store) => {
|
||||
const validateAuthenticatedRoute = (to, from, next) => {
|
||||
if (store.state.users.currentUser) {
|
||||
next()
|
||||
} else {
|
||||
next(store.state.instance.redirectRootNoLogin || '/main/all')
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ name: 'root',
|
||||
path: '/',
|
||||
redirect: _to => {
|
||||
return (store.state.users.currentUser
|
||||
? store.state.instance.redirectRootLogin
|
||||
: store.state.instance.redirectRootNoLogin) || '/main/all'
|
||||
? store.state.instance.redirectRootLogin
|
||||
: store.state.instance.redirectRootNoLogin) || '/main/all'
|
||||
}
|
||||
},
|
||||
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
|
||||
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
|
||||
{ name: 'friends', path: '/main/friends', component: FriendsTimeline },
|
||||
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
|
||||
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
|
||||
{ name: 'remote-user-profile-acct',
|
||||
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
|
||||
component: RemoteUserResolver,
|
||||
beforeEnter: validateAuthenticatedRoute
|
||||
},
|
||||
{ name: 'remote-user-profile',
|
||||
path: '/remote-users/:hostname/:username',
|
||||
component: RemoteUserResolver,
|
||||
beforeEnter: validateAuthenticatedRoute
|
||||
},
|
||||
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
|
||||
{ name: 'mentions', path: '/users/:username/mentions', component: Mentions },
|
||||
{ name: 'dms', path: '/users/:username/dms', component: DMs },
|
||||
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'settings', path: '/settings', component: Settings },
|
||||
{ name: 'registration', path: '/registration', component: Registration },
|
||||
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
|
||||
{ name: 'registration-token', path: '/registration/:token', component: Registration },
|
||||
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
|
||||
{ name: 'user-settings', path: '/user-settings', component: UserSettings },
|
||||
{ name: 'notifications', path: '/:username/notifications', component: Notifications },
|
||||
{ name: 'login', path: '/login', component: LoginForm },
|
||||
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'user-settings', path: '/user-settings', component: UserSettings, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'login', path: '/login', component: AuthForm },
|
||||
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
|
||||
{ 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 }) },
|
||||
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow },
|
||||
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
|
||||
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'about', path: '/about', component: About },
|
||||
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
|
||||
]
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue'
|
||||
import FeaturesPanel from '../features_panel/features_panel.vue'
|
||||
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
|
||||
import StaffPanel from '../staff_panel/staff_panel.vue'
|
||||
import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue'
|
||||
|
||||
const About = {
|
||||
components: {
|
||||
InstanceSpecificPanel,
|
||||
FeaturesPanel,
|
||||
TermsOfServicePanel
|
||||
TermsOfServicePanel,
|
||||
StaffPanel,
|
||||
MRFTransparencyPanel
|
||||
},
|
||||
computed: {
|
||||
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
|
||||
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
|
||||
showInstanceSpecificPanel () {
|
||||
return this.$store.state.instance.showInstanceSpecificPanel &&
|
||||
!this.$store.getters.mergedConfig.hideISP &&
|
||||
this.$store.state.instance.instanceSpecificPanelContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<template>
|
||||
<div class="sidebar">
|
||||
<instance-specific-panel></instance-specific-panel>
|
||||
<features-panel v-if="showFeaturesPanel"></features-panel>
|
||||
<terms-of-service-panel></terms-of-service-panel>
|
||||
<instance-specific-panel v-if="showInstanceSpecificPanel" />
|
||||
<staff-panel />
|
||||
<terms-of-service-panel />
|
||||
<MRFTransparencyPanel />
|
||||
<features-panel v-if="showFeaturesPanel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
32
src/components/account_actions/account_actions.js
Normal file
32
src/components/account_actions/account_actions.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import ProgressButton from '../progress_button/progress_button.vue'
|
||||
|
||||
const AccountActions = {
|
||||
props: [
|
||||
'user'
|
||||
],
|
||||
data () {
|
||||
return { }
|
||||
},
|
||||
components: {
|
||||
ProgressButton
|
||||
},
|
||||
methods: {
|
||||
showRepeats () {
|
||||
this.$store.dispatch('showReblogs', this.user.id)
|
||||
},
|
||||
hideRepeats () {
|
||||
this.$store.dispatch('hideReblogs', this.user.id)
|
||||
},
|
||||
blockUser () {
|
||||
this.$store.dispatch('blockUser', this.user.id)
|
||||
},
|
||||
unblockUser () {
|
||||
this.$store.dispatch('unblockUser', this.user.id)
|
||||
},
|
||||
reportUser () {
|
||||
this.$store.dispatch('openUserReportingModal', this.user.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AccountActions
|
83
src/components/account_actions/account_actions.vue
Normal file
83
src/components/account_actions/account_actions.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="account-actions">
|
||||
<v-popover
|
||||
trigger="click"
|
||||
class="account-tools-popover"
|
||||
:container="false"
|
||||
placement="bottom-end"
|
||||
:offset="5"
|
||||
>
|
||||
<div slot="popover">
|
||||
<div class="dropdown-menu">
|
||||
<template v-if="user.following">
|
||||
<button
|
||||
v-if="user.showing_reblogs"
|
||||
class="btn btn-default dropdown-item"
|
||||
@click="hideRepeats"
|
||||
>
|
||||
{{ $t('user_card.hide_repeats') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!user.showing_reblogs"
|
||||
class="btn btn-default dropdown-item"
|
||||
@click="showRepeats"
|
||||
>
|
||||
{{ $t('user_card.show_repeats') }}
|
||||
</button>
|
||||
<div
|
||||
role="separator"
|
||||
class="dropdown-divider"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
v-if="user.statusnet_blocking"
|
||||
class="btn btn-default btn-block dropdown-item"
|
||||
@click="unblockUser"
|
||||
>
|
||||
{{ $t('user_card.unblock') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-default btn-block dropdown-item"
|
||||
@click="blockUser"
|
||||
>
|
||||
{{ $t('user_card.block') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-default btn-block dropdown-item"
|
||||
@click="reportUser"
|
||||
>
|
||||
{{ $t('user_card.report') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn btn-default ellipsis-button">
|
||||
<i class="icon-ellipsis trigger-button" />
|
||||
</div>
|
||||
</v-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./account_actions.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import '../popper/popper.scss';
|
||||
.account-actions {
|
||||
margin: 0 .8em;
|
||||
}
|
||||
|
||||
.account-actions button.dropdown-item {
|
||||
margin-left: 0;
|
||||
}
|
||||
.account-actions .trigger-button {
|
||||
color: $fallback--lightText;
|
||||
color: var(--lightText, $fallback--lightText);
|
||||
opacity: .8;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -10,13 +10,14 @@ const Attachment = {
|
|||
'statusId',
|
||||
'size',
|
||||
'allowPlay',
|
||||
'setMedia'
|
||||
'setMedia',
|
||||
'naturalSizeLoad'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
|
||||
hideNsfwLocal: this.$store.state.config.hideNsfw,
|
||||
preloadImage: this.$store.state.config.preloadImage,
|
||||
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
|
||||
preloadImage: this.$store.getters.mergedConfig.preloadImage,
|
||||
loading: false,
|
||||
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
|
||||
modalOpen: false,
|
||||
|
@ -51,13 +52,13 @@ const Attachment = {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
linkClicked ({target}) {
|
||||
linkClicked ({ target }) {
|
||||
if (target.tagName === 'A') {
|
||||
window.open(target.href, '_blank')
|
||||
}
|
||||
},
|
||||
openModal (event) {
|
||||
const modalTypes = this.$store.state.config.playVideosInModal
|
||||
const modalTypes = this.$store.getters.mergedConfig.playVideosInModal
|
||||
? ['image', 'video']
|
||||
: ['image']
|
||||
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
|
||||
|
@ -70,7 +71,7 @@ const Attachment = {
|
|||
}
|
||||
},
|
||||
toggleHidden (event) {
|
||||
if (this.$store.state.config.useOneClickNsfw && !this.showHidden) {
|
||||
if (this.$store.getters.mergedConfig.useOneClickNsfw && !this.showHidden) {
|
||||
this.openModal(event)
|
||||
return
|
||||
}
|
||||
|
@ -88,6 +89,11 @@ const Attachment = {
|
|||
} else {
|
||||
this.showHidden = !this.showHidden
|
||||
}
|
||||
},
|
||||
onImageLoad (image) {
|
||||
const width = image.naturalWidth
|
||||
const height = image.naturalHeight
|
||||
this.naturalSizeLoad && this.naturalSizeLoad({ width, height })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,54 +1,107 @@
|
|||
<template>
|
||||
<div v-if="usePlaceHolder" @click="openModal">
|
||||
<a class="placeholder"
|
||||
<div
|
||||
v-if="usePlaceHolder"
|
||||
@click="openModal"
|
||||
>
|
||||
<a
|
||||
v-if="type !== 'html'"
|
||||
target="_blank" :href="attachment.url"
|
||||
class="placeholder"
|
||||
target="_blank"
|
||||
:href="attachment.url"
|
||||
>
|
||||
[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]
|
||||
[{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}]
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-else class="attachment"
|
||||
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
|
||||
v-else
|
||||
v-show="!isEmpty"
|
||||
class="attachment"
|
||||
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
|
||||
>
|
||||
<a class="image-attachment" v-if="hidden" :href="attachment.url" @click.prevent="toggleHidden">
|
||||
<img class="nsfw" :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/>
|
||||
<i v-if="type === 'video'" class="play-icon icon-play-circled"></i>
|
||||
<a
|
||||
v-if="hidden"
|
||||
class="image-attachment"
|
||||
:href="attachment.url"
|
||||
@click.prevent="toggleHidden"
|
||||
>
|
||||
<img
|
||||
:key="nsfwImage"
|
||||
class="nsfw"
|
||||
:src="nsfwImage"
|
||||
:class="{'small': isSmall}"
|
||||
>
|
||||
<i
|
||||
v-if="type === 'video'"
|
||||
class="play-icon icon-play-circled"
|
||||
/>
|
||||
</a>
|
||||
<div class="hider" v-if="nsfw && hideNsfwLocal && !hidden">
|
||||
<a href="#" @click.prevent="toggleHidden">Hide</a>
|
||||
<div
|
||||
v-if="nsfw && hideNsfwLocal && !hidden"
|
||||
class="hider"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="toggleHidden"
|
||||
>Hide</a>
|
||||
</div>
|
||||
|
||||
<a v-if="type === 'image' && (!hidden || preloadImage)"
|
||||
@click="openModal"
|
||||
<a
|
||||
v-if="type === 'image' && (!hidden || preloadImage)"
|
||||
class="image-attachment"
|
||||
:class="{'hidden': hidden && preloadImage }"
|
||||
:href="attachment.url" target="_blank"
|
||||
:href="attachment.url"
|
||||
target="_blank"
|
||||
:title="attachment.description"
|
||||
@click="openModal"
|
||||
>
|
||||
<StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
|
||||
<StillImage
|
||||
:referrerpolicy="referrerpolicy"
|
||||
:mimetype="attachment.mimetype"
|
||||
:src="attachment.large_thumb_url || attachment.url"
|
||||
:image-load-handler="onImageLoad"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a class="video-container"
|
||||
@click="openModal"
|
||||
<a
|
||||
v-if="type === 'video' && !hidden"
|
||||
class="video-container"
|
||||
:class="{'small': isSmall}"
|
||||
:href="allowPlay ? undefined : attachment.url"
|
||||
@click="openModal"
|
||||
>
|
||||
<VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" />
|
||||
<i v-if="!allowPlay" class="play-icon icon-play-circled"></i>
|
||||
<VideoAttachment
|
||||
class="video"
|
||||
:attachment="attachment"
|
||||
:controls="allowPlay"
|
||||
/>
|
||||
<i
|
||||
v-if="!allowPlay"
|
||||
class="play-icon icon-play-circled"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio>
|
||||
<audio
|
||||
v-if="type === 'audio'"
|
||||
:src="attachment.url"
|
||||
controls
|
||||
/>
|
||||
|
||||
<div @click.prevent="linkClicked" v-if="type === 'html' && attachment.oembed" class="oembed">
|
||||
<div v-if="attachment.thumb_url" class="image">
|
||||
<img :src="attachment.thumb_url"/>
|
||||
<div
|
||||
v-if="type === 'html' && attachment.oembed"
|
||||
class="oembed"
|
||||
@click.prevent="linkClicked"
|
||||
>
|
||||
<div
|
||||
v-if="attachment.thumb_url"
|
||||
class="image"
|
||||
>
|
||||
<img :src="attachment.thumb_url">
|
||||
</div>
|
||||
<div class="text">
|
||||
<h1><a :href="attachment.url">{{attachment.oembed.title}}</a></h1>
|
||||
<div v-html="attachment.oembed.oembedHTML"></div>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
|
||||
<div v-html="attachment.oembed.oembedHTML" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -68,6 +121,7 @@
|
|||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
video {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
@ -137,6 +191,7 @@
|
|||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
|
@ -233,7 +288,7 @@
|
|||
}
|
||||
|
||||
img {
|
||||
image-orientation: from-image;
|
||||
image-orientation: from-image; // NOTE: only FF supports this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
26
src/components/auth_form/auth_form.js
Normal file
26
src/components/auth_form/auth_form.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import LoginForm from '../login_form/login_form.vue'
|
||||
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
|
||||
import MFATOTPForm from '../mfa_form/totp_form.vue'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
const AuthForm = {
|
||||
name: 'AuthForm',
|
||||
render (createElement) {
|
||||
return createElement('component', { is: this.authForm })
|
||||
},
|
||||
computed: {
|
||||
authForm () {
|
||||
if (this.requiredTOTP) { return 'MFATOTPForm' }
|
||||
if (this.requiredRecovery) { return 'MFARecoveryForm' }
|
||||
return 'LoginForm'
|
||||
},
|
||||
...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery'])
|
||||
},
|
||||
components: {
|
||||
MFARecoveryForm,
|
||||
MFATOTPForm,
|
||||
LoginForm
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthForm
|
52
src/components/autosuggest/autosuggest.js
Normal file
52
src/components/autosuggest/autosuggest.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
const debounceMilliseconds = 500
|
||||
|
||||
export default {
|
||||
props: {
|
||||
query: { // function to query results and return a promise
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
filter: { // function to filter results in real time
|
||||
type: Function
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Search...'
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
term: '',
|
||||
timeout: null,
|
||||
results: [],
|
||||
resultsVisible: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filtered () {
|
||||
return this.filter ? this.filter(this.results) : this.results
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
term (val) {
|
||||
this.fetchResults(val)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchResults (term) {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = setTimeout(() => {
|
||||
this.results = []
|
||||
if (term) {
|
||||
this.query(term).then((results) => { this.results = results })
|
||||
}
|
||||
}, debounceMilliseconds)
|
||||
},
|
||||
onInputClick () {
|
||||
this.resultsVisible = true
|
||||
},
|
||||
onClickOutside () {
|
||||
this.resultsVisible = false
|
||||
}
|
||||
}
|
||||
}
|
59
src/components/autosuggest/autosuggest.vue
Normal file
59
src/components/autosuggest/autosuggest.vue
Normal file
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<div
|
||||
v-click-outside="onClickOutside"
|
||||
class="autosuggest"
|
||||
>
|
||||
<input
|
||||
v-model="term"
|
||||
:placeholder="placeholder"
|
||||
class="autosuggest-input"
|
||||
@click="onInputClick"
|
||||
>
|
||||
<div
|
||||
v-if="resultsVisible && filtered.length > 0"
|
||||
class="autosuggest-results"
|
||||
>
|
||||
<slot
|
||||
v-for="item in filtered"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./autosuggest.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.autosuggest {
|
||||
position: relative;
|
||||
|
||||
&-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-results {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
max-height: 400px;
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--lightBg, $fallback--lightBg);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-color: $fallback--border;
|
||||
border-color: var(--border, $fallback--border);
|
||||
border-radius: $fallback--inputRadius;
|
||||
border-radius: var(--inputRadius, $fallback--inputRadius);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
|
||||
box-shadow: var(--panelShadow);
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
21
src/components/avatar_list/avatar_list.js
Normal file
21
src/components/avatar_list/avatar_list.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
|
||||
const AvatarList = {
|
||||
props: ['users'],
|
||||
computed: {
|
||||
slicedUsers () {
|
||||
return this.users ? this.users.slice(0, 15) : []
|
||||
}
|
||||
},
|
||||
components: {
|
||||
UserAvatar
|
||||
},
|
||||
methods: {
|
||||
userProfileLink (user) {
|
||||
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AvatarList
|
46
src/components/avatar_list/avatar_list.vue
Normal file
46
src/components/avatar_list/avatar_list.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div class="avatars">
|
||||
<router-link
|
||||
v-for="user in slicedUsers"
|
||||
:key="user.id"
|
||||
:to="userProfileLink(user)"
|
||||
class="avatars-item"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="user"
|
||||
class="avatar-small"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./avatar_list.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.avatars {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
// For hiding overflowing elements
|
||||
flex-wrap: wrap;
|
||||
height: 24px;
|
||||
|
||||
.avatars-item {
|
||||
margin: 0 0 5px 5px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.avatar-small {
|
||||
border-radius: $fallback--avatarAltRadius;
|
||||
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,22 +1,51 @@
|
|||
<template>
|
||||
<div class="basic-user-card">
|
||||
<router-link :to="userProfileLink(user)">
|
||||
<UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/>
|
||||
<UserAvatar
|
||||
class="avatar"
|
||||
:user="user"
|
||||
@click.prevent.native="toggleUserExpanded"
|
||||
/>
|
||||
</router-link>
|
||||
<div class="basic-user-card-expanded-content" v-if="userExpanded">
|
||||
<UserCard :user="user" :rounded="true" :bordered="true"/>
|
||||
<div
|
||||
v-if="userExpanded"
|
||||
class="basic-user-card-expanded-content"
|
||||
>
|
||||
<UserCard
|
||||
:user="user"
|
||||
:rounded="true"
|
||||
:bordered="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="basic-user-card-collapsed-content" v-else>
|
||||
<div :title="user.name" class="basic-user-card-user-name">
|
||||
<span v-if="user.name_html" v-html="user.name_html"></span>
|
||||
<span v-else>{{ user.name }}</span>
|
||||
<div
|
||||
v-else
|
||||
class="basic-user-card-collapsed-content"
|
||||
>
|
||||
<div
|
||||
:title="user.name"
|
||||
class="basic-user-card-user-name"
|
||||
>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<span
|
||||
v-if="user.name_html"
|
||||
class="basic-user-card-user-name-value"
|
||||
v-html="user.name_html"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<span
|
||||
v-else
|
||||
class="basic-user-card-user-name-value"
|
||||
>{{ user.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<router-link class="basic-user-card-screen-name" :to="userProfileLink(user)">
|
||||
@{{user.screen_name}}
|
||||
<router-link
|
||||
class="basic-user-card-screen-name"
|
||||
:to="userProfileLink(user)"
|
||||
>
|
||||
@{{ user.screen_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -24,19 +53,11 @@
|
|||
<script src="./basic_user_card.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.basic-user-card {
|
||||
display: flex;
|
||||
flex: 1 0;
|
||||
margin: 0;
|
||||
padding-top: 0.6em;
|
||||
padding-right: 1em;
|
||||
padding-bottom: 0.6em;
|
||||
padding-left: 1em;
|
||||
border-bottom: 1px solid;
|
||||
border-bottom-color: $fallback--border;
|
||||
border-bottom-color: var(--border, $fallback--border);
|
||||
padding: 0.6em 1em;
|
||||
|
||||
&-collapsed-content {
|
||||
margin-left: 0.7em;
|
||||
|
@ -54,9 +75,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
&-user-name-value,
|
||||
&-screen-name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&-expanded-content {
|
||||
flex: 1;
|
||||
margin-left: 0.7em;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -9,7 +9,7 @@ const BlockCard = {
|
|||
},
|
||||
computed: {
|
||||
user () {
|
||||
return this.$store.getters.userById(this.userId)
|
||||
return this.$store.getters.findUser(this.userId)
|
||||
},
|
||||
blocked () {
|
||||
return this.user.statusnet_blocking
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
<template>
|
||||
<basic-user-card :user="user">
|
||||
<div class="block-card-content-container">
|
||||
<button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked">
|
||||
<button
|
||||
v-if="blocked"
|
||||
class="btn btn-default"
|
||||
:disabled="progress"
|
||||
@click="unblockUser"
|
||||
>
|
||||
<template v-if="progress">
|
||||
{{ $t('user_card.unblock_progress') }}
|
||||
</template>
|
||||
|
@ -9,7 +14,12 @@
|
|||
{{ $t('user_card.unblock') }}
|
||||
</template>
|
||||
</button>
|
||||
<button class="btn btn-default" @click="blockUser" :disabled="progress" v-else>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-default"
|
||||
:disabled="progress"
|
||||
@click="blockUser"
|
||||
>
|
||||
<template v-if="progress">
|
||||
{{ $t('user_card.block_progress') }}
|
||||
</template>
|
||||
|
|
|
@ -16,7 +16,7 @@ const chatPanel = {
|
|||
},
|
||||
methods: {
|
||||
submit (message) {
|
||||
this.$store.state.chat.channel.push('new_msg', {text: message}, 10000)
|
||||
this.$store.state.chat.channel.push('new_msg', { text: message }, 10000)
|
||||
this.currentMessage = ''
|
||||
},
|
||||
togglePanel () {
|
||||
|
|
|
@ -1,41 +1,70 @@
|
|||
<template>
|
||||
<div class="chat-panel" v-if="!this.collapsed || !this.floating">
|
||||
<div
|
||||
v-if="!collapsed || !floating"
|
||||
class="chat-panel"
|
||||
>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel">
|
||||
<div
|
||||
class="panel-heading timeline-heading"
|
||||
:class="{ 'chat-heading': floating }"
|
||||
@click.stop.prevent="togglePanel"
|
||||
>
|
||||
<div class="title">
|
||||
<span>{{$t('chat.title')}}</span>
|
||||
<i class="icon-cancel" v-if="floating"></i>
|
||||
<span>{{ $t('chat.title') }}</span>
|
||||
<i
|
||||
v-if="floating"
|
||||
class="icon-cancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-window" v-chat-scroll>
|
||||
<div class="chat-message" v-for="message in messages" :key="message.id">
|
||||
<div
|
||||
v-chat-scroll
|
||||
class="chat-window"
|
||||
>
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="chat-message"
|
||||
>
|
||||
<span class="chat-avatar">
|
||||
<img :src="message.author.avatar" />
|
||||
<img :src="message.author.avatar">
|
||||
</span>
|
||||
<div class="chat-content">
|
||||
<router-link
|
||||
class="chat-name"
|
||||
:to="userProfileLink(message.author)">
|
||||
{{message.author.username}}
|
||||
:to="userProfileLink(message.author)"
|
||||
>
|
||||
{{ message.author.username }}
|
||||
</router-link>
|
||||
<br>
|
||||
<span class="chat-text">
|
||||
{{message.text}}
|
||||
{{ message.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea @keyup.enter="submit(currentMessage)" v-model="currentMessage" class="chat-input-textarea" rows="1"></textarea>
|
||||
<textarea
|
||||
v-model="currentMessage"
|
||||
class="chat-input-textarea"
|
||||
rows="1"
|
||||
@keyup.enter="submit(currentMessage)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chat-panel">
|
||||
<div
|
||||
v-else
|
||||
class="chat-panel"
|
||||
>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading stub timeline-heading chat-heading" @click.stop.prevent="togglePanel">
|
||||
<div
|
||||
class="panel-heading stub timeline-heading chat-heading"
|
||||
@click.stop.prevent="togglePanel"
|
||||
>
|
||||
<div class="title">
|
||||
<i class="icon-comment-empty"></i>
|
||||
{{$t('chat.title')}}
|
||||
<i class="icon-comment-empty" />
|
||||
{{ $t('chat.title') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
105
src/components/checkbox/checkbox.vue
Normal file
105
src/components/checkbox/checkbox.vue
Normal file
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<label
|
||||
class="checkbox"
|
||||
:class="{ disabled, indeterminate }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:disabled="disabled"
|
||||
:checked="checked"
|
||||
:indeterminate.prop="indeterminate"
|
||||
@change="$emit('change', $event.target.checked)"
|
||||
>
|
||||
<i class="checkbox-indicator" />
|
||||
<span
|
||||
v-if="!!$slots.default"
|
||||
class="label"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
model: {
|
||||
prop: 'checked',
|
||||
event: 'change'
|
||||
},
|
||||
props: [
|
||||
'checked',
|
||||
'indeterminate',
|
||||
'disabled'
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-height: 1.2em;
|
||||
|
||||
&-indicator {
|
||||
position: relative;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
&-indicator::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
display: block;
|
||||
content: '✔';
|
||||
transition: color 200ms;
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
border-radius: $fallback--checkboxRadius;
|
||||
border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
|
||||
box-shadow: 0px 0px 2px black inset;
|
||||
box-shadow: var(--inputShadow);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--input, $fallback--fg);
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
line-height: 1.1em;
|
||||
font-size: 1.1em;
|
||||
color: transparent;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.checkbox-indicator::before,
|
||||
.label {
|
||||
opacity: .5;
|
||||
}
|
||||
.label {
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
}
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
display: none;
|
||||
|
||||
&:checked + .checkbox-indicator::before {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
|
||||
&:indeterminate + .checkbox-indicator::before {
|
||||
content: '–';
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
& > span {
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,33 +1,44 @@
|
|||
<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)"
|
||||
<div
|
||||
class="color-control style-control"
|
||||
:class="{ disabled: !present || disabled }"
|
||||
>
|
||||
<label
|
||||
:for="name"
|
||||
class="label"
|
||||
>
|
||||
<input
|
||||
:id="name + '-t'"
|
||||
class="text-input"
|
||||
type="text"
|
||||
:value="value || fallback"
|
||||
:disabled="!present || disabled"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
{{ label }}
|
||||
</label>
|
||||
<input
|
||||
v-if="typeof fallback !== 'undefined'"
|
||||
:id="name + '-o'"
|
||||
class="opt exlcude-disabled"
|
||||
type="checkbox"
|
||||
:checked="present"
|
||||
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
|
||||
>
|
||||
</div>
|
||||
<label
|
||||
v-if="typeof fallback !== 'undefined'"
|
||||
class="opt-l"
|
||||
:for="name + '-o'"
|
||||
/>
|
||||
<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>
|
||||
|
|
|
@ -1,28 +1,38 @@
|
|||
<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
|
||||
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 v-if="!contrast.aaa && contrast.aa">
|
||||
<i class="icon-adjust"/>
|
||||
</span>
|
||||
<span v-if="!contrast.aaa && !contrast.aa">
|
||||
<i class="icon-attention"/>
|
||||
<span
|
||||
v-if="contrast && large"
|
||||
class="rating"
|
||||
: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>
|
||||
<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>
|
||||
|
|
|
@ -1,17 +1,12 @@
|
|||
import Conversation from '../conversation/conversation.vue'
|
||||
import { find } from 'lodash'
|
||||
|
||||
const conversationPage = {
|
||||
components: {
|
||||
Conversation
|
||||
},
|
||||
computed: {
|
||||
statusoid () {
|
||||
const id = this.$route.params.id
|
||||
const statuses = this.$store.state.statuses.allStatuses
|
||||
const status = find(statuses, {id})
|
||||
|
||||
return status
|
||||
statusId () {
|
||||
return this.$route.params.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<conversation :collapsable="false" :statusoid="statusoid"></conversation>
|
||||
<conversation
|
||||
:collapsable="false"
|
||||
is-page="true"
|
||||
:status-id="statusId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./conversation-page.js"></script>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { reduce, filter } from 'lodash'
|
||||
import { reduce, filter, findIndex, clone, get } from 'lodash'
|
||||
import Status from '../status/status.vue'
|
||||
|
||||
const sortById = (a, b) => {
|
||||
const seqA = Number(a.id)
|
||||
const seqB = Number(b.id)
|
||||
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
|
||||
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
|
||||
const seqA = Number(idA)
|
||||
const seqB = Number(idB)
|
||||
const isSeqA = !Number.isNaN(seqA)
|
||||
const isSeqB = !Number.isNaN(seqB)
|
||||
if (isSeqA && isSeqB) {
|
||||
|
@ -13,49 +15,77 @@ const sortById = (a, b) => {
|
|||
} else if (!isSeqA && isSeqB) {
|
||||
return 1
|
||||
} else {
|
||||
return a.id < b.id ? -1 : 1
|
||||
return idA < idB ? -1 : 1
|
||||
}
|
||||
}
|
||||
|
||||
const sortAndFilterConversation = (conversation) => {
|
||||
conversation = filter(conversation, (status) => status.type !== 'retweet')
|
||||
const sortAndFilterConversation = (conversation, statusoid) => {
|
||||
if (statusoid.type === 'retweet') {
|
||||
conversation = filter(
|
||||
conversation,
|
||||
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
|
||||
)
|
||||
} else {
|
||||
conversation = filter(conversation, (status) => status.type !== 'retweet')
|
||||
}
|
||||
return conversation.filter(_ => _).sort(sortById)
|
||||
}
|
||||
|
||||
const conversation = {
|
||||
data () {
|
||||
return {
|
||||
highlight: null
|
||||
highlight: null,
|
||||
expanded: false
|
||||
}
|
||||
},
|
||||
props: [
|
||||
'statusoid',
|
||||
'collapsable'
|
||||
'statusId',
|
||||
'collapsable',
|
||||
'isPage',
|
||||
'pinnedStatusIdsObject',
|
||||
'inProfile',
|
||||
'profileUserId'
|
||||
],
|
||||
created () {
|
||||
if (this.isPage) {
|
||||
this.fetchConversation()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
status () {
|
||||
return this.statusoid
|
||||
return this.$store.state.statuses.allStatusesObject[this.statusId]
|
||||
},
|
||||
statusId () {
|
||||
if (this.statusoid.retweeted_status) {
|
||||
return this.statusoid.retweeted_status.id
|
||||
originalStatusId () {
|
||||
if (this.status.retweeted_status) {
|
||||
return this.status.retweeted_status.id
|
||||
} else {
|
||||
return this.statusoid.id
|
||||
return this.statusId
|
||||
}
|
||||
},
|
||||
conversationId () {
|
||||
return this.getConversationId(this.statusId)
|
||||
},
|
||||
conversation () {
|
||||
if (!this.status) {
|
||||
return []
|
||||
}
|
||||
|
||||
const conversationId = this.status.statusnet_conversation_id
|
||||
const statuses = this.$store.state.statuses.allStatuses
|
||||
const conversation = filter(statuses, { statusnet_conversation_id: conversationId })
|
||||
return sortAndFilterConversation(conversation)
|
||||
if (!this.isExpanded) {
|
||||
return [this.status]
|
||||
}
|
||||
|
||||
const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
|
||||
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
|
||||
if (statusIndex !== -1) {
|
||||
conversation[statusIndex] = this.status
|
||||
}
|
||||
|
||||
return sortAndFilterConversation(conversation, this.status)
|
||||
},
|
||||
replies () {
|
||||
let i = 1
|
||||
return reduce(this.conversation, (result, {id, in_reply_to_status_id}) => {
|
||||
// eslint-disable-next-line camelcase
|
||||
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
|
||||
/* eslint-disable camelcase */
|
||||
const irid = in_reply_to_status_id
|
||||
/* eslint-enable camelcase */
|
||||
|
@ -69,39 +99,67 @@ const conversation = {
|
|||
i++
|
||||
return result
|
||||
}, {})
|
||||
},
|
||||
isExpanded () {
|
||||
return this.expanded || this.isPage
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Status
|
||||
},
|
||||
created () {
|
||||
this.fetchConversation()
|
||||
},
|
||||
watch: {
|
||||
'$route': 'fetchConversation'
|
||||
statusId (newVal, oldVal) {
|
||||
const newConversationId = this.getConversationId(newVal)
|
||||
const oldConversationId = this.getConversationId(oldVal)
|
||||
if (newConversationId && oldConversationId && newConversationId === oldConversationId) {
|
||||
this.setHighlight(this.originalStatusId)
|
||||
} else {
|
||||
this.fetchConversation()
|
||||
}
|
||||
},
|
||||
expanded (value) {
|
||||
if (value) {
|
||||
this.fetchConversation()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchConversation () {
|
||||
if (this.status) {
|
||||
const conversationId = this.status.statusnet_conversation_id
|
||||
this.$store.state.api.backendInteractor.fetchConversation({id: conversationId})
|
||||
.then((statuses) => this.$store.dispatch('addNewStatuses', { statuses }))
|
||||
.then(() => this.setHighlight(this.statusId))
|
||||
this.$store.state.api.backendInteractor.fetchConversation({ id: this.statusId })
|
||||
.then(({ ancestors, descendants }) => {
|
||||
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
|
||||
this.$store.dispatch('addNewStatuses', { statuses: descendants })
|
||||
this.setHighlight(this.originalStatusId)
|
||||
})
|
||||
} else {
|
||||
const id = this.$route.params.id
|
||||
this.$store.state.api.backendInteractor.fetchStatus({id})
|
||||
.then((status) => this.$store.dispatch('addNewStatuses', { statuses: [status] }))
|
||||
.then(() => this.fetchConversation())
|
||||
this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId })
|
||||
.then((status) => {
|
||||
this.$store.dispatch('addNewStatuses', { statuses: [status] })
|
||||
this.fetchConversation()
|
||||
})
|
||||
}
|
||||
},
|
||||
getReplies (id) {
|
||||
return this.replies[id] || []
|
||||
},
|
||||
focused (id) {
|
||||
return id === this.statusId
|
||||
return (this.isExpanded) && id === this.statusId
|
||||
},
|
||||
setHighlight (id) {
|
||||
if (!id) return
|
||||
this.highlight = id
|
||||
this.$store.dispatch('fetchFavsAndRepeats', id)
|
||||
},
|
||||
getHighlight () {
|
||||
return this.isExpanded ? this.highlight : null
|
||||
},
|
||||
toggleExpanded () {
|
||||
this.expanded = !this.expanded
|
||||
},
|
||||
getConversationId (statusId) {
|
||||
const status = this.$store.state.statuses.allStatusesObject[statusId]
|
||||
return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,26 +1,54 @@
|
|||
<template>
|
||||
<div class="timeline panel panel-default">
|
||||
<div class="panel-heading conversation-heading">
|
||||
<div
|
||||
class="timeline panel-default"
|
||||
:class="[isExpanded ? 'panel' : 'panel-disabled']"
|
||||
>
|
||||
<div
|
||||
v-if="isExpanded"
|
||||
class="panel-heading conversation-heading"
|
||||
>
|
||||
<span class="title"> {{ $t('timeline.conversation') }} </span>
|
||||
<span v-if="collapsable">
|
||||
<a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="toggleExpanded"
|
||||
>{{ $t('timeline.collapse') }}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="timeline">
|
||||
<status
|
||||
v-for="status in conversation"
|
||||
@goto="setHighlight" :key="status.id"
|
||||
:inlineExpanded="collapsable" :statusoid="status"
|
||||
:expandable='false' :focused="focused(status.id)"
|
||||
:inConversation='true'
|
||||
:highlight="highlight"
|
||||
:replies="getReplies(status.id)"
|
||||
class="status-fadein">
|
||||
</status>
|
||||
</div>
|
||||
</div>
|
||||
<status
|
||||
v-for="status in conversation"
|
||||
:key="status.id"
|
||||
:inline-expanded="collapsable && isExpanded"
|
||||
:statusoid="status"
|
||||
:expandable="!isExpanded"
|
||||
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
|
||||
:focused="focused(status.id)"
|
||||
:in-conversation="isExpanded"
|
||||
:highlight="getHighlight()"
|
||||
:replies="getReplies(status.id)"
|
||||
:in-profile="inProfile"
|
||||
:profile-user-id="profileUserId"
|
||||
class="status-fadein panel-body"
|
||||
@goto="setHighlight"
|
||||
@toggleExpanded="toggleExpanded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./conversation.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.timeline {
|
||||
.panel-disabled {
|
||||
.status-el {
|
||||
border-left: none;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
border-color: var(--border, $fallback--border);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
const DeleteButton = {
|
||||
props: [ 'status' ],
|
||||
methods: {
|
||||
deleteStatus () {
|
||||
const confirmed = window.confirm('Do you really want to delete this status?')
|
||||
if (confirmed) {
|
||||
this.$store.dispatch('deleteStatus', { id: this.status.id })
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentUser () { return this.$store.state.users.currentUser },
|
||||
canDelete () { return this.currentUser && this.currentUser.rights.delete_others_notice || this.status.user.id === this.currentUser.id }
|
||||
}
|
||||
}
|
||||
|
||||
export default DeleteButton
|
|
@ -1,21 +0,0 @@
|
|||
<template>
|
||||
<div v-if="canDelete">
|
||||
<a href="#" v-on:click.prevent="deleteStatus()">
|
||||
<i class='button-icon icon-cancel delete-status'></i>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./delete_button.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.icon-cancel,.delete-status {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: $fallback--cRed;
|
||||
color: var(--cRed, $fallback--cRed);
|
||||
}
|
||||
}
|
||||
</style>
|
14
src/components/dialog_modal/dialog_modal.js
Normal file
14
src/components/dialog_modal/dialog_modal.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
const DialogModal = {
|
||||
props: {
|
||||
darkOverlay: {
|
||||
default: true,
|
||||
type: Boolean
|
||||
},
|
||||
onCancel: {
|
||||
default: () => {},
|
||||
type: Function
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DialogModal
|
100
src/components/dialog_modal/dialog_modal.vue
Normal file
100
src/components/dialog_modal/dialog_modal.vue
Normal file
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<span
|
||||
:class="{ 'dark-overlay': darkOverlay }"
|
||||
@click.self.stop="onCancel()"
|
||||
>
|
||||
<div
|
||||
class="dialog-modal panel panel-default"
|
||||
@click.stop=""
|
||||
>
|
||||
<div class="panel-heading dialog-modal-heading">
|
||||
<div class="title">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-modal-content">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
<div class="dialog-modal-footer user-interactions panel-footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script src="./dialog_modal.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
// TODO: unify with other modals.
|
||||
.dark-overlay {
|
||||
&::before {
|
||||
bottom: 0;
|
||||
content: " ";
|
||||
display: block;
|
||||
cursor: default;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background: rgba(27,31,35,.5);
|
||||
z-index: 99;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-modal.panel {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
max-height: 80vh;
|
||||
max-width: 90vw;
|
||||
margin: 15vh auto;
|
||||
position: fixed;
|
||||
transform: translateX(-50%);
|
||||
z-index: 999;
|
||||
cursor: default;
|
||||
display: block;
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--bg, $fallback--bg);
|
||||
|
||||
.dialog-modal-heading {
|
||||
padding: .5em .5em;
|
||||
margin-right: auto;
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--panelText);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--panel, $fallback--fg);
|
||||
|
||||
.title {
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-modal-content {
|
||||
margin: 0;
|
||||
padding: 1rem 1rem;
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--lightBg, $fallback--lightBg);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.dialog-modal-footer {
|
||||
margin: 0;
|
||||
padding: .5em .5em;
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--lightBg, $fallback--lightBg);
|
||||
border-top: 1px solid $fallback--bg;
|
||||
border-top: 1px solid var(--bg, $fallback--bg);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
width: auto;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<Timeline :title="$t('nav.dms')" v-bind:timeline="timeline" v-bind:timeline-name="'dms'"/>
|
||||
<Timeline
|
||||
:title="$t('nav.dms')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'dms'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./dm_timeline.js"></script>
|
||||
|
|
446
src/components/emoji_input/emoji_input.js
Normal file
446
src/components/emoji_input/emoji_input.js
Normal file
|
@ -0,0 +1,446 @@
|
|||
import Completion from '../../services/completion/completion.js'
|
||||
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
|
||||
import { take } from 'lodash'
|
||||
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
|
||||
|
||||
/**
|
||||
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
|
||||
* without having to give up the comfort of <input/> and <textarea/> elements
|
||||
*
|
||||
* Intended usage is:
|
||||
* <EmojiInput v-model="something">
|
||||
* <input v-model="something"/>
|
||||
* </EmojiInput>
|
||||
*
|
||||
* Works only with <input> and <textarea>. Intended to use with only one nested
|
||||
* input. It will find first input or textarea and work with that, multiple
|
||||
* nested children not tested. You HAVE TO duplicate v-model for both
|
||||
* <emoji-input> and <input>/<textarea> otherwise it will not work.
|
||||
*
|
||||
* Be prepared for CSS troubles though because it still wraps component in a div
|
||||
* while TRYING to make it look like nothing happened, but it could break stuff.
|
||||
*/
|
||||
|
||||
const EmojiInput = {
|
||||
props: {
|
||||
suggest: {
|
||||
/**
|
||||
* suggest: function (input: String) => Suggestion[]
|
||||
*
|
||||
* Function that takes input string which takes string (textAtCaret)
|
||||
* and returns an array of Suggestions
|
||||
*
|
||||
* Suggestion is an object containing following properties:
|
||||
* displayText: string. Main display text, what actual suggestion
|
||||
* represents (user's screen name/emoji shortcode)
|
||||
* replacement: string. Text that should replace the textAtCaret
|
||||
* detailText: string, optional. Subtitle text, providing additional info
|
||||
* if present (user's nickname)
|
||||
* imageUrl: string, optional. Image to display alongside with suggestion,
|
||||
* currently if no image is provided, replacement will be used (for
|
||||
* unicode emojis)
|
||||
*
|
||||
* TODO: make it asynchronous when adding proper server-provided user
|
||||
* suggestions
|
||||
*
|
||||
* For commonly used suggestors (emoji, users, both) use suggestor.js
|
||||
*/
|
||||
required: true,
|
||||
type: Function
|
||||
},
|
||||
value: {
|
||||
/**
|
||||
* Used for v-model
|
||||
*/
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
enableEmojiPicker: {
|
||||
/**
|
||||
* Enables emoji picker support, this implies that custom emoji are supported
|
||||
*/
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hideEmojiButton: {
|
||||
/**
|
||||
* intended to use with external picker trigger, i.e. you have a button outside
|
||||
* input that will open up the picker, see triggerShowPicker()
|
||||
*/
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableStickerPicker: {
|
||||
/**
|
||||
* Enables sticker picker support, only makes sense when enableEmojiPicker=true
|
||||
*/
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
input: undefined,
|
||||
highlighted: 0,
|
||||
caret: 0,
|
||||
focused: false,
|
||||
blurTimeout: null,
|
||||
showPicker: false,
|
||||
temporarilyHideSuggestions: false,
|
||||
keepOpen: false,
|
||||
disableClickOutside: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
EmojiPicker
|
||||
},
|
||||
computed: {
|
||||
padEmoji () {
|
||||
return this.$store.getters.mergedConfig.padEmoji
|
||||
},
|
||||
suggestions () {
|
||||
const firstchar = this.textAtCaret.charAt(0)
|
||||
if (this.textAtCaret === firstchar) { return [] }
|
||||
const matchedSuggestions = this.suggest(this.textAtCaret)
|
||||
if (matchedSuggestions.length <= 0) {
|
||||
return []
|
||||
}
|
||||
return take(matchedSuggestions, 5)
|
||||
.map(({ imageUrl, ...rest }, index) => ({
|
||||
...rest,
|
||||
// eslint-disable-next-line camelcase
|
||||
img: imageUrl || '',
|
||||
highlighted: index === this.highlighted
|
||||
}))
|
||||
},
|
||||
showSuggestions () {
|
||||
return this.focused &&
|
||||
this.suggestions &&
|
||||
this.suggestions.length > 0 &&
|
||||
!this.showPicker &&
|
||||
!this.temporarilyHideSuggestions
|
||||
},
|
||||
textAtCaret () {
|
||||
return (this.wordAtCaret || {}).word || ''
|
||||
},
|
||||
wordAtCaret () {
|
||||
if (this.value && this.caret) {
|
||||
const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
|
||||
return word
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
const slots = this.$slots.default
|
||||
if (!slots || slots.length === 0) return
|
||||
const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag))
|
||||
if (!input) return
|
||||
this.input = input
|
||||
this.resize()
|
||||
input.elm.addEventListener('blur', this.onBlur)
|
||||
input.elm.addEventListener('focus', this.onFocus)
|
||||
input.elm.addEventListener('paste', this.onPaste)
|
||||
input.elm.addEventListener('keyup', this.onKeyUp)
|
||||
input.elm.addEventListener('keydown', this.onKeyDown)
|
||||
input.elm.addEventListener('click', this.onClickInput)
|
||||
input.elm.addEventListener('transitionend', this.onTransition)
|
||||
input.elm.addEventListener('compositionupdate', this.onCompositionUpdate)
|
||||
},
|
||||
unmounted () {
|
||||
const { input } = this
|
||||
if (input) {
|
||||
input.elm.removeEventListener('blur', this.onBlur)
|
||||
input.elm.removeEventListener('focus', this.onFocus)
|
||||
input.elm.removeEventListener('paste', this.onPaste)
|
||||
input.elm.removeEventListener('keyup', this.onKeyUp)
|
||||
input.elm.removeEventListener('keydown', this.onKeyDown)
|
||||
input.elm.removeEventListener('click', this.onClickInput)
|
||||
input.elm.removeEventListener('transitionend', this.onTransition)
|
||||
input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
triggerShowPicker () {
|
||||
this.showPicker = true
|
||||
this.$refs.picker.startEmojiLoad()
|
||||
this.$nextTick(() => {
|
||||
this.scrollIntoView()
|
||||
})
|
||||
// This temporarily disables "click outside" handler
|
||||
// since external trigger also means click originates
|
||||
// from outside, thus preventing picker from opening
|
||||
this.disableClickOutside = true
|
||||
setTimeout(() => {
|
||||
this.disableClickOutside = false
|
||||
}, 0)
|
||||
},
|
||||
togglePicker () {
|
||||
this.input.elm.focus()
|
||||
this.showPicker = !this.showPicker
|
||||
if (this.showPicker) {
|
||||
this.scrollIntoView()
|
||||
this.$refs.picker.startEmojiLoad()
|
||||
}
|
||||
},
|
||||
replace (replacement) {
|
||||
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
|
||||
this.$emit('input', newValue)
|
||||
this.caret = 0
|
||||
},
|
||||
insert ({ insertion, keepOpen }) {
|
||||
const before = this.value.substring(0, this.caret) || ''
|
||||
const after = this.value.substring(this.caret) || ''
|
||||
|
||||
/* Using a bit more smart approach to padding emojis with spaces:
|
||||
* - put a space before cursor if there isn't one already, unless we
|
||||
* are at the beginning of post or in spam mode
|
||||
* - put a space after emoji if there isn't one already unless we are
|
||||
* in spam mode
|
||||
*
|
||||
* The idea is that when you put a cursor somewhere in between sentence
|
||||
* inserting just ' :emoji: ' will add more spaces to post which might
|
||||
* break the flow/spacing, as well as the case where user ends sentence
|
||||
* with a space before adding emoji.
|
||||
*
|
||||
* Spam mode is intended for creating multi-part emojis and overall spamming
|
||||
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
|
||||
*/
|
||||
const isSpaceRegex = /\s/
|
||||
const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : ''
|
||||
const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
|
||||
|
||||
const newValue = [
|
||||
before,
|
||||
spaceBefore,
|
||||
insertion,
|
||||
spaceAfter,
|
||||
after
|
||||
].join('')
|
||||
this.keepOpen = keepOpen
|
||||
this.$emit('input', newValue)
|
||||
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
|
||||
if (!keepOpen) {
|
||||
this.input.elm.focus()
|
||||
}
|
||||
|
||||
this.$nextTick(function () {
|
||||
// Re-focus inputbox after clicking suggestion
|
||||
// Set selection right after the replacement instead of the very end
|
||||
this.input.elm.setSelectionRange(position, position)
|
||||
this.caret = position
|
||||
})
|
||||
},
|
||||
replaceText (e, suggestion) {
|
||||
const len = this.suggestions.length || 0
|
||||
if (this.textAtCaret.length === 1) { return }
|
||||
if (len > 0 || suggestion) {
|
||||
const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
|
||||
const replacement = chosenSuggestion.replacement
|
||||
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
|
||||
this.$emit('input', newValue)
|
||||
this.highlighted = 0
|
||||
const position = this.wordAtCaret.start + replacement.length
|
||||
|
||||
this.$nextTick(function () {
|
||||
// Re-focus inputbox after clicking suggestion
|
||||
this.input.elm.focus()
|
||||
// Set selection right after the replacement instead of the very end
|
||||
this.input.elm.setSelectionRange(position, position)
|
||||
this.caret = position
|
||||
})
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
cycleBackward (e) {
|
||||
const len = this.suggestions.length || 0
|
||||
if (len > 1) {
|
||||
this.highlighted -= 1
|
||||
if (this.highlighted < 0) {
|
||||
this.highlighted = this.suggestions.length - 1
|
||||
}
|
||||
e.preventDefault()
|
||||
} else {
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
cycleForward (e) {
|
||||
const len = this.suggestions.length || 0
|
||||
if (len > 1) {
|
||||
this.highlighted += 1
|
||||
if (this.highlighted >= len) {
|
||||
this.highlighted = 0
|
||||
}
|
||||
e.preventDefault()
|
||||
} else {
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
scrollIntoView () {
|
||||
const rootRef = this.$refs['picker'].$el
|
||||
/* Scroller is either `window` (replies in TL), sidebar (main post form,
|
||||
* replies in notifs) or mobile post form. Note that getting and setting
|
||||
* scroll is different for `Window` and `Element`s
|
||||
*/
|
||||
const scrollerRef = this.$el.closest('.sidebar-scroller') ||
|
||||
this.$el.closest('.post-form-modal-view') ||
|
||||
window
|
||||
const currentScroll = scrollerRef === window
|
||||
? scrollerRef.scrollY
|
||||
: scrollerRef.scrollTop
|
||||
const scrollerHeight = scrollerRef === window
|
||||
? scrollerRef.innerHeight
|
||||
: scrollerRef.offsetHeight
|
||||
|
||||
const scrollerBottomBorder = currentScroll + scrollerHeight
|
||||
// We check where the bottom border of root element is, this uses findOffset
|
||||
// to find offset relative to scrollable container (scroller)
|
||||
const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
|
||||
|
||||
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
|
||||
// could also check top delta but there's no case for it
|
||||
const targetScroll = currentScroll + bottomDelta
|
||||
|
||||
if (scrollerRef === window) {
|
||||
scrollerRef.scroll(0, targetScroll)
|
||||
} else {
|
||||
scrollerRef.scrollTop = targetScroll
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
const { offsetHeight } = this.input.elm
|
||||
const { picker } = this.$refs
|
||||
const pickerBottom = picker.$el.getBoundingClientRect().bottom
|
||||
if (pickerBottom > window.innerHeight) {
|
||||
picker.$el.style.top = 'auto'
|
||||
picker.$el.style.bottom = offsetHeight + 'px'
|
||||
}
|
||||
})
|
||||
},
|
||||
onTransition (e) {
|
||||
this.resize()
|
||||
},
|
||||
onBlur (e) {
|
||||
// Clicking on any suggestion removes focus from autocomplete,
|
||||
// preventing click handler ever executing.
|
||||
this.blurTimeout = setTimeout(() => {
|
||||
this.focused = false
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
}, 200)
|
||||
},
|
||||
onClick (e, suggestion) {
|
||||
this.replaceText(e, suggestion)
|
||||
},
|
||||
onFocus (e) {
|
||||
if (this.blurTimeout) {
|
||||
clearTimeout(this.blurTimeout)
|
||||
this.blurTimeout = null
|
||||
}
|
||||
|
||||
if (!this.keepOpen) {
|
||||
this.showPicker = false
|
||||
}
|
||||
this.focused = true
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
this.temporarilyHideSuggestions = false
|
||||
},
|
||||
onKeyUp (e) {
|
||||
const { key } = e
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
|
||||
// Setting hider in keyUp to prevent suggestions from blinking
|
||||
// when moving away from suggested spot
|
||||
if (key === 'Escape') {
|
||||
this.temporarilyHideSuggestions = true
|
||||
} else {
|
||||
this.temporarilyHideSuggestions = false
|
||||
}
|
||||
},
|
||||
onPaste (e) {
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
},
|
||||
onKeyDown (e) {
|
||||
const { ctrlKey, shiftKey, key } = e
|
||||
// Disable suggestions hotkeys if suggestions are hidden
|
||||
if (!this.temporarilyHideSuggestions) {
|
||||
if (key === 'Tab') {
|
||||
if (shiftKey) {
|
||||
this.cycleBackward(e)
|
||||
} else {
|
||||
this.cycleForward(e)
|
||||
}
|
||||
}
|
||||
if (key === 'ArrowUp') {
|
||||
this.cycleBackward(e)
|
||||
} else if (key === 'ArrowDown') {
|
||||
this.cycleForward(e)
|
||||
}
|
||||
if (key === 'Enter') {
|
||||
if (!ctrlKey) {
|
||||
this.replaceText(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Probably add optional keyboard controls for emoji picker?
|
||||
|
||||
// Escape hides suggestions, if suggestions are hidden it
|
||||
// de-focuses the element (i.e. default browser behavior)
|
||||
if (key === 'Escape') {
|
||||
if (!this.temporarilyHideSuggestions) {
|
||||
this.input.elm.focus()
|
||||
}
|
||||
}
|
||||
|
||||
this.showPicker = false
|
||||
this.resize()
|
||||
},
|
||||
onInput (e) {
|
||||
this.showPicker = false
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
this.$emit('input', e.target.value)
|
||||
},
|
||||
onCompositionUpdate (e) {
|
||||
this.showPicker = false
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
this.$emit('input', e.target.value)
|
||||
},
|
||||
onClickInput (e) {
|
||||
this.showPicker = false
|
||||
},
|
||||
onClickOutside (e) {
|
||||
if (this.disableClickOutside) return
|
||||
this.showPicker = false
|
||||
},
|
||||
onStickerUploaded (e) {
|
||||
this.showPicker = false
|
||||
this.$emit('sticker-uploaded', e)
|
||||
},
|
||||
onStickerUploadFailed (e) {
|
||||
this.showPicker = false
|
||||
this.$emit('sticker-upload-Failed', e)
|
||||
},
|
||||
setCaret ({ target: { selectionStart } }) {
|
||||
this.caret = selectionStart
|
||||
},
|
||||
resize () {
|
||||
const { panel, picker } = this.$refs
|
||||
if (!panel) return
|
||||
const { offsetHeight, offsetTop } = this.input.elm
|
||||
const offsetBottom = offsetTop + offsetHeight
|
||||
|
||||
panel.style.top = offsetBottom + 'px'
|
||||
picker.$el.style.top = offsetBottom + 'px'
|
||||
picker.$el.style.bottom = 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiInput
|
169
src/components/emoji_input/emoji_input.vue
Normal file
169
src/components/emoji_input/emoji_input.vue
Normal file
|
@ -0,0 +1,169 @@
|
|||
<template>
|
||||
<div
|
||||
v-click-outside="onClickOutside"
|
||||
class="emoji-input"
|
||||
:class="{ 'with-picker': !hideEmojiButton }"
|
||||
>
|
||||
<slot />
|
||||
<template v-if="enableEmojiPicker">
|
||||
<div
|
||||
v-if="!hideEmojiButton"
|
||||
class="emoji-picker-icon"
|
||||
@click.prevent="togglePicker"
|
||||
>
|
||||
<i class="icon-smile" />
|
||||
</div>
|
||||
<EmojiPicker
|
||||
v-if="enableEmojiPicker"
|
||||
ref="picker"
|
||||
:class="{ hide: !showPicker }"
|
||||
:enable-sticker-picker="enableStickerPicker"
|
||||
class="emoji-picker-panel"
|
||||
@emoji="insert"
|
||||
@sticker-uploaded="onStickerUploaded"
|
||||
@sticker-upload-failed="onStickerUploadFailed"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
ref="panel"
|
||||
class="autocomplete-panel"
|
||||
:class="{ hide: !showSuggestions }"
|
||||
>
|
||||
<div class="autocomplete-panel-body">
|
||||
<div
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="index"
|
||||
class="autocomplete-item"
|
||||
:class="{ highlighted: suggestion.highlighted }"
|
||||
@click.stop.prevent="onClick($event, suggestion)"
|
||||
>
|
||||
<span class="image">
|
||||
<img
|
||||
v-if="suggestion.img"
|
||||
:src="suggestion.img"
|
||||
>
|
||||
<span v-else>{{ suggestion.replacement }}</span>
|
||||
</span>
|
||||
<div class="label">
|
||||
<span class="displayText">{{ suggestion.displayText }}</span>
|
||||
<span class="detailText">{{ suggestion.detailText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./emoji_input.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.emoji-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&.with-picker input {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.emoji-picker-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: .2em .25em;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
line-height: 24px;
|
||||
|
||||
&:hover i {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
.emoji-picker-panel {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
margin-top: 2px;
|
||||
|
||||
&.hide {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
&-panel {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
margin-top: 2px;
|
||||
|
||||
&.hide {
|
||||
display: none
|
||||
}
|
||||
|
||||
&-body {
|
||||
margin: 0 0.5em 0 0.5em;
|
||||
border-radius: $fallback--tooltipRadius;
|
||||
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
|
||||
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: var(--popupShadow);
|
||||
min-width: 75%;
|
||||
background: $fallback--bg;
|
||||
background: var(--bg, $fallback--bg);
|
||||
color: $fallback--lightText;
|
||||
color: var(--lightText, $fallback--lightText);
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
padding: 0.2em 0.4em;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
|
||||
height: 32px;
|
||||
|
||||
.image {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
|
||||
margin-right: 4px;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 0.1em 0 0.2em;
|
||||
|
||||
.displayText {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detailText {
|
||||
font-size: 9px;
|
||||
line-height: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--lightBg, $fallback--fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
94
src/components/emoji_input/suggestor.js
Normal file
94
src/components/emoji_input/suggestor.js
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { debounce } from 'lodash'
|
||||
/**
|
||||
* suggest - generates a suggestor function to be used by emoji-input
|
||||
* data: object providing source information for specific types of suggestions:
|
||||
* data.emoji - optional, an array of all emoji available i.e.
|
||||
* (state.instance.emoji + state.instance.customEmoji)
|
||||
* data.users - optional, an array of all known users
|
||||
* updateUsersList - optional, a function to search and append to users
|
||||
*
|
||||
* Depending on data present one or both (or none) can be present, so if field
|
||||
* doesn't support user linking you can just provide only emoji.
|
||||
*/
|
||||
|
||||
const debounceUserSearch = debounce((data, input) => {
|
||||
data.updateUsersList(input)
|
||||
}, 500, { leading: true, trailing: false })
|
||||
|
||||
export default data => input => {
|
||||
const firstChar = input[0]
|
||||
if (firstChar === ':' && data.emoji) {
|
||||
return suggestEmoji(data.emoji)(input)
|
||||
}
|
||||
if (firstChar === '@' && data.users) {
|
||||
return suggestUsers(data)(input)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export const suggestEmoji = emojis => input => {
|
||||
const noPrefix = input.toLowerCase().substr(1)
|
||||
return emojis
|
||||
.filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix))
|
||||
.sort((a, b) => {
|
||||
let aScore = 0
|
||||
let bScore = 0
|
||||
|
||||
// Make custom emojis a priority
|
||||
aScore += a.imageUrl ? 10 : 0
|
||||
bScore += b.imageUrl ? 10 : 0
|
||||
|
||||
// Sort alphabetically
|
||||
const alphabetically = a.displayText > b.displayText ? 1 : -1
|
||||
|
||||
return bScore - aScore + alphabetically
|
||||
})
|
||||
}
|
||||
|
||||
export const suggestUsers = data => input => {
|
||||
const noPrefix = input.toLowerCase().substr(1)
|
||||
const users = data.users
|
||||
|
||||
const newUsers = users.filter(
|
||||
user =>
|
||||
user.screen_name.toLowerCase().startsWith(noPrefix) ||
|
||||
user.name.toLowerCase().startsWith(noPrefix)
|
||||
|
||||
/* taking only 20 results so that sorting is a bit cheaper, we display
|
||||
* only 5 anyway. could be inaccurate, but we ideally we should query
|
||||
* backend anyway
|
||||
*/
|
||||
).slice(0, 20).sort((a, b) => {
|
||||
let aScore = 0
|
||||
let bScore = 0
|
||||
|
||||
// Matches on screen name (i.e. user@instance) makes a priority
|
||||
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
|
||||
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
|
||||
|
||||
// Matches on name takes second priority
|
||||
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
|
||||
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
|
||||
|
||||
const diff = (bScore - aScore) * 10
|
||||
|
||||
// Then sort alphabetically
|
||||
const nameAlphabetically = a.name > b.name ? 1 : -1
|
||||
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
|
||||
|
||||
return diff + nameAlphabetically + screenNameAlphabetically
|
||||
/* eslint-disable camelcase */
|
||||
}).map(({ screen_name, name, profile_image_url_original }) => ({
|
||||
displayText: screen_name,
|
||||
detailText: name,
|
||||
imageUrl: profile_image_url_original,
|
||||
replacement: '@' + screen_name + ' '
|
||||
}))
|
||||
|
||||
// BE search users if there are no matches
|
||||
if (newUsers.length === 0 && data.updateUsersList) {
|
||||
debounceUserSearch(data, noPrefix)
|
||||
}
|
||||
return newUsers
|
||||
/* eslint-enable camelcase */
|
||||
}
|
187
src/components/emoji_picker/emoji_picker.js
Normal file
187
src/components/emoji_picker/emoji_picker.js
Normal file
|
@ -0,0 +1,187 @@
|
|||
import Checkbox from '../checkbox/checkbox.vue'
|
||||
|
||||
// At widest, approximately 20 emoji are visible in a row,
|
||||
// loading 3 rows, could be overkill for narrow picker
|
||||
const LOAD_EMOJI_BY = 60
|
||||
|
||||
// When to start loading new batch emoji, in pixels
|
||||
const LOAD_EMOJI_MARGIN = 64
|
||||
|
||||
const filterByKeyword = (list, keyword = '') => {
|
||||
return list.filter(x => x.displayText.includes(keyword))
|
||||
}
|
||||
|
||||
const EmojiPicker = {
|
||||
props: {
|
||||
enableStickerPicker: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
keyword: '',
|
||||
activeGroup: 'custom',
|
||||
showingStickers: false,
|
||||
groupsScrolledClass: 'scrolled-top',
|
||||
keepOpen: false,
|
||||
customEmojiBufferSlice: LOAD_EMOJI_BY,
|
||||
customEmojiTimeout: null,
|
||||
customEmojiLoadAllConfirmed: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
StickerPicker: () => import('../sticker_picker/sticker_picker.vue'),
|
||||
Checkbox
|
||||
},
|
||||
methods: {
|
||||
onStickerUploaded (e) {
|
||||
this.$emit('sticker-uploaded', e)
|
||||
},
|
||||
onStickerUploadFailed (e) {
|
||||
this.$emit('sticker-upload-failed', e)
|
||||
},
|
||||
onEmoji (emoji) {
|
||||
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
|
||||
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
|
||||
},
|
||||
onScroll (e) {
|
||||
const target = (e && e.target) || this.$refs['emoji-groups']
|
||||
this.updateScrolledClass(target)
|
||||
this.scrolledGroup(target)
|
||||
this.triggerLoadMore(target)
|
||||
},
|
||||
highlight (key) {
|
||||
const ref = this.$refs['group-' + key]
|
||||
const top = ref[0].offsetTop
|
||||
this.setShowStickers(false)
|
||||
this.activeGroup = key
|
||||
this.$nextTick(() => {
|
||||
this.$refs['emoji-groups'].scrollTop = top + 1
|
||||
})
|
||||
},
|
||||
updateScrolledClass (target) {
|
||||
if (target.scrollTop <= 5) {
|
||||
this.groupsScrolledClass = 'scrolled-top'
|
||||
} else if (target.scrollTop >= target.scrollTopMax - 5) {
|
||||
this.groupsScrolledClass = 'scrolled-bottom'
|
||||
} else {
|
||||
this.groupsScrolledClass = 'scrolled-middle'
|
||||
}
|
||||
},
|
||||
triggerLoadMore (target) {
|
||||
const ref = this.$refs['group-end-custom'][0]
|
||||
if (!ref) return
|
||||
const bottom = ref.offsetTop + ref.offsetHeight
|
||||
|
||||
const scrollerBottom = target.scrollTop + target.clientHeight
|
||||
const scrollerTop = target.scrollTop
|
||||
const scrollerMax = target.scrollHeight
|
||||
|
||||
// Loads more emoji when they come into view
|
||||
const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
|
||||
// Always load when at the very top in case there's no scroll space yet
|
||||
const atTop = scrollerTop < 5
|
||||
// Don't load when looking at unicode category or at the very bottom
|
||||
const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
|
||||
if (!bottomAboveViewport && (approachingBottom || atTop)) {
|
||||
this.loadEmoji()
|
||||
}
|
||||
},
|
||||
scrolledGroup (target) {
|
||||
const top = target.scrollTop + 5
|
||||
this.$nextTick(() => {
|
||||
this.emojisView.forEach(group => {
|
||||
const ref = this.$refs['group-' + group.id]
|
||||
if (ref[0].offsetTop <= top) {
|
||||
this.activeGroup = group.id
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
loadEmoji () {
|
||||
const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
|
||||
|
||||
if (allLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
this.customEmojiBufferSlice += LOAD_EMOJI_BY
|
||||
},
|
||||
startEmojiLoad (forceUpdate = false) {
|
||||
if (!forceUpdate) {
|
||||
this.keyword = ''
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.$refs['emoji-groups'].scrollTop = 0
|
||||
})
|
||||
const bufferSize = this.customEmojiBuffer.length
|
||||
const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
|
||||
if (bufferPrefilledAll && !forceUpdate) {
|
||||
return
|
||||
}
|
||||
this.customEmojiBufferSlice = LOAD_EMOJI_BY
|
||||
},
|
||||
toggleStickers () {
|
||||
this.showingStickers = !this.showingStickers
|
||||
},
|
||||
setShowStickers (value) {
|
||||
this.showingStickers = value
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
keyword () {
|
||||
this.customEmojiLoadAllConfirmed = false
|
||||
this.onScroll()
|
||||
this.startEmojiLoad(true)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
activeGroupView () {
|
||||
return this.showingStickers ? '' : this.activeGroup
|
||||
},
|
||||
stickersAvailable () {
|
||||
if (this.$store.state.instance.stickers) {
|
||||
return this.$store.state.instance.stickers.length > 0
|
||||
}
|
||||
return 0
|
||||
},
|
||||
filteredEmoji () {
|
||||
return filterByKeyword(
|
||||
this.$store.state.instance.customEmoji || [],
|
||||
this.keyword
|
||||
)
|
||||
},
|
||||
customEmojiBuffer () {
|
||||
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
|
||||
},
|
||||
emojis () {
|
||||
const standardEmojis = this.$store.state.instance.emoji || []
|
||||
const customEmojis = this.customEmojiBuffer
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'custom',
|
||||
text: this.$t('emoji.custom'),
|
||||
icon: 'icon-smile',
|
||||
emojis: customEmojis
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
text: this.$t('emoji.unicode'),
|
||||
icon: 'icon-picture',
|
||||
emojis: filterByKeyword(standardEmojis, this.keyword)
|
||||
}
|
||||
]
|
||||
},
|
||||
emojisView () {
|
||||
return this.emojis.filter(value => value.emojis.length > 0)
|
||||
},
|
||||
stickerPickerEnabled () {
|
||||
return (this.$store.state.instance.stickers || []).length !== 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiPicker
|
175
src/components/emoji_picker/emoji_picker.scss
Normal file
175
src/components/emoji_picker/emoji_picker.scss
Normal file
|
@ -0,0 +1,175 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.emoji-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
margin: 0 !important;
|
||||
z-index: 1;
|
||||
|
||||
.keep-open,
|
||||
.too-many-emoji {
|
||||
padding: 7px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.too-many-emoji {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.keep-open-label {
|
||||
padding: 0 7px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 10px 7px 5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0px;
|
||||
}
|
||||
|
||||
.emoji-tabs {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.emoji-groups {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.additional-tabs {
|
||||
border-left: 1px solid;
|
||||
border-left-color: $fallback--icon;
|
||||
border-left-color: var(--icon, $fallback--icon);
|
||||
padding-left: 7px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.additional-tabs,
|
||||
.emoji-tabs {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex-basis: auto;
|
||||
flex-shrink: 1;
|
||||
|
||||
&-item {
|
||||
padding: 0 7px;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
&.active {
|
||||
border-bottom: 4px solid;
|
||||
|
||||
i {
|
||||
color: $fallback--lightText;
|
||||
color: var(--lightText, $fallback--lightText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sticker-picker {
|
||||
flex: 1 1 auto
|
||||
}
|
||||
|
||||
.stickers,
|
||||
.emoji {
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
&-search {
|
||||
padding: 5px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-groups {
|
||||
flex: 1 1 1px;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
user-select: none;
|
||||
mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
|
||||
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
|
||||
linear-gradient(to top, white, white);
|
||||
transition: mask-size 150ms;
|
||||
mask-size: 100% 20px, 100% 20px, auto;
|
||||
// Autoprefixed seem to ignore this one, and also syntax is different
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
&.scrolled {
|
||||
&-top {
|
||||
mask-size: 100% 20px, 100% 0, auto;
|
||||
}
|
||||
&-bottom {
|
||||
mask-size: 100% 0, 100% 20px, auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding-left: 5px;
|
||||
justify-content: left;
|
||||
|
||||
&-title {
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
&.disabled {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
font-size: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 4px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
99
src/components/emoji_picker/emoji_picker.vue
Normal file
99
src/components/emoji_picker/emoji_picker.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div class="emoji-picker panel panel-default panel-body">
|
||||
<div class="heading">
|
||||
<span class="emoji-tabs">
|
||||
<span
|
||||
v-for="group in emojis"
|
||||
:key="group.id"
|
||||
class="emoji-tabs-item"
|
||||
:class="{
|
||||
active: activeGroupView === group.id,
|
||||
disabled: group.emojis.length === 0
|
||||
}"
|
||||
:title="group.text"
|
||||
@click.prevent="highlight(group.id)"
|
||||
>
|
||||
<i :class="group.icon" />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="stickerPickerEnabled"
|
||||
class="additional-tabs"
|
||||
>
|
||||
<span
|
||||
class="stickers-tab-icon additional-tabs-item"
|
||||
:class="{active: showingStickers}"
|
||||
:title="$t('emoji.stickers')"
|
||||
@click.prevent="toggleStickers"
|
||||
>
|
||||
<i class="icon-star" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div
|
||||
class="emoji-content"
|
||||
:class="{hidden: showingStickers}"
|
||||
>
|
||||
<div class="emoji-search">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="$t('emoji.search_emoji')"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="emoji-groups"
|
||||
class="emoji-groups"
|
||||
:class="groupsScrolledClass"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="group in emojisView"
|
||||
:key="group.id"
|
||||
class="emoji-group"
|
||||
>
|
||||
<h6
|
||||
:ref="'group-' + group.id"
|
||||
class="emoji-group-title"
|
||||
>
|
||||
{{ group.text }}
|
||||
</h6>
|
||||
<span
|
||||
v-for="emoji in group.emojis"
|
||||
:key="group.id + emoji.displayText"
|
||||
:title="emoji.displayText"
|
||||
class="emoji-item"
|
||||
@click.stop.prevent="onEmoji(emoji)"
|
||||
>
|
||||
<span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
|
||||
<img
|
||||
v-else
|
||||
:src="emoji.imageUrl"
|
||||
>
|
||||
</span>
|
||||
<span :ref="'group-end-' + group.id" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="keep-open">
|
||||
<Checkbox v-model="keepOpen">
|
||||
{{ $t('emoji.keep_open') }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showingStickers"
|
||||
class="stickers-content"
|
||||
>
|
||||
<sticker-picker
|
||||
@uploaded="onStickerUploaded"
|
||||
@upload-failed="onStickerUploadFailed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./emoji_picker.js"></script>
|
||||
<style lang="scss" src="./emoji_picker.scss"></style>
|
|
@ -1,12 +1,27 @@
|
|||
<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>
|
||||
<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>
|
||||
|
@ -49,7 +64,7 @@ export default {
|
|||
if (event.target.files[0]) {
|
||||
// eslint-disable-next-line no-undef
|
||||
const reader = new FileReader()
|
||||
reader.onload = ({target}) => {
|
||||
reader.onload = ({ target }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(target.result)
|
||||
const valid = this.validator(parsed)
|
||||
|
|
48
src/components/exporter/exporter.js
Normal file
48
src/components/exporter/exporter.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
const Exporter = {
|
||||
props: {
|
||||
getContent: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
default: 'export.csv'
|
||||
},
|
||||
exportButtonLabel: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('exporter.export')
|
||||
}
|
||||
},
|
||||
processingMessage: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('exporter.processing')
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
process () {
|
||||
this.processing = true
|
||||
this.getContent()
|
||||
.then((content) => {
|
||||
const fileToDownload = document.createElement('a')
|
||||
fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content))
|
||||
fileToDownload.setAttribute('download', this.filename)
|
||||
fileToDownload.style.display = 'none'
|
||||
document.body.appendChild(fileToDownload)
|
||||
fileToDownload.click()
|
||||
document.body.removeChild(fileToDownload)
|
||||
// Add delay before hiding processing state since browser takes some time to handle file download
|
||||
setTimeout(() => { this.processing = false }, 2000)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Exporter
|
26
src/components/exporter/exporter.vue
Normal file
26
src/components/exporter/exporter.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<div class="exporter">
|
||||
<div v-if="processing">
|
||||
<i class="icon-spin4 animate-spin exporter-processing" />
|
||||
<span>{{ processingMessage }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-default"
|
||||
@click="process"
|
||||
>
|
||||
{{ exportButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./exporter.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.exporter {
|
||||
&-processing {
|
||||
font-size: 1.5em;
|
||||
margin: 0.25em;
|
||||
}
|
||||
}
|
||||
</style>
|
50
src/components/extra_buttons/extra_buttons.js
Normal file
50
src/components/extra_buttons/extra_buttons.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
const ExtraButtons = {
|
||||
props: [ 'status' ],
|
||||
methods: {
|
||||
deleteStatus () {
|
||||
const confirmed = window.confirm(this.$t('status.delete_confirm'))
|
||||
if (confirmed) {
|
||||
this.$store.dispatch('deleteStatus', { id: this.status.id })
|
||||
}
|
||||
},
|
||||
pinStatus () {
|
||||
this.$store.dispatch('pinStatus', this.status.id)
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
},
|
||||
unpinStatus () {
|
||||
this.$store.dispatch('unpinStatus', this.status.id)
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
},
|
||||
muteConversation () {
|
||||
this.$store.dispatch('muteConversation', this.status.id)
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
},
|
||||
unmuteConversation () {
|
||||
this.$store.dispatch('unmuteConversation', this.status.id)
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentUser () { return this.$store.state.users.currentUser },
|
||||
canDelete () {
|
||||
if (!this.currentUser) { return }
|
||||
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
|
||||
return superuser || this.status.user.id === this.currentUser.id
|
||||
},
|
||||
ownStatus () {
|
||||
return this.status.user.id === this.currentUser.id
|
||||
},
|
||||
canPin () {
|
||||
return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
|
||||
},
|
||||
canMute () {
|
||||
return !!this.currentUser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ExtraButtons
|
71
src/components/extra_buttons/extra_buttons.vue
Normal file
71
src/components/extra_buttons/extra_buttons.vue
Normal file
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<v-popover
|
||||
v-if="canDelete || canMute || canPin"
|
||||
trigger="click"
|
||||
placement="top"
|
||||
class="extra-button-popover"
|
||||
>
|
||||
<div slot="popover">
|
||||
<div class="dropdown-menu">
|
||||
<button
|
||||
v-if="canMute && !status.thread_muted"
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="muteConversation"
|
||||
>
|
||||
<i class="icon-eye-off" /><span>{{ $t("status.mute_conversation") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canMute && status.thread_muted"
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="unmuteConversation"
|
||||
>
|
||||
<i class="icon-eye-off" /><span>{{ $t("status.unmute_conversation") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!status.pinned && canPin"
|
||||
v-close-popover
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="pinStatus"
|
||||
>
|
||||
<i class="icon-pin" /><span>{{ $t("status.pin") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="status.pinned && canPin"
|
||||
v-close-popover
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="unpinStatus"
|
||||
>
|
||||
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
v-close-popover
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="deleteStatus"
|
||||
>
|
||||
<i class="icon-cancel" /><span>{{ $t("status.delete") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-icon">
|
||||
<i class="icon-ellipsis" />
|
||||
</div>
|
||||
</v-popover>
|
||||
</template>
|
||||
|
||||
<script src="./extra_buttons.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import '../popper/popper.scss';
|
||||
|
||||
.icon-ellipsis {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
.extra-button-popover.open & {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,19 +1,18 @@
|
|||
import { mapGetters } from 'vuex'
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
favorite () {
|
||||
if (!this.status.favorited) {
|
||||
this.$store.dispatch('favorite', {id: this.status.id})
|
||||
this.$store.dispatch('favorite', { id: this.status.id })
|
||||
} else {
|
||||
this.$store.dispatch('unfavorite', {id: this.status.id})
|
||||
this.$store.dispatch('unfavorite', { id: this.status.id })
|
||||
}
|
||||
this.animated = true
|
||||
setTimeout(() => {
|
||||
|
@ -28,7 +27,8 @@ const FavoriteButton = {
|
|||
'icon-star': this.status.favorited,
|
||||
'animate-spin': this.animated
|
||||
}
|
||||
}
|
||||
},
|
||||
...mapGetters(['mergedConfig'])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
<template>
|
||||
<div v-if="loggedIn">
|
||||
<i :class='classes' class='button-icon favorite-button fav-active' @click.prevent='favorite()' :title="$t('tool_tip.favorite')"/>
|
||||
<span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span>
|
||||
<i
|
||||
:class="classes"
|
||||
class="button-icon favorite-button fav-active"
|
||||
:title="$t('tool_tip.favorite')"
|
||||
@click.prevent="favorite()"
|
||||
/>
|
||||
<span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<i :class='classes' class='button-icon favorite-button' :title="$t('tool_tip.favorite')"/>
|
||||
<span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span>
|
||||
<i
|
||||
:class="classes"
|
||||
class="button-icon favorite-button"
|
||||
:title="$t('tool_tip.favorite')"
|
||||
/>
|
||||
<span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
const FeaturesPanel = {
|
||||
computed: {
|
||||
chat: function () {
|
||||
return this.$store.state.instance.chatAvailable && (!this.$store.state.chatDisabled)
|
||||
},
|
||||
chat: function () { return this.$store.state.instance.chatAvailable },
|
||||
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 },
|
||||
minimalScopesMode: function () { return this.$store.state.instance.minimalScopesMode },
|
||||
textlimit: function () { return this.$store.state.instance.textlimit }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,17 +3,25 @@
|
|||
<div class="panel panel-default base01-background">
|
||||
<div class="panel-heading timeline-heading base02-background base04">
|
||||
<div class="title">
|
||||
{{$t('features_panel.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>
|
||||
<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>{{ $t('features_panel.scope_options') }}</li>
|
||||
<li>{{ $t('features_panel.text_limit') }} = {{ textlimit }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
53
src/components/follow_button/follow_button.js
Normal file
53
src/components/follow_button/follow_button.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
|
||||
export default {
|
||||
props: ['user', 'labelFollowing', 'buttonClass'],
|
||||
data () {
|
||||
return {
|
||||
inProgress: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isPressed () {
|
||||
return this.inProgress || this.user.following
|
||||
},
|
||||
title () {
|
||||
if (this.inProgress || this.user.following) {
|
||||
return this.$t('user_card.follow_unfollow')
|
||||
} else if (this.user.requested) {
|
||||
return this.$t('user_card.follow_again')
|
||||
} else {
|
||||
return this.$t('user_card.follow')
|
||||
}
|
||||
},
|
||||
label () {
|
||||
if (this.inProgress) {
|
||||
return this.$t('user_card.follow_progress')
|
||||
} else if (this.user.following) {
|
||||
return this.labelFollowing || this.$t('user_card.following')
|
||||
} else if (this.user.requested) {
|
||||
return this.$t('user_card.follow_sent')
|
||||
} else {
|
||||
return this.$t('user_card.follow')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClick () {
|
||||
this.user.following ? this.unfollow() : this.follow()
|
||||
},
|
||||
follow () {
|
||||
this.inProgress = true
|
||||
requestFollow(this.user, this.$store).then(() => {
|
||||
this.inProgress = false
|
||||
})
|
||||
},
|
||||
unfollow () {
|
||||
const store = this.$store
|
||||
this.inProgress = true
|
||||
requestUnfollow(this.user, store).then(() => {
|
||||
this.inProgress = false
|
||||
store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
13
src/components/follow_button/follow_button.vue
Normal file
13
src/components/follow_button/follow_button.vue
Normal file
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<button
|
||||
class="btn btn-default follow-button"
|
||||
:class="{ pressed: isPressed }"
|
||||
:disabled="inProgress"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script src="./follow_button.js"></script>
|
|
@ -1,43 +1,23 @@
|
|||
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
|
||||
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
|
||||
import RemoteFollow from '../remote_follow/remote_follow.vue'
|
||||
import FollowButton from '../follow_button/follow_button.vue'
|
||||
|
||||
const FollowCard = {
|
||||
props: [
|
||||
'user',
|
||||
'noFollowsYou'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
inProgress: false,
|
||||
requestSent: false,
|
||||
updated: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
BasicUserCard
|
||||
BasicUserCard,
|
||||
RemoteFollow,
|
||||
FollowButton
|
||||
},
|
||||
computed: {
|
||||
isMe () { return this.$store.state.users.currentUser.id === this.user.id },
|
||||
following () { return this.updated ? this.updated.following : this.user.following },
|
||||
showFollow () {
|
||||
return !this.following || this.updated && !this.updated.following
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
followUser () {
|
||||
this.inProgress = true
|
||||
requestFollow(this.user, this.$store).then(({ sent, updated }) => {
|
||||
this.inProgress = false
|
||||
this.requestSent = sent
|
||||
this.updated = updated
|
||||
})
|
||||
isMe () {
|
||||
return this.$store.state.users.currentUser.id === this.user.id
|
||||
},
|
||||
unfollowUser () {
|
||||
this.inProgress = true
|
||||
requestUnfollow(this.user, this.$store).then(({ updated }) => {
|
||||
this.inProgress = false
|
||||
this.updated = updated
|
||||
})
|
||||
loggedIn () {
|
||||
return this.$store.state.users.currentUser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,34 +1,27 @@
|
|||
<template>
|
||||
<basic-user-card :user="user">
|
||||
<div class="follow-card-content-container">
|
||||
<span class="faint" v-if="!noFollowsYou && user.follows_you">
|
||||
<span
|
||||
v-if="!noFollowsYou && user.follows_you"
|
||||
class="faint"
|
||||
>
|
||||
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
|
||||
</span>
|
||||
<button
|
||||
v-if="showFollow"
|
||||
class="btn btn-default"
|
||||
@click="followUser"
|
||||
:disabled="inProgress"
|
||||
:title="requestSent ? $t('user_card.follow_again') : ''"
|
||||
>
|
||||
<template v-if="inProgress">
|
||||
{{ $t('user_card.follow_progress') }}
|
||||
</template>
|
||||
<template v-else-if="requestSent">
|
||||
{{ $t('user_card.follow_sent') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('user_card.follow') }}
|
||||
</template>
|
||||
</button>
|
||||
<button v-if="following" class="btn btn-default pressed" @click="unfollowUser" :disabled="inProgress">
|
||||
<template v-if="inProgress">
|
||||
{{ $t('user_card.follow_progress') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('user_card.follow_unfollow') }}
|
||||
</template>
|
||||
</button>
|
||||
<template v-if="!loggedIn">
|
||||
<div
|
||||
v-if="!user.following"
|
||||
class="follow-card-follow-button"
|
||||
>
|
||||
<RemoteFollow :user="user" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FollowButton
|
||||
:user="user"
|
||||
class="follow-card-follow-button"
|
||||
:label-following="$t('user_card.follow_unfollow')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</basic-user-card>
|
||||
</template>
|
||||
|
@ -36,15 +29,17 @@
|
|||
<script src="./follow_card.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.follow-card-content-container {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.5em;
|
||||
.follow-card {
|
||||
&-content-container {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
&-follow-button {
|
||||
margin-top: 0.5em;
|
||||
margin-left: auto;
|
||||
width: 10em;
|
||||
|
|
|
@ -7,11 +7,11 @@ const FollowRequestCard = {
|
|||
},
|
||||
methods: {
|
||||
approveUser () {
|
||||
this.$store.state.api.backendInteractor.approveUser(this.user.id)
|
||||
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
|
||||
this.$store.dispatch('removeFollowRequest', this.user)
|
||||
},
|
||||
denyUser () {
|
||||
this.$store.state.api.backendInteractor.denyUser(this.user.id)
|
||||
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
|
||||
this.$store.dispatch('removeFollowRequest', this.user)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,18 @@
|
|||
<template>
|
||||
<basic-user-card :user="user">
|
||||
<div class="follow-request-card-content-container">
|
||||
<button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button>
|
||||
<button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button>
|
||||
<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>
|
||||
</basic-user-card>
|
||||
</template>
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
<template>
|
||||
<div class="settings panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{{$t('nav.friend_requests')}}
|
||||
{{ $t('nav.friend_requests') }}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<FollowRequestCard v-for="request in requests" :key="request.id" :user="request"/>
|
||||
<FollowRequestCard
|
||||
v-for="request in requests"
|
||||
:key="request.id"
|
||||
:user="request"
|
||||
class="list-item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,35 +1,56 @@
|
|||
<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
|
||||
<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'"
|
||||
:id="name + '-o'"
|
||||
class="opt exlcude-disabled"
|
||||
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
|
||||
:for="name + '-font-switcher'"
|
||||
class="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>
|
||||
>
|
||||
<select
|
||||
:id="name + '-font-switcher'"
|
||||
v-model="preset"
|
||||
:disabled="!present"
|
||||
class="font-switcher"
|
||||
>
|
||||
<option
|
||||
v-for="option in availableOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="icon-down-open" />
|
||||
</label>
|
||||
<input
|
||||
v-if="isCustom"
|
||||
:id="name"
|
||||
v-model="family"
|
||||
class="custom-font"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./font_control.js" ></script>
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<Timeline :title="$t('nav.timeline')" v-bind:timeline="timeline" v-bind:timeline-name="'friends'"/>
|
||||
<Timeline
|
||||
:title="$t('nav.timeline')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'friends'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./friends_timeline.js"></script>
|
||||
|
|
|
@ -1,23 +1,18 @@
|
|||
import Attachment from '../attachment/attachment.vue'
|
||||
import { chunk, last, dropRight } from 'lodash'
|
||||
import { chunk, last, dropRight, sumBy } from 'lodash'
|
||||
|
||||
const Gallery = {
|
||||
data: () => ({
|
||||
width: 500
|
||||
}),
|
||||
props: [
|
||||
'attachments',
|
||||
'nsfw',
|
||||
'setMedia'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
sizes: {}
|
||||
}
|
||||
},
|
||||
components: { Attachment },
|
||||
mounted () {
|
||||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
},
|
||||
computed: {
|
||||
rows () {
|
||||
if (!this.attachments) {
|
||||
|
@ -33,21 +28,24 @@ const Gallery = {
|
|||
}
|
||||
return rows
|
||||
},
|
||||
rowHeight () {
|
||||
return itemsPerRow => ({ 'height': `${(this.width / (itemsPerRow + 0.6))}px` })
|
||||
},
|
||||
useContainFit () {
|
||||
return this.$store.state.config.useContainFit
|
||||
return this.$store.getters.mergedConfig.useContainFit
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resize () {
|
||||
// Quick optimization to make resizing not always trigger state change,
|
||||
// only update attachment size in 10px steps
|
||||
const width = Math.floor(this.$el.getBoundingClientRect().width / 10) * 10
|
||||
if (this.width !== width) {
|
||||
this.width = width
|
||||
}
|
||||
onNaturalSizeLoad (id, size) {
|
||||
this.$set(this.sizes, id, size)
|
||||
},
|
||||
rowStyle (itemsPerRow) {
|
||||
return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` }
|
||||
},
|
||||
itemStyle (id, row) {
|
||||
const total = sumBy(row, item => this.getAspectRatio(item.id))
|
||||
return { flex: `${this.getAspectRatio(id) / total} 1 0%` }
|
||||
},
|
||||
getAspectRatio (id) {
|
||||
const size = this.sizes[id]
|
||||
return size ? size.width / size.height : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,27 @@
|
|||
<template>
|
||||
<div ref="galleryContainer" style="width: 100%;">
|
||||
<div class="gallery-row" v-for="row in rows" :style="rowHeight(row.length)" :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }">
|
||||
<attachment
|
||||
v-for="attachment in row"
|
||||
:setMedia="setMedia"
|
||||
:nsfw="nsfw"
|
||||
:attachment="attachment"
|
||||
:allowPlay="false"
|
||||
:key="attachment.id"
|
||||
/>
|
||||
<div
|
||||
ref="galleryContainer"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="index"
|
||||
class="gallery-row"
|
||||
:style="rowStyle(row.length)"
|
||||
:class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"
|
||||
>
|
||||
<div class="gallery-row-inner">
|
||||
<attachment
|
||||
v-for="attachment in row"
|
||||
:key="attachment.id"
|
||||
:set-media="setMedia"
|
||||
:nsfw="nsfw"
|
||||
:attachment="attachment"
|
||||
:allow-play="false"
|
||||
:natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)"
|
||||
:style="itemStyle(attachment.id, row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -19,16 +32,27 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.gallery-row {
|
||||
height: 200px;
|
||||
position: relative;
|
||||
height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-content: stretch;
|
||||
flex-grow: 1;
|
||||
margin-top: 0.5em;
|
||||
|
||||
.attachments, .attachment {
|
||||
.gallery-row-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
// FIXME: specificity problem with this and .attachments.attachment
|
||||
// we shouldn't have the need for .image here
|
||||
.attachment.image {
|
||||
margin: 0 0.5em 0 0;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
|
@ -50,13 +74,17 @@
|
|||
}
|
||||
|
||||
&.contain-fit {
|
||||
img, video {
|
||||
img,
|
||||
video,
|
||||
canvas {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&.cover-fit {
|
||||
img, video {
|
||||
img,
|
||||
video,
|
||||
canvas {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,9 @@ const ImageCropper = {
|
|||
saveButtonLabel: {
|
||||
type: String
|
||||
},
|
||||
saveWithoutCroppingButtonlabel: {
|
||||
type: String
|
||||
},
|
||||
cancelButtonLabel: {
|
||||
type: String
|
||||
}
|
||||
|
@ -48,6 +51,9 @@ const ImageCropper = {
|
|||
saveText () {
|
||||
return this.saveButtonLabel || this.$t('image_cropper.save')
|
||||
},
|
||||
saveWithoutCroppingText () {
|
||||
return this.saveWithoutCroppingButtonlabel || this.$t('image_cropper.save_without_cropping')
|
||||
},
|
||||
cancelText () {
|
||||
return this.cancelButtonLabel || this.$t('image_cropper.cancel')
|
||||
},
|
||||
|
@ -64,10 +70,10 @@ const ImageCropper = {
|
|||
this.dataUrl = undefined
|
||||
this.$emit('close')
|
||||
},
|
||||
submit () {
|
||||
submit (cropping = true) {
|
||||
this.submitting = true
|
||||
this.avatarUploadError = null
|
||||
this.submitHandler(this.cropper, this.file)
|
||||
this.submitHandler(cropping && this.cropper, this.file)
|
||||
.then(() => this.destroy())
|
||||
.catch((err) => {
|
||||
this.submitError = err
|
||||
|
|
|
@ -2,19 +2,57 @@
|
|||
<div class="image-cropper">
|
||||
<div v-if="dataUrl">
|
||||
<div class="image-cropper-image-container">
|
||||
<img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" />
|
||||
<img
|
||||
ref="img"
|
||||
:src="dataUrl"
|
||||
alt=""
|
||||
@load.stop="createCropper"
|
||||
>
|
||||
</div>
|
||||
<div class="image-cropper-buttons-wrapper">
|
||||
<button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button>
|
||||
<button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button>
|
||||
<i class="icon-spin4 animate-spin" v-if="submitting"></i>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="submitting"
|
||||
@click="submit()"
|
||||
v-text="saveText"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="submitting"
|
||||
@click="destroy"
|
||||
v-text="cancelText"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="submitting"
|
||||
@click="submit(false)"
|
||||
v-text="saveWithoutCroppingText"
|
||||
/>
|
||||
<i
|
||||
v-if="submitting"
|
||||
class="icon-spin4 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
<div class="alert error" v-if="submitError">
|
||||
{{submitErrorMsg}}
|
||||
<i class="button-icon icon-cancel" @click="clearError"></i>
|
||||
<div
|
||||
v-if="submitError"
|
||||
class="alert error"
|
||||
>
|
||||
{{ submitErrorMsg }}
|
||||
<i
|
||||
class="button-icon icon-cancel"
|
||||
@click="clearError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="input" type="file" class="image-cropper-img-input" :accept="mimes">
|
||||
<input
|
||||
ref="input"
|
||||
type="file"
|
||||
class="image-cropper-img-input"
|
||||
:accept="mimes"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -36,7 +74,11 @@
|
|||
}
|
||||
|
||||
&-buttons-wrapper {
|
||||
margin-top: 15px;
|
||||
margin-top: 10px;
|
||||
|
||||
button {
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
53
src/components/importer/importer.js
Normal file
53
src/components/importer/importer.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
const Importer = {
|
||||
props: {
|
||||
submitHandler: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitButtonLabel: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('importer.submit')
|
||||
}
|
||||
},
|
||||
successMessage: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('importer.success')
|
||||
}
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('importer.error')
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
file: null,
|
||||
error: false,
|
||||
success: false,
|
||||
submitting: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
change () {
|
||||
this.file = this.$refs.input.files[0]
|
||||
},
|
||||
submit () {
|
||||
this.dismiss()
|
||||
this.submitting = true
|
||||
this.submitHandler(this.file)
|
||||
.then(() => { this.success = true })
|
||||
.catch(() => { this.error = true })
|
||||
.finally(() => { this.submitting = false })
|
||||
},
|
||||
dismiss () {
|
||||
this.success = false
|
||||
this.error = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Importer
|
47
src/components/importer/importer.vue
Normal file
47
src/components/importer/importer.vue
Normal file
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="importer">
|
||||
<form>
|
||||
<input
|
||||
ref="input"
|
||||
type="file"
|
||||
@change="change"
|
||||
>
|
||||
</form>
|
||||
<i
|
||||
v-if="submitting"
|
||||
class="icon-spin4 animate-spin importer-uploading"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-default"
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitButtonLabel }}
|
||||
</button>
|
||||
<div v-if="success">
|
||||
<i
|
||||
class="icon-cross"
|
||||
@click="dismiss"
|
||||
/>
|
||||
<p>{{ successMessage }}</p>
|
||||
</div>
|
||||
<div v-else-if="error">
|
||||
<i
|
||||
class="icon-cross"
|
||||
@click="dismiss"
|
||||
/>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./importer.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.importer {
|
||||
&-uploading {
|
||||
font-size: 1.5em;
|
||||
margin: 0.25em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -2,9 +2,6 @@ const InstanceSpecificPanel = {
|
|||
computed: {
|
||||
instanceSpecificPanelContent () {
|
||||
return this.$store.state.instance.instanceSpecificPanelContent
|
||||
},
|
||||
show () {
|
||||
return !this.$store.state.config.hideISP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
<template>
|
||||
<div v-if="show" class="instance-specific-panel">
|
||||
<div class="instance-specific-panel">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<div v-html="instanceSpecificPanelContent">
|
||||
</div>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div v-html="instanceSpecificPanelContent" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./instance_specific_panel.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
|
|
26
src/components/interactions/interactions.js
Normal file
26
src/components/interactions/interactions.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import Notifications from '../notifications/notifications.vue'
|
||||
|
||||
const tabModeDict = {
|
||||
mentions: ['mention'],
|
||||
'likes+repeats': ['repeat', 'like'],
|
||||
follows: ['follow'],
|
||||
moves: ['move']
|
||||
}
|
||||
|
||||
const Interactions = {
|
||||
data () {
|
||||
return {
|
||||
filterMode: tabModeDict['mentions']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onModeSwitch (key) {
|
||||
this.filterMode = tabModeDict[key]
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Notifications
|
||||
}
|
||||
}
|
||||
|
||||
export default Interactions
|
38
src/components/interactions/interactions.vue
Normal file
38
src/components/interactions/interactions.vue
Normal file
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="title">
|
||||
{{ $t("nav.interactions") }}
|
||||
</div>
|
||||
</div>
|
||||
<tab-switcher
|
||||
ref="tabSwitcher"
|
||||
:on-switch="onModeSwitch"
|
||||
>
|
||||
<span
|
||||
key="mentions"
|
||||
:label="$t('nav.mentions')"
|
||||
/>
|
||||
<span
|
||||
key="likes+repeats"
|
||||
:label="$t('interactions.favs_repeats')"
|
||||
/>
|
||||
<span
|
||||
key="follows"
|
||||
:label="$t('interactions.follows')"
|
||||
/>
|
||||
<span
|
||||
key="moves"
|
||||
:label="$t('interactions.moves')"
|
||||
/>
|
||||
</tab-switcher>
|
||||
<Notifications
|
||||
ref="notifications"
|
||||
:no-heading="true"
|
||||
:minimal-mode="true"
|
||||
:filter-mode="filterMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./interactions.js"></script>
|
|
@ -3,39 +3,60 @@
|
|||
<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">
|
||||
<label
|
||||
for="interface-language-switcher"
|
||||
class="select"
|
||||
>
|
||||
<select
|
||||
id="interface-language-switcher"
|
||||
v-model="language"
|
||||
>
|
||||
<option
|
||||
v-for="(langCode, i) in languageCodes"
|
||||
:key="langCode"
|
||||
:value="langCode"
|
||||
>
|
||||
{{ languageNames[i] }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="icon-down-open"/>
|
||||
<i class="icon-down-open" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import languagesObject from '../../i18n/messages'
|
||||
import ISO6391 from 'iso-639-1'
|
||||
import _ from 'lodash'
|
||||
import languagesObject from '../../i18n/messages'
|
||||
import ISO6391 from 'iso-639-1'
|
||||
import _ from 'lodash'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
languageCodes () {
|
||||
return Object.keys(languagesObject)
|
||||
},
|
||||
export default {
|
||||
computed: {
|
||||
languageCodes () {
|
||||
return Object.keys(languagesObject)
|
||||
},
|
||||
|
||||
languageNames () {
|
||||
return _.map(this.languageCodes, ISO6391.getName)
|
||||
},
|
||||
languageNames () {
|
||||
return _.map(this.languageCodes, this.getLanguageName)
|
||||
},
|
||||
|
||||
language: {
|
||||
get: function () { return this.$store.state.config.interfaceLanguage },
|
||||
set: function (val) {
|
||||
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
|
||||
this.$i18n.locale = val
|
||||
}
|
||||
language: {
|
||||
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
|
||||
set: function (val) {
|
||||
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
|
||||
this.$i18n.locale = val
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getLanguageName (code) {
|
||||
const specialLanguageNames = {
|
||||
'ja': 'Japanese (日本語)',
|
||||
'ja_easy': 'Japanese (やさしいにほんご)',
|
||||
'zh': 'Chinese (简体中文)'
|
||||
}
|
||||
return specialLanguageNames[code] || ISO6391.getName(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -5,6 +5,11 @@ const LinkPreview = {
|
|||
'size',
|
||||
'nsfw'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
imageLoaded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
useImage () {
|
||||
// Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid
|
||||
|
@ -15,6 +20,15 @@ const LinkPreview = {
|
|||
useDescription () {
|
||||
return this.card.description && /\S/.test(this.card.description)
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (this.useImage) {
|
||||
const newImg = new Image()
|
||||
newImg.onload = () => {
|
||||
this.imageLoaded = true
|
||||
}
|
||||
newImg.src = this.card.image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,25 @@
|
|||
<template>
|
||||
<div>
|
||||
<a class="link-preview-card" :href="card.url" target="_blank" rel="noopener">
|
||||
<div class="card-image" :class="{ 'small-image': size === 'small' }" v-if="useImage">
|
||||
<img :src="card.image"></img>
|
||||
<a
|
||||
class="link-preview-card"
|
||||
:href="card.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<div
|
||||
v-if="useImage && imageLoaded"
|
||||
class="card-image"
|
||||
:class="{ 'small-image': size === 'small' }"
|
||||
>
|
||||
<img :src="card.image">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="card-host faint">{{ card.provider_name }}</span>
|
||||
<h4 class="card-title">{{ card.title }}</h4>
|
||||
<p class="card-description" v-if="useDescription">{{ card.description }}</p>
|
||||
<p
|
||||
v-if="useDescription"
|
||||
class="card-description"
|
||||
>{{ card.description }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
52
src/components/list/list.vue
Normal file
52
src/components/list/list.vue
Normal file
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="getKey(item)"
|
||||
class="list-item"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="items.length === 0 && !!$slots.empty"
|
||||
class="list-empty-content faint"
|
||||
>
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
getKey: {
|
||||
type: Function,
|
||||
default: item => item.id
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.list {
|
||||
&-item:not(:last-child) {
|
||||
border-bottom: 1px solid;
|
||||
border-bottom-color: $fallback--border;
|
||||
border-bottom-color: var(--border, $fallback--border);
|
||||
}
|
||||
|
||||
&-empty-content {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,50 +1,83 @@
|
|||
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
|
||||
import oauthApi from '../../services/new_api/oauth.js'
|
||||
|
||||
const LoginForm = {
|
||||
data: () => ({
|
||||
user: {},
|
||||
authError: false
|
||||
error: false
|
||||
}),
|
||||
computed: {
|
||||
loginMethod () { return this.$store.state.instance.loginMethod },
|
||||
loggingIn () { return this.$store.state.users.loggingIn },
|
||||
registrationOpen () { return this.$store.state.instance.registrationOpen }
|
||||
isPasswordAuth () { return this.requiredPassword },
|
||||
isTokenAuth () { return this.requiredToken },
|
||||
...mapState({
|
||||
registrationOpen: state => state.instance.registrationOpen,
|
||||
instance: state => state.instance,
|
||||
loggingIn: state => state.users.loggingIn,
|
||||
oauth: state => state.oauth
|
||||
}),
|
||||
...mapGetters(
|
||||
'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA']
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
oAuthLogin () {
|
||||
oauthApi.login({
|
||||
oauth: this.$store.state.oauth,
|
||||
instance: this.$store.state.instance.server,
|
||||
commit: this.$store.commit
|
||||
})
|
||||
},
|
||||
...mapMutations('authFlow', ['requireMFA']),
|
||||
...mapActions({ login: 'authFlow/login' }),
|
||||
submit () {
|
||||
this.isTokenAuth ? this.submitToken() : this.submitPassword()
|
||||
},
|
||||
submitToken () {
|
||||
const { clientId, clientSecret } = this.oauth
|
||||
const data = {
|
||||
oauth: this.$store.state.oauth,
|
||||
instance: this.$store.state.instance.server
|
||||
clientId,
|
||||
clientSecret,
|
||||
instance: this.instance.server,
|
||||
commit: this.$store.commit
|
||||
}
|
||||
this.clearError()
|
||||
|
||||
oauthApi.getOrCreateApp(data)
|
||||
.then((app) => { oauthApi.login({ ...app, ...data }) })
|
||||
},
|
||||
submitPassword () {
|
||||
const { clientId } = this.oauth
|
||||
const data = {
|
||||
clientId,
|
||||
oauth: this.oauth,
|
||||
instance: this.instance.server,
|
||||
commit: this.$store.commit
|
||||
}
|
||||
this.error = false
|
||||
|
||||
oauthApi.getOrCreateApp(data).then((app) => {
|
||||
oauthApi.getTokenWithCredentials(
|
||||
{
|
||||
app,
|
||||
...app,
|
||||
instance: data.instance,
|
||||
username: this.user.username,
|
||||
password: this.user.password
|
||||
}
|
||||
).then((result) => {
|
||||
if (result.error) {
|
||||
this.authError = result.error
|
||||
this.user.password = ''
|
||||
if (result.error === 'mfa_required') {
|
||||
this.requireMFA({ settings: result })
|
||||
} else if (result.identifier === 'password_reset_required') {
|
||||
this.$router.push({ name: 'password-reset', params: { passwordResetRequested: true } })
|
||||
} else {
|
||||
this.error = result.error
|
||||
this.focusOnPasswordInput()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.$store.commit('setToken', result.access_token)
|
||||
this.$store.dispatch('loginUser', result.access_token)
|
||||
this.$router.push({name: 'friends'})
|
||||
this.login(result).then(() => {
|
||||
this.$router.push({ name: 'friends' })
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
clearError () {
|
||||
this.authError = false
|
||||
clearError () { this.error = false },
|
||||
focusOnPasswordInput () {
|
||||
let passwordInput = this.$refs.passwordInput
|
||||
passwordInput.focus()
|
||||
passwordInput.setSelectionRange(0, passwordInput.value.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,44 +1,85 @@
|
|||
<template>
|
||||
<div class="login panel panel-default">
|
||||
<!-- Default panel contents -->
|
||||
<div class="panel-heading">
|
||||
{{$t('login.login')}}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<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' v-bind:placeholder="$t('login.placeholder')">
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='password'>{{$t('login.password')}}</label>
|
||||
<input :disabled="loggingIn" v-model='user.password' class='form-control' id='password' type='password'>
|
||||
</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>
|
||||
|
||||
<form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form">
|
||||
<div class="form-group">
|
||||
<p>{{$t('login.description')}}</p>
|
||||
<div class="panel-heading">
|
||||
{{ $t('login.login') }}
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<form
|
||||
class="login-form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<template v-if="isPasswordAuth">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ $t('login.username') }}</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="user.username"
|
||||
:disabled="loggingIn"
|
||||
class="form-control"
|
||||
:placeholder="$t('login.placeholder')"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ $t('login.password') }}</label>
|
||||
<input
|
||||
id="password"
|
||||
ref="passwordInput"
|
||||
v-model="user.password"
|
||||
:disabled="loggingIn"
|
||||
class="form-control"
|
||||
type="password"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<router-link :to="{name: 'password-reset'}">
|
||||
{{ $t('password_reset.forgot_password') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isTokenAuth"
|
||||
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 class="form-group">
|
||||
<div class="login-bottom">
|
||||
<div>
|
||||
<router-link
|
||||
v-if="registrationOpen"
|
||||
:to="{name: 'registration'}"
|
||||
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 v-if="authError" class='form-group'>
|
||||
<div class='alert error'>
|
||||
{{authError}}
|
||||
<i class="button-icon icon-cancel" @click="clearError"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="form-group"
|
||||
>
|
||||
<div class="alert error">
|
||||
{{ error }}
|
||||
<i
|
||||
class="button-icon icon-cancel"
|
||||
@click="clearError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -50,6 +91,10 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.6em;
|
||||
|
||||
.btn {
|
||||
min-height: 28px;
|
||||
width: 10em;
|
||||
|
@ -66,9 +111,30 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.login {
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.3em 0.5em 0.6em;
|
||||
line-height:24px;
|
||||
}
|
||||
|
||||
.form-bottom {
|
||||
display: flex;
|
||||
padding: 0.5em;
|
||||
height: 32px;
|
||||
|
||||
button {
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.35em;
|
||||
padding: 0.35em;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import StillImage from '../still-image/still-image.vue'
|
||||
import VideoAttachment from '../video_attachment/video_attachment.vue'
|
||||
import Modal from '../modal/modal.vue'
|
||||
import fileTypeService from '../../services/file_type/file_type.service.js'
|
||||
import GestureService from '../../services/gesture_service/gesture_service'
|
||||
|
||||
const MediaModal = {
|
||||
components: {
|
||||
StillImage,
|
||||
VideoAttachment
|
||||
VideoAttachment,
|
||||
Modal
|
||||
},
|
||||
computed: {
|
||||
showing () {
|
||||
|
@ -27,7 +30,27 @@ const MediaModal = {
|
|||
return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.mediaSwipeGestureRight = GestureService.swipeGesture(
|
||||
GestureService.DIRECTION_RIGHT,
|
||||
this.goPrev,
|
||||
50
|
||||
)
|
||||
this.mediaSwipeGestureLeft = GestureService.swipeGesture(
|
||||
GestureService.DIRECTION_LEFT,
|
||||
this.goNext,
|
||||
50
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
mediaTouchStart (e) {
|
||||
GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
|
||||
GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
|
||||
},
|
||||
mediaTouchMove (e) {
|
||||
GestureService.updateSwipe(e, this.mediaSwipeGestureRight)
|
||||
GestureService.updateSwipe(e, this.mediaSwipeGestureLeft)
|
||||
},
|
||||
hide () {
|
||||
this.$store.dispatch('closeMediaViewer')
|
||||
},
|
||||
|
|
|
@ -1,50 +1,58 @@
|
|||
<template>
|
||||
<div class="modal-view media-modal-view" v-if="showing" @click.prevent="hide">
|
||||
<img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img>
|
||||
<VideoAttachment
|
||||
<Modal
|
||||
v-if="showing"
|
||||
class="media-modal-view"
|
||||
@backdropClicked="hide"
|
||||
>
|
||||
<img
|
||||
v-if="type === 'image'"
|
||||
class="modal-image"
|
||||
:src="currentMedia.url"
|
||||
@touchstart.stop="mediaTouchStart"
|
||||
@touchmove.stop="mediaTouchMove"
|
||||
@click="hide"
|
||||
>
|
||||
<VideoAttachment
|
||||
v-if="type === 'video'"
|
||||
class="modal-image"
|
||||
:attachment="currentMedia"
|
||||
:controls="true"
|
||||
@click.stop.native="">
|
||||
</VideoAttachment>
|
||||
/>
|
||||
<button
|
||||
v-if="canNavigate"
|
||||
:title="$t('media_modal.previous')"
|
||||
class="modal-view-button-arrow modal-view-button-arrow--prev"
|
||||
v-if="canNavigate"
|
||||
@click.stop.prevent="goPrev"
|
||||
>
|
||||
<i class="icon-left-open arrow-icon" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canNavigate"
|
||||
:title="$t('media_modal.next')"
|
||||
class="modal-view-button-arrow modal-view-button-arrow--next"
|
||||
v-if="canNavigate"
|
||||
@click.stop.prevent="goNext"
|
||||
>
|
||||
<i class="icon-right-open arrow-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script src="./media_modal.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
.modal-view.media-modal-view {
|
||||
z-index: 1001;
|
||||
|
||||
.media-modal-view {
|
||||
&:hover {
|
||||
.modal-view-button-arrow {
|
||||
opacity: 0.75;
|
||||
.modal-view-button-arrow {
|
||||
opacity: 0.75;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +61,7 @@
|
|||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
|
||||
image-orientation: from-image; // NOTE: only FF supports this
|
||||
}
|
||||
|
||||
.modal-view-button-arrow {
|
||||
|
@ -98,5 +107,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -16,11 +16,11 @@ const mediaUpload = {
|
|||
if (file.size > store.state.instance.uploadlimit) {
|
||||
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
|
||||
const allowedsize = fileSizeFormatService.fileSizeFormat(store.state.instance.uploadlimit)
|
||||
self.$emit('upload-failed', 'file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit})
|
||||
self.$emit('upload-failed', 'file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit })
|
||||
return
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('media', file)
|
||||
formData.append('file', file)
|
||||
|
||||
self.$emit('uploading')
|
||||
self.uploading = true
|
||||
|
@ -36,7 +36,7 @@ const mediaUpload = {
|
|||
},
|
||||
fileDrop (e) {
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
e.preventDefault() // allow dropping text like before
|
||||
e.preventDefault() // allow dropping text like before
|
||||
this.uploadFile(e.dataTransfer.files[0])
|
||||
}
|
||||
},
|
||||
|
@ -54,7 +54,7 @@ const mediaUpload = {
|
|||
this.uploadReady = true
|
||||
})
|
||||
},
|
||||
change ({target}) {
|
||||
change ({ target }) {
|
||||
for (var i = 0; i < target.files.length; i++) {
|
||||
let file = target.files[i]
|
||||
this.uploadFile(file)
|
||||
|
|
|
@ -1,22 +1,53 @@
|
|||
<template>
|
||||
<div class="media-upload" @drop.prevent @dragover.prevent="fileDrag" @drop="fileDrop">
|
||||
<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" v-if="uploadReady" @change="change" style="position: fixed; top: -100em" multiple="true"></input>
|
||||
<div
|
||||
class="media-upload"
|
||||
@drop.prevent
|
||||
@dragover.prevent="fileDrag"
|
||||
@drop="fileDrop"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
:title="$t('tool_tip.media_upload')"
|
||||
>
|
||||
<i
|
||||
v-if="uploading"
|
||||
class="progress-icon icon-spin4 animate-spin"
|
||||
/>
|
||||
<i
|
||||
v-if="!uploading"
|
||||
class="new-icon icon-upload"
|
||||
/>
|
||||
<input
|
||||
v-if="uploadReady"
|
||||
type="file"
|
||||
style="position: fixed; top: -100em"
|
||||
multiple="true"
|
||||
@change="change"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./media_upload.js" ></script>
|
||||
|
||||
<style>
|
||||
.media-upload {
|
||||
font-size: 26px;
|
||||
flex: 1;
|
||||
}
|
||||
<style lang="scss">
|
||||
.media-upload {
|
||||
.label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.icon-upload {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
.new-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-icon {
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
&::before {
|
||||
/* Overriding fontello to achieve the perfect speeeen */
|
||||
margin: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<Timeline :title="$t('nav.mentions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/>
|
||||
<Timeline
|
||||
:title="$t('nav.interactions')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'mentions'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./mentions.js"></script>
|
||||
|
|
46
src/components/mfa_form/recovery_form.js
Normal file
46
src/components/mfa_form/recovery_form.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
import mfaApi from '../../services/new_api/mfa.js'
|
||||
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
code: null,
|
||||
error: false
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters({
|
||||
authSettings: 'authFlow/settings'
|
||||
}),
|
||||
...mapState({
|
||||
instance: 'instance',
|
||||
oauth: 'oauth'
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('authFlow', ['requireTOTP', 'abortMFA']),
|
||||
...mapActions({ login: 'authFlow/login' }),
|
||||
clearError () { this.error = false },
|
||||
submit () {
|
||||
const { clientId, clientSecret } = this.oauth
|
||||
|
||||
const data = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
instance: this.instance.server,
|
||||
mfaToken: this.authSettings.mfa_token,
|
||||
code: this.code
|
||||
}
|
||||
|
||||
mfaApi.verifyRecoveryCode(data).then((result) => {
|
||||
if (result.error) {
|
||||
this.error = result.error
|
||||
this.code = null
|
||||
return
|
||||
}
|
||||
|
||||
this.login(result).then(() => {
|
||||
this.$router.push({ name: 'friends' })
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue