Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma-fe into develop
This commit is contained in:
commit
0790fbdf99
4
.babelrc
4
.babelrc
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"presets": ["es2015", "stage-2"],
|
||||
"plugins": ["transform-runtime", "lodash"],
|
||||
"presets": ["es2015", "stage-2", "env"],
|
||||
"plugins": ["transform-runtime", "lodash", "transform-vue-jsx"],
|
||||
"comments": false
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ module.exports = {
|
|||
'html'
|
||||
],
|
||||
// add your custom rules here
|
||||
'rules': {
|
||||
rules: {
|
||||
// allow paren-less arrow functions
|
||||
'arrow-parens': 0,
|
||||
// allow async-await
|
||||
|
|
|
@ -6,3 +6,4 @@ test/unit/coverage
|
|||
test/e2e/reports
|
||||
selenium-debug.log
|
||||
.idea/
|
||||
config/local.json
|
||||
|
|
|
@ -3,32 +3,10 @@
|
|||
# https://hub.docker.com/r/library/node/tags/
|
||||
image: node:7
|
||||
|
||||
before_script:
|
||||
# Install ssh-agent if not already installed, it is required by Docker.
|
||||
# (change apt-get to yum if you use a CentOS-based image)
|
||||
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
|
||||
|
||||
# Run ssh-agent (inside the build environment)
|
||||
- eval $(ssh-agent -s)
|
||||
|
||||
# For Docker builds disable host key checking. Be aware that by adding that
|
||||
# you are suspectible to man-in-the-middle attacks.
|
||||
# WARNING: Use this only with the Docker executor, if you use it with shell
|
||||
# you will overwrite your user's SSH config.
|
||||
- mkdir -p ~/.ssh
|
||||
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
|
||||
|
||||
# This folder is cached between builds
|
||||
# http://docs.gitlab.com/ce/ci/yaml/README.html#cache
|
||||
#cache:
|
||||
# paths:
|
||||
# - node_modules/
|
||||
|
||||
stages:
|
||||
- lint
|
||||
- build
|
||||
- test
|
||||
- deploy
|
||||
|
||||
lint:
|
||||
stage: lint
|
||||
|
@ -50,14 +28,3 @@ build:
|
|||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
environment: dev
|
||||
only:
|
||||
- develop
|
||||
script:
|
||||
- yarn
|
||||
- npm run build
|
||||
- ssh-add <(echo "$SSH_PRIVATE_KEY")
|
||||
- scp -r dist/* pleroma@tenshi.heldscal.la:~/pleroma
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
# v1.0
|
||||
## Removed features/radically changed behavior
|
||||
### 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.*
|
|
@ -8,3 +8,4 @@ Contributors of this project.
|
|||
- hakui (hakui@freezepeach.xyz): CSS and styling
|
||||
- shpuld (shpuld@shitposter.club): CSS and styling
|
||||
- Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
|
||||
- hj (hj@shigusegubu.club): Code
|
||||
|
|
22
README.md
22
README.md
|
@ -6,11 +6,12 @@
|
|||
|
||||
# For Translators
|
||||
|
||||
To translate Pleroma, add your language to [src/i18n/messages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/messages.js). Pleroma will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
|
||||
To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/messages.js). Pleroma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
|
||||
|
||||
# FOR ADMINS
|
||||
|
||||
You don't need to build Pleroma yourself. Check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma and Qvitter at the same time.
|
||||
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box.
|
||||
For the GNU social backend, check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma-FE and Qvitter at the same time.
|
||||
|
||||
## Build Setup
|
||||
|
||||
|
@ -29,6 +30,21 @@ npm run build
|
|||
npm run unit
|
||||
```
|
||||
|
||||
# For Contributors:
|
||||
|
||||
You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/config/local.example.json)) to enable some convenience dev options:
|
||||
|
||||
* `target`: makes local dev server redirect to some existing instance's BE instead of local BE, useful for testing things in near-production environment and searching for real-life use-cases.
|
||||
* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode.
|
||||
|
||||
FE Build process also leaves current commit hash in global variable `___pleromafe_commit_hash` so that you can easily see which pleroma-fe commit instance is running, also helps pinpointing which commit was used when FE was bundled into BE.
|
||||
|
||||
# Configuration
|
||||
|
||||
Edit config.json for configuration. scopeOptionsEnabled gives you input fields for CWs and the scope settings.
|
||||
Edit config.json for configuration.
|
||||
|
||||
## Options
|
||||
|
||||
### Login methods
|
||||
|
||||
```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations.
|
||||
|
|
|
@ -2,6 +2,7 @@ var path = require('path')
|
|||
var config = require('../config')
|
||||
var utils = require('./utils')
|
||||
var projectRoot = path.resolve(__dirname, '../')
|
||||
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
|
||||
|
||||
var env = process.env.NODE_ENV
|
||||
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the
|
||||
|
@ -54,7 +55,7 @@ module.exports = {
|
|||
loader: 'vue'
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
test: /\.jsx?$/,
|
||||
loader: 'babel',
|
||||
include: projectRoot,
|
||||
exclude: /node_modules\/(?!tributejs)/
|
||||
|
@ -91,5 +92,11 @@ module.exports = {
|
|||
browsers: ['last 2 versions']
|
||||
})
|
||||
]
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new ServiceWorkerWebpackPlugin({
|
||||
entry: path.join(__dirname, '..', 'src/sw.js'),
|
||||
filename: 'sw-pleroma.js'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
@ -18,7 +18,9 @@ module.exports = merge(baseWebpackConfig, {
|
|||
devtool: '#eval-source-map',
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': config.dev.env
|
||||
'process.env': config.dev.env,
|
||||
'COMMIT_HASH': JSON.stringify('DEV'),
|
||||
'DEV_OVERRIDES': JSON.stringify(config.dev.settings)
|
||||
}),
|
||||
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
|
||||
new webpack.optimize.OccurenceOrderPlugin(),
|
||||
|
|
|
@ -7,8 +7,13 @@ var baseWebpackConfig = require('./webpack.base.conf')
|
|||
var ExtractTextPlugin = require('extract-text-webpack-plugin')
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
var env = process.env.NODE_ENV === 'testing'
|
||||
? require('../config/test.env')
|
||||
: config.build.env
|
||||
? require('../config/test.env')
|
||||
: config.build.env
|
||||
|
||||
let commitHash = require('child_process')
|
||||
.execSync('git rev-parse --short HEAD')
|
||||
.toString();
|
||||
console.log(commitHash)
|
||||
|
||||
var webpackConfig = merge(baseWebpackConfig, {
|
||||
module: {
|
||||
|
@ -29,7 +34,9 @@ var webpackConfig = merge(baseWebpackConfig, {
|
|||
plugins: [
|
||||
// http://vuejs.github.io/vue-loader/workflow/production.html
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': env
|
||||
'process.env': env,
|
||||
'COMMIT_HASH': JSON.stringify(commitHash),
|
||||
'DEV_OVERRIDES': JSON.stringify(undefined)
|
||||
}),
|
||||
new webpack.optimize.UglifyJsPlugin({
|
||||
compress: {
|
||||
|
@ -51,7 +58,8 @@ var webpackConfig = merge(baseWebpackConfig, {
|
|||
minify: {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true
|
||||
removeAttributeQuotes: true,
|
||||
ignoreCustomComments: [/server-generated-meta/]
|
||||
// more options:
|
||||
// https://github.com/kangax/html-minifier#options-quick-reference
|
||||
},
|
||||
|
|
|
@ -1,5 +1,15 @@
|
|||
// see http://vuejs-templates.github.io/webpack for documentation.
|
||||
var path = require('path')
|
||||
const path = require('path')
|
||||
let settings = {}
|
||||
try {
|
||||
settings = require('./local.json')
|
||||
console.log('Using local dev server settings (/config/local.json):')
|
||||
console.log(JSON.stringify(settings, null, 2))
|
||||
} catch (e) {
|
||||
console.log('Local dev server settings not found (/config/local.json)')
|
||||
}
|
||||
|
||||
const target = settings.target || 'http://localhost:4000/'
|
||||
|
||||
module.exports = {
|
||||
build: {
|
||||
|
@ -19,16 +29,22 @@ module.exports = {
|
|||
dev: {
|
||||
env: require('./dev.env'),
|
||||
port: 8080,
|
||||
settings,
|
||||
assetsSubDirectory: 'static',
|
||||
assetsPublicPath: '/',
|
||||
proxyTable: {
|
||||
'/api': {
|
||||
target: 'htts://localhost:4000/',
|
||||
target,
|
||||
changeOrigin: true,
|
||||
cookieDomainRewrite: 'localhost'
|
||||
},
|
||||
'/nodeinfo': {
|
||||
target,
|
||||
changeOrigin: true,
|
||||
cookieDomainRewrite: 'localhost'
|
||||
},
|
||||
'/socket': {
|
||||
target: 'htts://localhost:4000/',
|
||||
target,
|
||||
changeOrigin: true,
|
||||
cookieDomainRewrite: 'localhost',
|
||||
ws: true
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"target": "https://pleroma.soykaf.com/",
|
||||
"staticConfigPreference": false
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pleroma</title>
|
||||
<!--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">
|
||||
|
|
14
package.json
14
package.json
|
@ -16,29 +16,41 @@
|
|||
"dependencies": {
|
||||
"babel-plugin-add-module-exports": "^0.2.1",
|
||||
"babel-plugin-lodash": "^3.2.11",
|
||||
"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",
|
||||
"popper.js": "^1.14.7",
|
||||
"sanitize-html": "^1.13.0",
|
||||
"sass-loader": "^4.0.2",
|
||||
"v-click-outside": "^2.1.1",
|
||||
"vue": "^2.5.13",
|
||||
"vue-chat-scroll": "^1.2.1",
|
||||
"vue-i18n": "^7.3.2",
|
||||
"vue-popperjs": "^2.0.3",
|
||||
"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",
|
||||
"@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",
|
||||
|
@ -63,6 +75,7 @@
|
|||
"html-webpack-plugin": "^2.8.1",
|
||||
"http-proxy-middleware": "^0.17.2",
|
||||
"inject-loader": "^2.0.1",
|
||||
"iso-639-1": "^2.0.3",
|
||||
"isparta-loader": "^2.0.0",
|
||||
"json-loader": "^0.5.4",
|
||||
"karma": "^1.3.0",
|
||||
|
@ -83,6 +96,7 @@
|
|||
"raw-loader": "^0.5.1",
|
||||
"selenium-server": "2.53.1",
|
||||
"semver": "^5.3.0",
|
||||
"serviceworker-webpack-plugin": "0.2.3",
|
||||
"shelljs": "^0.7.4",
|
||||
"sinon": "^1.17.3",
|
||||
"sinon-chai": "^2.8.0",
|
||||
|
|
93
src/App.js
93
src/App.js
|
@ -2,9 +2,15 @@ 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 WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.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 MobileNav from './components/mobile_nav/mobile_nav.vue'
|
||||
import { windowWidth } from './services/window_utils/window_utils'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
|
@ -13,34 +19,95 @@ export default {
|
|||
NavPanel,
|
||||
Notifications,
|
||||
UserFinder,
|
||||
WhoToFollowPanel,
|
||||
InstanceSpecificPanel,
|
||||
ChatPanel
|
||||
FeaturesPanel,
|
||||
WhoToFollowPanel,
|
||||
ChatPanel,
|
||||
MediaModal,
|
||||
SideDrawer,
|
||||
MobilePostStatusModal,
|
||||
MobileNav
|
||||
},
|
||||
data: () => ({
|
||||
mobileActivePanel: 'timeline'
|
||||
mobileActivePanel: 'timeline',
|
||||
finderHidden: true,
|
||||
supportsMask: window.CSS && window.CSS.supports && (
|
||||
window.CSS.supports('mask-size', 'contain') ||
|
||||
window.CSS.supports('-webkit-mask-size', 'contain') ||
|
||||
window.CSS.supports('-moz-mask-size', 'contain') ||
|
||||
window.CSS.supports('-ms-mask-size', 'contain') ||
|
||||
window.CSS.supports('-o-mask-size', 'contain')
|
||||
)
|
||||
}),
|
||||
created () {
|
||||
// Load the locale from the storage
|
||||
this.$i18n.locale = this.$store.state.config.interfaceLanguage
|
||||
window.addEventListener('resize', this.updateMobileState)
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('resize', this.updateMobileState)
|
||||
},
|
||||
computed: {
|
||||
currentUser () { return this.$store.state.users.currentUser },
|
||||
background () {
|
||||
return this.currentUser.background_image || this.$store.state.config.background
|
||||
return this.currentUser.background_image || this.$store.state.instance.background
|
||||
},
|
||||
logoStyle () { return { 'background-image': `url(${this.$store.state.config.logo})` } },
|
||||
style () { return { 'background-image': `url(${this.background})` } },
|
||||
sitename () { return this.$store.state.config.name },
|
||||
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
|
||||
logoStyle () {
|
||||
return {
|
||||
'visibility': this.enableMask ? 'hidden' : 'visible'
|
||||
}
|
||||
},
|
||||
logoMaskStyle () {
|
||||
return this.enableMask ? {
|
||||
'mask-image': `url(${this.$store.state.instance.logo})`
|
||||
} : {
|
||||
'background-color': this.enableMask ? '' : 'transparent'
|
||||
}
|
||||
},
|
||||
logoBgStyle () {
|
||||
return Object.assign({
|
||||
'margin': `${this.$store.state.instance.logoMargin} 0`,
|
||||
opacity: this.finderHidden ? 1 : 0
|
||||
}, this.enableMask ? {} : {
|
||||
'background-color': this.enableMask ? '' : 'transparent'
|
||||
})
|
||||
},
|
||||
logo () { return this.$store.state.instance.logo },
|
||||
bgStyle () {
|
||||
return {
|
||||
'background-image': `url(${this.background})`
|
||||
}
|
||||
},
|
||||
bgAppStyle () {
|
||||
return {
|
||||
'--body-background-image': `url(${this.background})`
|
||||
}
|
||||
},
|
||||
sitename () { return this.$store.state.instance.name },
|
||||
chat () { return this.$store.state.chat.channel.state === 'joined' },
|
||||
showWhoToFollowPanel () { return this.$store.state.config.showWhoToFollowPanel },
|
||||
showInstanceSpecificPanel () { return this.$store.state.config.showInstanceSpecificPanel }
|
||||
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
|
||||
showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel },
|
||||
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
|
||||
isMobileLayout () { return this.$store.state.interface.mobileLayout }
|
||||
},
|
||||
methods: {
|
||||
activatePanel (panelName) {
|
||||
this.mobileActivePanel = panelName
|
||||
},
|
||||
scrollToTop () {
|
||||
window.scrollTo(0, 0)
|
||||
},
|
||||
logout () {
|
||||
this.$router.replace('/main/public')
|
||||
this.$store.dispatch('logout')
|
||||
},
|
||||
onFinderToggled (hidden) {
|
||||
this.finderHidden = hidden
|
||||
},
|
||||
updateMobileState () {
|
||||
const mobileLayout = windowWidth() <= 800
|
||||
const changed = mobileLayout !== this.isMobileLayout
|
||||
if (changed) {
|
||||
this.$store.dispatch('setMobileLayout', mobileLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
546
src/App.scss
546
src/App.scss
|
@ -1,15 +1,21 @@
|
|||
@import './_variables.scss';
|
||||
|
||||
#app {
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 50px;
|
||||
min-height: 100vh;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-bg-wrapper {
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 50%;
|
||||
}
|
||||
|
||||
i {
|
||||
user-select: none;
|
||||
}
|
||||
|
@ -34,10 +40,11 @@ h4 {
|
|||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
font-family: var(--interfaceFont, sans-serif);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
color: $fallback--fg;
|
||||
color: var(--fg, $fallback--fg);
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
@ -48,24 +55,39 @@ a {
|
|||
color: var(--link, $fallback--link);
|
||||
}
|
||||
|
||||
button{
|
||||
button {
|
||||
user-select: none;
|
||||
color: $fallback--fg;
|
||||
color: var(--fg, $fallback--fg);
|
||||
background-color: $fallback--btn;
|
||||
background-color: var(--btn, $fallback--btn);
|
||||
color: $fallback--text;
|
||||
color: var(--btnText, $fallback--text);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--btn, $fallback--fg);
|
||||
border: none;
|
||||
border-radius: $fallback--btnRadius;
|
||||
border-radius: var(--btnRadius, $fallback--btnRadius);
|
||||
cursor: pointer;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0px 0px 2px black;
|
||||
box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
|
||||
box-shadow: var(--buttonShadow);
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
font-family: var(--interfaceFont, sans-serif);
|
||||
|
||||
i[class*=icon-] {
|
||||
color: $fallback--text;
|
||||
color: var(--btnText, $fallback--text);
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3);
|
||||
box-shadow: var(--buttonHoverShadow);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
|
||||
box-shadow: var(--buttonPressedShadow);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
@ -79,6 +101,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 {
|
||||
|
@ -90,21 +120,27 @@ input, textarea, .select {
|
|||
border: none;
|
||||
border-radius: $fallback--inputRadius;
|
||||
border-radius: var(--inputRadius, $fallback--inputRadius);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0px 0px 2px black inset;
|
||||
background-color: $fallback--input;
|
||||
background-color: var(--input, $fallback--input);
|
||||
color: $fallback--lightFg;
|
||||
color: var(--lightFg, $fallback--lightFg);
|
||||
box-shadow: 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px 0px 2px 0px rgba(0, 0, 0, 1) inset;
|
||||
box-shadow: var(--inputShadow);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--input, $fallback--fg);
|
||||
color: $fallback--lightText;
|
||||
color: var(--inputText, $fallback--lightText);
|
||||
font-family: sans-serif;
|
||||
font-family: var(--inputFont, sans-serif);
|
||||
font-size: 14px;
|
||||
padding: 8px 7px;
|
||||
padding: 8px .5em;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
height: 29px;
|
||||
height: 28px;
|
||||
line-height: 16px;
|
||||
hyphens: none;
|
||||
|
||||
&:disabled, &[disabled=disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon-down-open {
|
||||
position: absolute;
|
||||
|
@ -112,9 +148,9 @@ input, textarea, .select {
|
|||
bottom: 0;
|
||||
right: 5px;
|
||||
height: 100%;
|
||||
color: $fallback--fg;
|
||||
color: var(--fg, $fallback--fg);
|
||||
line-height: 29px;
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
line-height: 28px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
@ -125,22 +161,40 @@ input, textarea, .select {
|
|||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: $fallback--text;
|
||||
color: var(--inputText, --text, $fallback--text);
|
||||
margin: 0;
|
||||
color: $fallback--fg;
|
||||
color: var(--fg, $fallback--fg);
|
||||
padding: 4px 2em 3px 3px;
|
||||
padding: 0 2em 0 .2em;
|
||||
font-family: sans-serif;
|
||||
font-family: var(--inputFont, sans-serif);
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
height: 29px;
|
||||
height: 28px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&[type=range] {
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&[type=radio],
|
||||
&[type=checkbox] {
|
||||
display: none;
|
||||
&:checked + label::before {
|
||||
color: $fallback--fg;
|
||||
color: var(--fg, $fallback--fg);
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
&:disabled {
|
||||
&,
|
||||
& + label,
|
||||
& + label::before {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
+ label::before {
|
||||
display: inline-block;
|
||||
|
@ -148,14 +202,13 @@ input, textarea, .select {
|
|||
transition: color 200ms;
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
border-radius: $fallback--checkBoxRadius;
|
||||
border-radius: var(--checkBoxRadius, $fallback--checkBoxRadius);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: $fallback--checkboxRadius;
|
||||
border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
|
||||
box-shadow: 0px 0px 2px black inset;
|
||||
box-shadow: var(--inputShadow);
|
||||
margin-right: .5em;
|
||||
background-color: $fallback--input;
|
||||
background-color: var(--input, $fallback--input);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--input, $fallback--fg);
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
line-height: 1.1em;
|
||||
|
@ -168,6 +221,13 @@ input, textarea, .select {
|
|||
}
|
||||
}
|
||||
|
||||
option {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--bg, $fallback--bg);
|
||||
}
|
||||
|
||||
i[class*=icon-] {
|
||||
color: $fallback--icon;
|
||||
color: var(--icon, $fallback--icon)
|
||||
|
@ -181,51 +241,84 @@ i[class*=icon-] {
|
|||
padding: 0 10px 0 10px;
|
||||
}
|
||||
|
||||
.gaps {
|
||||
margin: -1em 0 0 -1em;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: 1;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.1em;
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.gaps > .item {
|
||||
padding: 1em 0 0 1em;
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-size {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
nav {
|
||||
.nav-bar {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
height: 50px;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
z-index: -1;
|
||||
transition: opacity;
|
||||
transition-timing-function: ease-out;
|
||||
transition-duration: 100ms;
|
||||
|
||||
.mask {
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--topBarText, $fallback--fg);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-nav {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-basis: 970px;
|
||||
margin: auto;
|
||||
height: 50px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: auto 80%;
|
||||
|
||||
a i {
|
||||
a, a i {
|
||||
color: $fallback--link;
|
||||
color: var(--link, $fallback--link);
|
||||
color: var(--topBarLink, $fallback--link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -248,15 +341,33 @@ main-router {
|
|||
|
||||
.panel {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
flex-direction: column;
|
||||
margin: 0.5em;
|
||||
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--bg, $fallback--bg);
|
||||
|
||||
border-radius: $fallback--panelRadius;
|
||||
border-radius: var(--panelRadius, $fallback--panelRadius);
|
||||
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
|
||||
&::after, & {
|
||||
border-radius: $fallback--panelRadius;
|
||||
border-radius: var(--panelRadius, $fallback--panelRadius);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
|
||||
box-shadow: var(--panelShadow);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body:empty::before {
|
||||
|
@ -267,15 +378,55 @@ main-router {
|
|||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
|
||||
border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
|
||||
background-size: cover;
|
||||
padding: 0.6em 1.0em;
|
||||
padding: .6em .6em;
|
||||
text-align: left;
|
||||
font-size: 1.3em;
|
||||
line-height: 24px;
|
||||
background-color: $fallback--btn;
|
||||
background-color: var(--btn, $fallback--btn);
|
||||
line-height: 28px;
|
||||
color: var(--panelText);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--panel, $fallback--fg);
|
||||
align-items: baseline;
|
||||
box-shadow: var(--panelHeaderShadow);
|
||||
|
||||
.title {
|
||||
flex: 1 0 auto;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.faint {
|
||||
background-color: transparent;
|
||||
color: $fallback--faint;
|
||||
color: var(--panelFaint, $fallback--faint);
|
||||
}
|
||||
|
||||
.alert {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button, .alert {
|
||||
// height: 100%;
|
||||
line-height: 21px;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
margin-left: .25em;
|
||||
min-width: 1px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $fallback--link;
|
||||
color: var(--panelLink, $fallback--link)
|
||||
}
|
||||
}
|
||||
|
||||
.panel-heading.stub {
|
||||
|
@ -286,6 +437,17 @@ main-router {
|
|||
.panel-footer {
|
||||
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
|
||||
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
|
||||
|
||||
|
||||
.faint {
|
||||
color: $fallback--faint;
|
||||
color: var(--panelFaint, $fallback--faint);
|
||||
}
|
||||
|
||||
a {
|
||||
color: $fallback--link;
|
||||
color: var(--panelLink, $fallback--link)
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body > p {
|
||||
|
@ -304,11 +466,30 @@ main-router {
|
|||
|
||||
nav {
|
||||
z-index: 1000;
|
||||
background-color: $fallback--btn;
|
||||
background-color: var(--btn, $fallback--btn);
|
||||
color: var(--topBarText);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--topBar, $fallback--fg);
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
|
||||
box-shadow: var(--topBarShadow);
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
|
@ -319,7 +500,7 @@ nav {
|
|||
}
|
||||
|
||||
.main {
|
||||
flex-basis: 60%;
|
||||
flex-basis: 50%;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
@ -339,23 +520,17 @@ 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: 960px) {
|
||||
@media all and (min-width: 800px) {
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
nav {
|
||||
.back-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-bounds {
|
||||
overflow: hidden;
|
||||
max-height: 100vh;
|
||||
|
@ -382,20 +557,46 @@ nav {
|
|||
flex-grow: 0;
|
||||
}
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 99px;
|
||||
min-width: 22px;
|
||||
max-width: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
|
||||
&.badge-notification {
|
||||
background-color: $fallback--cRed;
|
||||
background-color: var(--badgeNotification, $fallback--cRed);
|
||||
color: white;
|
||||
color: var(--badgeNotificationText, white);
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 0.35em;
|
||||
padding: 0.25em;
|
||||
border-radius: $fallback--tooltipRadius;
|
||||
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
min-height: 28px;
|
||||
line-height: 28px;
|
||||
|
||||
&.error {
|
||||
background-color: $fallback--cAlertRed;
|
||||
background-color: var(--cAlertRed, $fallback--cAlertRed);
|
||||
background-color: $fallback--alertError;
|
||||
background-color: var(--alertError, $fallback--alertError);
|
||||
color: $fallback--text;
|
||||
color: var(--alertErrorText, $fallback--text);
|
||||
|
||||
.panel-heading & {
|
||||
color: $fallback--text;
|
||||
color: var(--alertErrorPanelText, $fallback--text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -404,7 +605,105 @@ nav {
|
|||
color: var(--faint, $fallback--faint);
|
||||
}
|
||||
|
||||
@media all and (max-width: 959px) {
|
||||
.faint-link {
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 800px) {
|
||||
.logo {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.item.right {
|
||||
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;
|
||||
border: 1px solid var(--faint, $fallback--faint);
|
||||
border-radius: $fallback--inputRadius;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
@keyframes shakeError {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
15% {
|
||||
transform: translateX(0.375rem);
|
||||
}
|
||||
30% {
|
||||
transform: translateX(-0.375rem);
|
||||
}
|
||||
45% {
|
||||
transform: translateX(0.375rem);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-0.375rem);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(0.375rem);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(-0.375rem);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 800px) {
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
@ -414,15 +713,84 @@ nav {
|
|||
}
|
||||
|
||||
.container {
|
||||
padding: 0 0 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin: 0.5em 0 0.5em 0;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
display: block;
|
||||
margin-right: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.item.right {
|
||||
text-align: right;
|
||||
padding-right: 20px;
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
|
||||
@media all and (min-width: 801px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 1em 0px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.btn-default {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
&-panel {
|
||||
position: relative;
|
||||
|
||||
&-body {
|
||||
margin: 0 0.5em 0 0.5em;
|
||||
border-radius: $fallback--tooltipRadius;
|
||||
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
// this doesn't match original but i don't care, making it uniform.
|
||||
box-shadow: var(--popupShadow);
|
||||
min-width: 75%;
|
||||
background: $fallback--bg;
|
||||
background: var(--bg, $fallback--bg);
|
||||
color: $fallback--lightText;
|
||||
color: var(--lightText, $fallback--lightText);
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
cursor: pointer;
|
||||
padding: 0.2em 0.4em 0.2em 0.4em;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
span {
|
||||
line-height: 24px;
|
||||
margin: 0 0.1em 0 0.2em;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-left: .5em;
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--lightBg, $fallback--fg);
|
||||
}
|
||||
}
|
||||
}
|
41
src/App.vue
41
src/App.vue
|
@ -1,42 +1,51 @@
|
|||
<template>
|
||||
<div id="app" v-bind:style="style">
|
||||
<nav class='container' @click="scrollToTop()" id="nav">
|
||||
<div class='inner-nav' :style="logoStyle">
|
||||
<div id="app" v-bind:style="bgAppStyle">
|
||||
<div class="app-bg-wrapper" v-bind:style="bgStyle"></div>
|
||||
<MobileNav v-if="isMobileLayout" />
|
||||
<nav v-else 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'>
|
||||
<router-link :to="{ name: 'root'}">{{sitename}}</router-link>
|
||||
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
|
||||
</div>
|
||||
<div class='item right'>
|
||||
<user-finder class="nav-icon"></user-finder>
|
||||
<router-link :to="{ name: 'settings'}"><i class="icon-cog nav-icon"></i></router-link>
|
||||
<a href="#" v-if="currentUser" @click.prevent="logout"><i class="icon-logout nav-icon" :title="$t('login.logout')"></i></a>
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container" id="content">
|
||||
<div class="panel-switcher">
|
||||
<button @click="activatePanel('sidebar')">Sidebar</button>
|
||||
<button @click="activatePanel('timeline')">Timeline</button>
|
||||
</div>
|
||||
<div class="sidebar-flexer" :class="{ 'mobile-hidden': mobileActivePanel != 'sidebar'}">
|
||||
<div v-if="" class="container" id="content">
|
||||
<div class="sidebar-flexer mobile-hidden" v-if="!isMobileLayout">
|
||||
<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>
|
||||
<who-to-follow-panel v-if="currentUser && showWhoToFollowPanel"></who-to-follow-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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main" :class="{ 'mobile-hidden': mobileActivePanel != 'timeline' }">
|
||||
<div class="main">
|
||||
<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>
|
||||
</transition>
|
||||
</div>
|
||||
<media-modal></media-modal>
|
||||
</div>
|
||||
<chat-panel v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
|
||||
<chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -3,24 +3,23 @@ $main-background: white;
|
|||
$darkened-background: whitesmoke;
|
||||
|
||||
$fallback--bg: #121a24;
|
||||
$fallback--btn: #182230;
|
||||
$fallback--input: #182230;
|
||||
$fallback--fg: #182230;
|
||||
$fallback--faint: rgba(185, 185, 186, .5);
|
||||
$fallback--fg: #b9b9ba;
|
||||
$fallback--text: #b9b9ba;
|
||||
$fallback--link: #d8a070;
|
||||
$fallback--icon: #666;
|
||||
$fallback--lightBg: rgb(21, 30, 42);
|
||||
$fallback--lightFg: #b9b9ba;
|
||||
$fallback--lightText: #b9b9ba;
|
||||
$fallback--border: #222;
|
||||
$fallback--cRed: #ff0000;
|
||||
$fallback--cBlue: #0095ff;
|
||||
$fallback--cGreen: #0fa00f;
|
||||
$fallback--cOrange: orange;
|
||||
|
||||
$fallback--cAlertRed: rgba(211,16,20,.5);
|
||||
$fallback--alertError: rgba(211,16,20,.5);
|
||||
|
||||
$fallback--panelRadius: 10px;
|
||||
$fallback--checkBoxRadius: 2px;
|
||||
$fallback--checkboxRadius: 2px;
|
||||
$fallback--btnRadius: 4px;
|
||||
$fallback--inputRadius: 4px;
|
||||
$fallback--tooltipRadius: 5px;
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 34 KiB |
|
@ -0,0 +1,293 @@
|
|||
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'
|
||||
|
||||
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.
|
||||
if (uploadlimit) {
|
||||
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
|
||||
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
|
||||
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
|
||||
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
|
||||
}
|
||||
|
||||
if (vapidPublicKey) {
|
||||
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
|
||||
}
|
||||
|
||||
return data.site.pleromafe
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Could not load statusnet config, potentially fatal')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
||||
const setSettings = async ({ apiConfig, staticConfig, store }) => {
|
||||
const overrides = window.___pleromafe_dev_overrides || {}
|
||||
const env = window.___pleromafe_mode.NODE_ENV
|
||||
|
||||
// This takes static config and overrides properties that are present in apiConfig
|
||||
let config = {}
|
||||
if (overrides.staticConfigPreference && env === 'development') {
|
||||
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
|
||||
config = Object.assign({}, apiConfig, staticConfig)
|
||||
} else {
|
||||
config = Object.assign({}, staticConfig, apiConfig)
|
||||
}
|
||||
|
||||
const copyInstanceOption = (name) => {
|
||||
store.dispatch('setInstanceOption', { name, value: config[name] })
|
||||
}
|
||||
|
||||
copyInstanceOption('nsfwCensorImage')
|
||||
copyInstanceOption('background')
|
||||
copyInstanceOption('hidePostStats')
|
||||
copyInstanceOption('hideUserStats')
|
||||
copyInstanceOption('hideFilteredStatuses')
|
||||
copyInstanceOption('logo')
|
||||
|
||||
store.dispatch('setInstanceOption', {
|
||||
name: 'logoMask',
|
||||
value: typeof config.logoMask === 'undefined'
|
||||
? true
|
||||
: config.logoMask
|
||||
})
|
||||
|
||||
store.dispatch('setInstanceOption', {
|
||||
name: 'logoMargin',
|
||||
value: typeof config.logoMargin === 'undefined'
|
||||
? 0
|
||||
: config.logoMargin
|
||||
})
|
||||
|
||||
copyInstanceOption('redirectRootNoLogin')
|
||||
copyInstanceOption('redirectRootLogin')
|
||||
copyInstanceOption('showInstanceSpecificPanel')
|
||||
copyInstanceOption('minimalScopesMode')
|
||||
copyInstanceOption('formattingOptionsEnabled')
|
||||
copyInstanceOption('hideMutedPosts')
|
||||
copyInstanceOption('collapseMessageWithSubject')
|
||||
copyInstanceOption('loginMethod')
|
||||
copyInstanceOption('scopeCopy')
|
||||
copyInstanceOption('subjectLineBehavior')
|
||||
copyInstanceOption('postContentType')
|
||||
copyInstanceOption('alwaysShowSubjectInput')
|
||||
copyInstanceOption('noAttachmentLinks')
|
||||
copyInstanceOption('showFeaturesPanel')
|
||||
|
||||
if ((config.chatDisabled)) {
|
||||
store.dispatch('disableChat')
|
||||
} else {
|
||||
store.dispatch('initializeSocket')
|
||||
}
|
||||
|
||||
return store.dispatch('setTheme', config['theme'])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 getStaticEmoji = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/static/emoji.json')
|
||||
if (res.ok) {
|
||||
const values = await res.json()
|
||||
const emoji = Object.keys(values).map((key) => {
|
||||
return { shortcode: key, image_url: false, 'utf': values[key] }
|
||||
})
|
||||
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Can't load static emoji")
|
||||
console.warn(e)
|
||||
}
|
||||
}
|
||||
|
||||
// This is also used to indicate if we have a 'pleroma backend' or not.
|
||||
// Somewhat weird, should probably be somewhere else.
|
||||
const getCustomEmoji = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/api/pleroma/emoji.json')
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
const values = Array.isArray(result) ? Object.assign({}, ...result) : result
|
||||
const emoji = Object.keys(values).map((key) => {
|
||||
return { shortcode: key, image_url: values[key].image_url || values[key] }
|
||||
})
|
||||
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
|
||||
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (e) {
|
||||
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false })
|
||||
console.warn("Can't load custom emojis, maybe not a Pleroma instance?")
|
||||
console.warn(e)
|
||||
}
|
||||
}
|
||||
|
||||
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: '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 })
|
||||
|
||||
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') })
|
||||
} 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 })
|
||||
}
|
||||
|
||||
const checkOAuthToken = async ({ store }) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if (store.state.oauth.token) {
|
||||
try {
|
||||
await store.dispatch('loginUser', store.state.oauth.token)
|
||||
} 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 }),
|
||||
getStaticEmoji({ store }),
|
||||
getCustomEmoji({ 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
|
|
@ -0,0 +1,53 @@
|
|||
import PublicTimeline from 'components/public_timeline/public_timeline.vue'
|
||||
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
|
||||
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
|
||||
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
|
||||
import ConversationPage from 'components/conversation-page/conversation-page.vue'
|
||||
import Mentions from 'components/mentions/mentions.vue'
|
||||
import DMs from 'components/dm_timeline/dm_timeline.vue'
|
||||
import UserProfile from 'components/user_profile/user_profile.vue'
|
||||
import Settings from 'components/settings/settings.vue'
|
||||
import Registration from 'components/registration/registration.vue'
|
||||
import UserSettings from 'components/user_settings/user_settings.vue'
|
||||
import FollowRequests from 'components/follow_requests/follow_requests.vue'
|
||||
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
|
||||
import UserSearch from 'components/user_search/user_search.vue'
|
||||
import Notifications from 'components/notifications/notifications.vue'
|
||||
import LoginForm from 'components/login_form/login_form.vue'
|
||||
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'
|
||||
|
||||
export default (store) => {
|
||||
return [
|
||||
{ name: 'root',
|
||||
path: '/',
|
||||
redirect: _to => {
|
||||
return (store.state.users.currentUser
|
||||
? 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: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
|
||||
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
|
||||
{ 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: 'settings', path: '/settings', component: Settings },
|
||||
{ name: 'registration', path: '/registration', component: Registration },
|
||||
{ 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: '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: 'about', path: '/about', component: About },
|
||||
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
|
||||
]
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
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'
|
||||
|
||||
const About = {
|
||||
components: {
|
||||
InstanceSpecificPanel,
|
||||
FeaturesPanel,
|
||||
TermsOfServicePanel
|
||||
},
|
||||
computed: {
|
||||
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
|
||||
}
|
||||
}
|
||||
|
||||
export default About
|
|
@ -0,0 +1,12 @@
|
|||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./about.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
|
@ -1,4 +1,5 @@
|
|||
import StillImage from '../still-image/still-image.vue'
|
||||
import VideoAttachment from '../video_attachment/video_attachment.vue'
|
||||
import nsfwImage from '../../assets/nsfw.png'
|
||||
import fileTypeService from '../../services/file_type/file_type.service.js'
|
||||
|
||||
|
@ -7,21 +8,32 @@ const Attachment = {
|
|||
'attachment',
|
||||
'nsfw',
|
||||
'statusId',
|
||||
'size'
|
||||
'size',
|
||||
'allowPlay',
|
||||
'setMedia'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
nsfwImage,
|
||||
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
|
||||
hideNsfwLocal: this.$store.state.config.hideNsfw,
|
||||
showHidden: false,
|
||||
preloadImage: this.$store.state.config.preloadImage,
|
||||
loading: false,
|
||||
img: document.createElement('img')
|
||||
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
|
||||
modalOpen: false,
|
||||
showHidden: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
StillImage
|
||||
StillImage,
|
||||
VideoAttachment
|
||||
},
|
||||
computed: {
|
||||
usePlaceHolder () {
|
||||
return this.size === 'hide' || this.type === 'unknown'
|
||||
},
|
||||
referrerpolicy () {
|
||||
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
|
||||
},
|
||||
type () {
|
||||
return fileTypeService.fileType(this.attachment.mimetype)
|
||||
},
|
||||
|
@ -35,7 +47,7 @@ const Attachment = {
|
|||
return this.size === 'small'
|
||||
},
|
||||
fullwidth () {
|
||||
return fileTypeService.fileType(this.attachment.mimetype) === 'html'
|
||||
return this.type === 'html' || this.type === 'audio'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -44,16 +56,37 @@ const Attachment = {
|
|||
window.open(target.href, '_blank')
|
||||
}
|
||||
},
|
||||
toggleHidden () {
|
||||
if (this.img.onload) {
|
||||
this.img.onload()
|
||||
} else {
|
||||
this.loading = true
|
||||
this.img.src = this.attachment.url
|
||||
this.img.onload = () => {
|
||||
this.loading = false
|
||||
this.showHidden = !this.showHidden
|
||||
openModal (event) {
|
||||
const modalTypes = this.$store.state.config.playVideosInModal
|
||||
? ['image', 'video']
|
||||
: ['image']
|
||||
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
|
||||
this.usePlaceHolder
|
||||
) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
this.setMedia()
|
||||
this.$store.dispatch('setCurrent', this.attachment)
|
||||
}
|
||||
},
|
||||
toggleHidden (event) {
|
||||
if (this.$store.state.config.useOneClickNsfw && !this.showHidden) {
|
||||
this.openModal(event)
|
||||
return
|
||||
}
|
||||
if (this.img && !this.preloadImage) {
|
||||
if (this.img.onload) {
|
||||
this.img.onload()
|
||||
} else {
|
||||
this.loading = true
|
||||
this.img.src = this.attachment.url
|
||||
this.img.onload = () => {
|
||||
this.loading = false
|
||||
this.showHidden = !this.showHidden
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.showHidden = !this.showHidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,44 @@
|
|||
<template>
|
||||
<div v-if="size==='hide'">
|
||||
<a class="placeholder" v-if="type !== 'html'" target="_blank" :href="attachment.url">[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]</a>
|
||||
<div v-if="usePlaceHolder" @click="openModal">
|
||||
<a class="placeholder"
|
||||
v-if="type !== 'html'"
|
||||
target="_blank" :href="attachment.url"
|
||||
>
|
||||
[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]
|
||||
</a>
|
||||
</div>
|
||||
<div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth}" v-show="!isEmpty">
|
||||
<a class="image-attachment" v-if="hidden" @click.prevent="toggleHidden()">
|
||||
<img :key="nsfwImage" :src="nsfwImage"/>
|
||||
<div
|
||||
v-else class="attachment"
|
||||
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
|
||||
v-show="!isEmpty"
|
||||
>
|
||||
<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>
|
||||
<div class="hider" v-if="nsfw && hideNsfwLocal && !hidden">
|
||||
<a href="#" @click.prevent="toggleHidden()">Hide</a>
|
||||
<a href="#" @click.prevent="toggleHidden">Hide</a>
|
||||
</div>
|
||||
|
||||
<a v-if="type === 'image' && !hidden" class="image-attachment" :href="attachment.url" target="_blank">
|
||||
<StillImage :class="{'small': isSmall}" referrerpolicy="no-referrer" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
|
||||
<a v-if="type === 'image' && (!hidden || preloadImage)"
|
||||
@click="openModal"
|
||||
class="image-attachment"
|
||||
:class="{'hidden': hidden && preloadImage }"
|
||||
:href="attachment.url" target="_blank"
|
||||
:title="attachment.description"
|
||||
>
|
||||
<StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
|
||||
</a>
|
||||
|
||||
<video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" :src="attachment.url" controls loop></video>
|
||||
<a class="video-container"
|
||||
@click="openModal"
|
||||
v-if="type === 'video' && !hidden"
|
||||
:class="{'small': isSmall}"
|
||||
:href="allowPlay ? undefined : attachment.url"
|
||||
>
|
||||
<VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" />
|
||||
<i v-if="!allowPlay" class="play-icon icon-play-circled"></i>
|
||||
</a>
|
||||
|
||||
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio>
|
||||
|
||||
|
@ -38,28 +62,33 @@
|
|||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-right: -0.7em;
|
||||
|
||||
.attachment.media-upload-container {
|
||||
flex: 0 0 auto;
|
||||
max-height: 300px;
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
video {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
margin-right: 0.5em;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.small-attachment {
|
||||
&.image, &.video {
|
||||
max-width: 35%;
|
||||
.nsfw-placeholder {
|
||||
cursor: pointer;
|
||||
|
||||
&.loading {
|
||||
cursor: progress;
|
||||
}
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
flex: 1 0 30%;
|
||||
margin: 0.5em 0.7em 0.6em 0.0em;
|
||||
position: relative;
|
||||
margin-top: 0.5em;
|
||||
align-self: flex-start;
|
||||
line-height: 0;
|
||||
|
||||
|
@ -71,6 +100,28 @@
|
|||
border-color: var(--border, $fallback--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.non-gallery.attachment {
|
||||
&.video {
|
||||
flex: 1 0 40%;
|
||||
}
|
||||
.nsfw {
|
||||
height: 260px;
|
||||
}
|
||||
.small {
|
||||
height: 120px;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.video {
|
||||
height: 260px;
|
||||
display: flex;
|
||||
}
|
||||
video {
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.fullwidth {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
@ -79,18 +130,38 @@
|
|||
line-height: 0;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
display: flex;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
position: absolute;
|
||||
font-size: 64px;
|
||||
top: calc(50% - 32px);
|
||||
left: calc(50% - 32px);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.play-icon::before {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.html {
|
||||
flex-basis: 90%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.hider {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
white-space: nowrap;
|
||||
margin: 10px;
|
||||
padding: 5px;
|
||||
background: rgba(230,230,230,0.6);
|
||||
|
@ -101,13 +172,7 @@
|
|||
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
|
||||
}
|
||||
|
||||
.small {
|
||||
max-height: 100px;
|
||||
}
|
||||
video {
|
||||
max-height: 500px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
|
@ -117,7 +182,7 @@
|
|||
|
||||
img.media-upload {
|
||||
line-height: 0;
|
||||
max-height: 300px;
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
@ -154,25 +219,20 @@
|
|||
}
|
||||
|
||||
.image-attachment {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.still-image {
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nsfw {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.small {
|
||||
img {
|
||||
max-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%; /* If this isn't here, chrome will stretch the images */
|
||||
max-height: 500px;
|
||||
image-orientation: from-image;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div class="autosuggest" v-click-outside="onClickOutside">
|
||||
<input v-model="term" :placeholder="placeholder" @click="onInputClick" class="autosuggest-input" />
|
||||
<div class="autosuggest-results" v-if="resultsVisible && filtered.length > 0">
|
||||
<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>
|
|
@ -0,0 +1,28 @@
|
|||
import UserCard from '../user_card/user_card.vue'
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
|
||||
const BasicUserCard = {
|
||||
props: [
|
||||
'user'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
userExpanded: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
UserCard,
|
||||
UserAvatar
|
||||
},
|
||||
methods: {
|
||||
toggleUserExpanded () {
|
||||
this.userExpanded = !this.userExpanded
|
||||
},
|
||||
userProfileLink (user) {
|
||||
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BasicUserCard
|
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div class="basic-user-card">
|
||||
<router-link :to="userProfileLink(user)">
|
||||
<UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/>
|
||||
</router-link>
|
||||
<div class="basic-user-card-expanded-content" v-if="userExpanded">
|
||||
<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" class="basic-user-card-user-name-value" v-html="user.name_html"></span>
|
||||
<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>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./basic_user_card.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.basic-user-card {
|
||||
display: flex;
|
||||
flex: 1 0;
|
||||
margin: 0;
|
||||
padding: 0.6em 1em;
|
||||
|
||||
&-collapsed-content {
|
||||
margin-left: 0.7em;
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-user-name {
|
||||
img {
|
||||
object-fit: contain;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&-value {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
&-expanded-content {
|
||||
flex: 1;
|
||||
margin-left: 0.7em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,37 @@
|
|||
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
|
||||
|
||||
const BlockCard = {
|
||||
props: ['userId'],
|
||||
data () {
|
||||
return {
|
||||
progress: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user () {
|
||||
return this.$store.getters.findUser(this.userId)
|
||||
},
|
||||
blocked () {
|
||||
return this.user.statusnet_blocking
|
||||
}
|
||||
},
|
||||
components: {
|
||||
BasicUserCard
|
||||
},
|
||||
methods: {
|
||||
unblockUser () {
|
||||
this.progress = true
|
||||
this.$store.dispatch('unblockUser', this.user.id).then(() => {
|
||||
this.progress = false
|
||||
})
|
||||
},
|
||||
blockUser () {
|
||||
this.progress = true
|
||||
this.$store.dispatch('blockUser', this.user.id).then(() => {
|
||||
this.progress = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BlockCard
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<basic-user-card :user="user">
|
||||
<div class="block-card-content-container">
|
||||
<button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked">
|
||||
<template v-if="progress">
|
||||
{{ $t('user_card.unblock_progress') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('user_card.unblock') }}
|
||||
</template>
|
||||
</button>
|
||||
<button class="btn btn-default" @click="blockUser" :disabled="progress" v-else>
|
||||
<template v-if="progress">
|
||||
{{ $t('user_card.block_progress') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('user_card.block') }}
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</basic-user-card>
|
||||
</template>
|
||||
|
||||
<script src="./block_card.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.block-card-content-container {
|
||||
margin-top: 0.5em;
|
||||
text-align: right;
|
||||
button {
|
||||
width: 10em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,4 +1,7 @@
|
|||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
|
||||
const chatPanel = {
|
||||
props: [ 'floating' ],
|
||||
data () {
|
||||
return {
|
||||
currentMessage: '',
|
||||
|
@ -18,6 +21,9 @@ const chatPanel = {
|
|||
},
|
||||
togglePanel () {
|
||||
this.collapsed = !this.collapsed
|
||||
},
|
||||
userProfileLink (user) {
|
||||
return generateProfileLink(user.id, user.username, this.$store.state.instance.restrictedNicknames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<div class="chat-panel" v-if="!this.collapsed">
|
||||
<div class="chat-panel" v-if="!this.collapsed || !this.floating">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading timeline-heading chat-heading" @click.stop.prevent="togglePanel">
|
||||
<div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel">
|
||||
<div class="title">
|
||||
{{$t('chat.title')}}
|
||||
<i class="icon-cancel" style="float: right;"></i>
|
||||
<span>{{$t('chat.title')}}</span>
|
||||
<i class="icon-cancel" v-if="floating"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-window" v-chat-scroll>
|
||||
|
@ -13,8 +13,10 @@
|
|||
<img :src="message.author.avatar" />
|
||||
</span>
|
||||
<div class="chat-content">
|
||||
<router-link class="chat-name" :to="{ name: 'user-profile', params: { id: message.author.id } }">
|
||||
{{message.author.username}}
|
||||
<router-link
|
||||
class="chat-name"
|
||||
:to="userProfileLink(message.author)">
|
||||
{{message.author.username}}
|
||||
</router-link>
|
||||
<br>
|
||||
<span class="chat-text">
|
||||
|
@ -50,24 +52,25 @@
|
|||
right: 0px;
|
||||
bottom: 0px;
|
||||
z-index: 1000;
|
||||
max-width: 25em;
|
||||
}
|
||||
|
||||
.chat-heading {
|
||||
cursor: pointer;
|
||||
.icon-comment-empty {
|
||||
color: $fallback--fg;
|
||||
color: var(--fg, $fallback--fg);
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
width: 345px;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 20em;
|
||||
}
|
||||
|
||||
.chat-name {
|
||||
.chat-window-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
|
@ -95,4 +98,11 @@
|
|||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
.title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" :checked="checked" @change="$emit('change', $event.target.checked)" :indeterminate.prop="indeterminate">
|
||||
<i class="checkbox-indicator" />
|
||||
<span v-if="!!$slots.default"><slot></slot></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
model: {
|
||||
prop: 'checked',
|
||||
event: 'change'
|
||||
},
|
||||
props: ['checked', 'indeterminate']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-left: 1.2em;
|
||||
min-height: 1.2em;
|
||||
|
||||
&-indicator::before {
|
||||
position: absolute;
|
||||
left: 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
&:disabled + .checkbox-indicator::before {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
& > span {
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<div class="color-control style-control" :class="{ disabled: !present || disabled }">
|
||||
<label :for="name" class="label">
|
||||
{{label}}
|
||||
</label>
|
||||
<input
|
||||
v-if="typeof fallback !== 'undefined'"
|
||||
class="opt exlcude-disabled"
|
||||
:id="name + '-o'"
|
||||
type="checkbox"
|
||||
:checked="present"
|
||||
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)">
|
||||
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
|
||||
<input
|
||||
:id="name"
|
||||
class="color-input"
|
||||
type="color"
|
||||
:value="value || fallback"
|
||||
:disabled="!present || disabled"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
>
|
||||
<input
|
||||
:id="name + '-t'"
|
||||
class="text-input"
|
||||
type="text"
|
||||
:value="value || fallback"
|
||||
:disabled="!present || disabled"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: [
|
||||
'name', 'label', 'value', 'fallback', 'disabled'
|
||||
],
|
||||
computed: {
|
||||
present () {
|
||||
return typeof this.value !== 'undefined'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.color-control {
|
||||
input.text-input {
|
||||
max-width: 7em;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<span v-if="contrast" class="contrast-ratio">
|
||||
<span :title="hint" class="rating">
|
||||
<span v-if="contrast.aaa">
|
||||
<i class="icon-thumbs-up-alt"/>
|
||||
</span>
|
||||
<span v-if="!contrast.aaa && contrast.aa">
|
||||
<i class="icon-adjust"/>
|
||||
</span>
|
||||
<span v-if="!contrast.aaa && !contrast.aa">
|
||||
<i class="icon-attention"/>
|
||||
</span>
|
||||
</span>
|
||||
<span class="rating" v-if="contrast && large" :title="hint_18pt">
|
||||
<span v-if="contrast.laaa">
|
||||
<i class="icon-thumbs-up-alt"/>
|
||||
</span>
|
||||
<span v-if="!contrast.laaa && contrast.laa">
|
||||
<i class="icon-adjust"/>
|
||||
</span>
|
||||
<span v-if="!contrast.laaa && !contrast.laa">
|
||||
<i class="icon-attention"/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: [
|
||||
'large', 'contrast'
|
||||
],
|
||||
computed: {
|
||||
hint () {
|
||||
const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')
|
||||
const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
|
||||
const context = this.$t('settings.style.common.contrast.context.text')
|
||||
const ratio = this.contrast.text
|
||||
return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
|
||||
},
|
||||
hint_18pt () {
|
||||
const levelVal = this.contrast.laaa ? 'aaa' : (this.contrast.laa ? 'aa' : 'bad')
|
||||
const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
|
||||
const context = this.$t('settings.style.common.contrast.context.18pt')
|
||||
const ratio = this.contrast.text
|
||||
return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.contrast-ratio {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
margin-top: -4px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.label {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,5 +1,4 @@
|
|||
import Conversation from '../conversation/conversation.vue'
|
||||
import { find, toInteger } from 'lodash'
|
||||
|
||||
const conversationPage = {
|
||||
components: {
|
||||
|
@ -7,9 +6,9 @@ const conversationPage = {
|
|||
},
|
||||
computed: {
|
||||
statusoid () {
|
||||
const id = toInteger(this.$route.params.id)
|
||||
const statuses = this.$store.state.statuses.allStatuses
|
||||
const status = find(statuses, {id})
|
||||
const id = this.$route.params.id
|
||||
const statuses = this.$store.state.statuses.allStatusesObject
|
||||
const status = statuses[id]
|
||||
|
||||
return status
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<conversation :collapsable="false" :statusoid="statusoid"></conversation>
|
||||
<conversation
|
||||
:collapsable="false"
|
||||
isPage="true"
|
||||
:statusoid="statusoid"
|
||||
></conversation>
|
||||
</template>
|
||||
|
||||
<script src="./conversation-page.js"></script>
|
||||
|
|
|
@ -1,38 +1,94 @@
|
|||
import { reduce, filter, sortBy } from 'lodash'
|
||||
import { statusType } from '../../modules/statuses.js'
|
||||
import { reduce, filter, findIndex, clone } from 'lodash'
|
||||
import Status from '../status/status.vue'
|
||||
|
||||
const sortAndFilterConversation = (conversation) => {
|
||||
conversation = filter(conversation, (status) => statusType(status) !== 'retweet')
|
||||
return sortBy(conversation, 'id')
|
||||
const sortById = (a, b) => {
|
||||
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) {
|
||||
return seqA < seqB ? -1 : 1
|
||||
} else if (isSeqA && !isSeqB) {
|
||||
return -1
|
||||
} else if (!isSeqA && isSeqB) {
|
||||
return 1
|
||||
} else {
|
||||
return idA < idB ? -1 : 1
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
'collapsable',
|
||||
'isPage'
|
||||
],
|
||||
created () {
|
||||
if (this.isPage) {
|
||||
this.fetchConversation()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
status () { return this.statusoid },
|
||||
status () {
|
||||
return this.statusoid
|
||||
},
|
||||
statusId () {
|
||||
if (this.statusoid.retweeted_status) {
|
||||
return this.statusoid.retweeted_status.id
|
||||
} else {
|
||||
return this.statusoid.id
|
||||
}
|
||||
},
|
||||
conversationId () {
|
||||
if (this.statusoid.retweeted_status) {
|
||||
return this.statusoid.retweeted_status.statusnet_conversation_id
|
||||
} else {
|
||||
return this.statusoid.statusnet_conversation_id
|
||||
}
|
||||
},
|
||||
conversation () {
|
||||
if (!this.status) {
|
||||
return false
|
||||
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.statusId })
|
||||
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}) => {
|
||||
const irid = Number(in_reply_to_status_id)
|
||||
/* eslint-disable camelcase */
|
||||
const irid = in_reply_to_status_id
|
||||
/* eslint-enable camelcase */
|
||||
if (irid) {
|
||||
result[irid] = result[irid] || []
|
||||
result[irid].push({
|
||||
|
@ -43,24 +99,31 @@ const conversation = {
|
|||
i++
|
||||
return result
|
||||
}, {})
|
||||
},
|
||||
isExpanded () {
|
||||
return this.expanded || this.isPage
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Status
|
||||
},
|
||||
created () {
|
||||
this.fetchConversation()
|
||||
},
|
||||
watch: {
|
||||
'$route': 'fetchConversation'
|
||||
'$route': '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.statusoid.id))
|
||||
this.$store.state.api.backendInteractor.fetchConversation({id: this.status.id})
|
||||
.then(({ancestors, descendants}) => {
|
||||
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
|
||||
this.$store.dispatch('addNewStatuses', { statuses: descendants })
|
||||
})
|
||||
.then(() => this.setHighlight(this.statusId))
|
||||
} else {
|
||||
const id = this.$route.params.id
|
||||
this.$store.state.api.backendInteractor.fetchStatus({id})
|
||||
|
@ -69,18 +132,22 @@ const conversation = {
|
|||
}
|
||||
},
|
||||
getReplies (id) {
|
||||
id = Number(id)
|
||||
return this.replies[id] || []
|
||||
},
|
||||
focused (id) {
|
||||
if (this.statusoid.retweeted_status) {
|
||||
return (id === this.statusoid.retweeted_status.id)
|
||||
} else {
|
||||
return (id === this.statusoid.id)
|
||||
}
|
||||
return (this.isExpanded) && id === this.status.id
|
||||
},
|
||||
setHighlight (id) {
|
||||
this.highlight = Number(id)
|
||||
this.highlight = id
|
||||
},
|
||||
getHighlight () {
|
||||
return this.isExpanded ? this.highlight : null
|
||||
},
|
||||
toggleExpanded () {
|
||||
this.expanded = !this.expanded
|
||||
if (!this.expanded) {
|
||||
this.setHighlight(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,26 +1,42 @@
|
|||
<template>
|
||||
<div class="timeline panel panel-default">
|
||||
<div class="panel-heading conversation-heading">
|
||||
{{ $t('timeline.conversation') }}
|
||||
<span v-if="collapsable" style="float:right;">
|
||||
<small><a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a></small>
|
||||
<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="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"
|
||||
@goto="setHighlight"
|
||||
@toggleExpanded="toggleExpanded"
|
||||
:key="status.id"
|
||||
:inlineExpanded="collapsable"
|
||||
:statusoid="status"
|
||||
:expandable='!isExpanded'
|
||||
:focused="focused(status.id)"
|
||||
:inConversation="isExpanded"
|
||||
:highlight="getHighlight()"
|
||||
:replies="getReplies(status.id)"
|
||||
class="status-fadein panel-body"
|
||||
/>
|
||||
</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>
|
||||
|
|
|
@ -10,7 +10,11 @@ const DeleteButton = {
|
|||
},
|
||||
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 }
|
||||
canDelete () {
|
||||
if (!this.currentUser) { return }
|
||||
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
|
||||
return superuser || this.status.user.id === this.currentUser.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div v-if="canDelete">
|
||||
<a href="#" v-on:click.prevent="deleteStatus()">
|
||||
<i class='icon-cancel delete-status'></i>
|
||||
<i class='button-icon icon-cancel delete-status'></i>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -14,8 +14,8 @@
|
|||
.icon-cancel,.delete-status {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: var(--cRed, $fallback--cRed);
|
||||
color: $fallback--cRed;
|
||||
color: var(--cRed, $fallback--cRed);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
const DialogModal = {
|
||||
props: {
|
||||
darkOverlay: {
|
||||
default: true,
|
||||
type: Boolean
|
||||
},
|
||||
onCancel: {
|
||||
default: () => {},
|
||||
type: Function
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DialogModal
|
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<span v-bind: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"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-modal-content">
|
||||
<slot name="default"></slot>
|
||||
</div>
|
||||
<div class="dialog-modal-footer user-interactions panel-footer">
|
||||
<slot name="footer"></slot>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
width: auto;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,14 @@
|
|||
import Timeline from '../timeline/timeline.vue'
|
||||
|
||||
const DMs = {
|
||||
computed: {
|
||||
timeline () {
|
||||
return this.$store.state.statuses.timelines.dms
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Timeline
|
||||
}
|
||||
}
|
||||
|
||||
export default DMs
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<Timeline :title="$t('nav.dms')" v-bind:timeline="timeline" v-bind:timeline-name="'dms'"/>
|
||||
</template>
|
||||
|
||||
<script src="./dm_timeline.js"></script>
|
|
@ -0,0 +1,107 @@
|
|||
import Completion from '../../services/completion/completion.js'
|
||||
import { take, filter, map } from 'lodash'
|
||||
|
||||
const EmojiInput = {
|
||||
props: [
|
||||
'value',
|
||||
'placeholder',
|
||||
'type',
|
||||
'classname'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
highlighted: 0,
|
||||
caret: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
suggestions () {
|
||||
const firstchar = this.textAtCaret.charAt(0)
|
||||
if (firstchar === ':') {
|
||||
if (this.textAtCaret === ':') { return }
|
||||
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
|
||||
if (matchedEmoji.length <= 0) {
|
||||
return false
|
||||
}
|
||||
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
|
||||
shortcode: `:${shortcode}:`,
|
||||
utf: utf || '',
|
||||
// eslint-disable-next-line camelcase
|
||||
img: utf ? '' : this.$store.state.instance.server + image_url,
|
||||
highlighted: index === this.highlighted
|
||||
}))
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
textAtCaret () {
|
||||
return (this.wordAtCaret || {}).word || ''
|
||||
},
|
||||
wordAtCaret () {
|
||||
const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
|
||||
return word
|
||||
},
|
||||
emoji () {
|
||||
return this.$store.state.instance.emoji || []
|
||||
},
|
||||
customEmoji () {
|
||||
return this.$store.state.instance.customEmoji || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
replace (replacement) {
|
||||
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
|
||||
this.$emit('input', newValue)
|
||||
this.caret = 0
|
||||
},
|
||||
replaceEmoji (e) {
|
||||
const len = this.suggestions.length || 0
|
||||
if (this.textAtCaret === ':' || e.ctrlKey) { return }
|
||||
if (len > 0) {
|
||||
e.preventDefault()
|
||||
const emoji = this.suggestions[this.highlighted]
|
||||
const replacement = emoji.utf || (emoji.shortcode + ' ')
|
||||
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
|
||||
this.$emit('input', newValue)
|
||||
this.caret = 0
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
cycleBackward (e) {
|
||||
const len = this.suggestions.length || 0
|
||||
if (len > 0) {
|
||||
e.preventDefault()
|
||||
this.highlighted -= 1
|
||||
if (this.highlighted < 0) {
|
||||
this.highlighted = this.suggestions.length - 1
|
||||
}
|
||||
} else {
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
cycleForward (e) {
|
||||
const len = this.suggestions.length || 0
|
||||
if (len > 0) {
|
||||
if (e.shiftKey) { return }
|
||||
e.preventDefault()
|
||||
this.highlighted += 1
|
||||
if (this.highlighted >= len) {
|
||||
this.highlighted = 0
|
||||
}
|
||||
} else {
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
onKeydown (e) {
|
||||
e.stopPropagation()
|
||||
},
|
||||
onInput (e) {
|
||||
this.$emit('input', e.target.value)
|
||||
},
|
||||
setCaret ({target: {selectionStart}}) {
|
||||
this.caret = selectionStart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiInput
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div class="emoji-input">
|
||||
<input
|
||||
v-if="type !== 'textarea'"
|
||||
:class="classname"
|
||||
:type="type"
|
||||
:value="value"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
@click="setCaret"
|
||||
@keyup="setCaret"
|
||||
@keydown="onKeydown"
|
||||
@keydown.down="cycleForward"
|
||||
@keydown.up="cycleBackward"
|
||||
@keydown.shift.tab="cycleBackward"
|
||||
@keydown.tab="cycleForward"
|
||||
@keydown.enter="replaceEmoji"
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
:class="classname"
|
||||
:value="value"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
@click="setCaret"
|
||||
@keyup="setCaret"
|
||||
@keydown="onKeydown"
|
||||
@keydown.down="cycleForward"
|
||||
@keydown.up="cycleBackward"
|
||||
@keydown.shift.tab="cycleBackward"
|
||||
@keydown.tab="cycleForward"
|
||||
@keydown.enter="replaceEmoji"
|
||||
></textarea>
|
||||
<div class="autocomplete-panel" v-if="suggestions">
|
||||
<div class="autocomplete-panel-body">
|
||||
<div
|
||||
v-for="(emoji, index) in suggestions"
|
||||
:key="index"
|
||||
@click="replace(emoji.utf || (emoji.shortcode + ' '))"
|
||||
class="autocomplete-item"
|
||||
:class="{ highlighted: emoji.highlighted }"
|
||||
>
|
||||
<span v-if="emoji.img">
|
||||
<img :src="emoji.img" />
|
||||
</span>
|
||||
<span v-else>{{emoji.utf}}</span>
|
||||
<span>{{emoji.shortcode}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./emoji-input.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.emoji-input {
|
||||
.form-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<div class="import-export-container">
|
||||
<slot name="before"/>
|
||||
<button class="btn" @click="exportData">{{ exportLabel }}</button>
|
||||
<button class="btn" @click="importData">{{ importLabel }}</button>
|
||||
<slot name="afterButtons"/>
|
||||
<p v-if="importFailed" class="alert error">{{ importFailedText }}</p>
|
||||
<slot name="afterError"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: [
|
||||
'exportObject',
|
||||
'importLabel',
|
||||
'exportLabel',
|
||||
'importFailedText',
|
||||
'validator',
|
||||
'onImport',
|
||||
'onImportFailure'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
importFailed: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
exportData () {
|
||||
const stringified = JSON.stringify(this.exportObject) // Pretty-print and indent with 2 spaces
|
||||
|
||||
// Create an invisible link with a data url and simulate a click
|
||||
const e = document.createElement('a')
|
||||
e.setAttribute('download', 'pleroma_theme.json')
|
||||
e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
|
||||
e.style.display = 'none'
|
||||
|
||||
document.body.appendChild(e)
|
||||
e.click()
|
||||
document.body.removeChild(e)
|
||||
},
|
||||
importData () {
|
||||
this.importFailed = false
|
||||
const filePicker = document.createElement('input')
|
||||
filePicker.setAttribute('type', 'file')
|
||||
filePicker.setAttribute('accept', '.json')
|
||||
|
||||
filePicker.addEventListener('change', event => {
|
||||
if (event.target.files[0]) {
|
||||
// eslint-disable-next-line no-undef
|
||||
const reader = new FileReader()
|
||||
reader.onload = ({target}) => {
|
||||
try {
|
||||
const parsed = JSON.parse(target.result)
|
||||
const valid = this.validator(parsed)
|
||||
if (valid) {
|
||||
this.onImport(parsed)
|
||||
} else {
|
||||
this.importFailed = true
|
||||
// this.onImportFailure(valid)
|
||||
}
|
||||
} catch (e) {
|
||||
// This will happen both if there is a JSON syntax error or the theme is missing components
|
||||
this.importFailed = true
|
||||
// this.onImportFailure(e)
|
||||
}
|
||||
}
|
||||
reader.readAsText(event.target.files[0])
|
||||
}
|
||||
})
|
||||
|
||||
document.body.appendChild(filePicker)
|
||||
filePicker.click()
|
||||
document.body.removeChild(filePicker)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.import-export-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
|
@ -2,6 +2,9 @@ const FavoriteButton = {
|
|||
props: ['status', 'loggedIn'],
|
||||
data () {
|
||||
return {
|
||||
hidePostStatsLocal: typeof this.$store.state.config.hidePostStats === 'undefined'
|
||||
? this.$store.state.instance.hidePostStats
|
||||
: this.$store.state.config.hidePostStats,
|
||||
animated: false
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<div v-if="loggedIn">
|
||||
<i :class='classes' class='favorite-button fav-active' @click.prevent='favorite()'/>
|
||||
<span v-if='status.fave_num > 0'>{{status.fave_num}}</span>
|
||||
<i :class='classes' class='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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<i :class='classes' class='favorite-button'/>
|
||||
<span v-if='status.fave_num > 0'>{{status.fave_num}}</span>
|
||||
<i :class='classes' class='button-icon favorite-button' :title="$t('tool_tip.favorite')"/>
|
||||
<span v-if='!hidePostStatsLocal && status.fave_num > 0'>{{status.fave_num}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
const FeaturesPanel = {
|
||||
computed: {
|
||||
chat: function () {
|
||||
return this.$store.state.instance.chatAvailable && (!this.$store.state.chatDisabled)
|
||||
},
|
||||
gopher: function () { return this.$store.state.instance.gopherAvailable },
|
||||
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
|
||||
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
|
||||
minimalScopesMode: function () { return this.$store.state.instance.minimalScopesMode },
|
||||
textlimit: function () { return this.$store.state.instance.textlimit }
|
||||
}
|
||||
}
|
||||
|
||||
export default FeaturesPanel
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div class="features-panel">
|
||||
<div class="panel panel-default base01-background">
|
||||
<div class="panel-heading timeline-heading base02-background base04">
|
||||
<div class="title">
|
||||
{{$t('features_panel.title')}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body features-panel">
|
||||
<ul>
|
||||
<li v-if="chat">{{$t('features_panel.chat')}}</li>
|
||||
<li v-if="gopher">{{$t('features_panel.gopher')}}</li>
|
||||
<li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li>
|
||||
<li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li>
|
||||
<li>{{$t('features_panel.scope_options')}}</li>
|
||||
<li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./features_panel.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
.features-panel li {
|
||||
line-height: 24px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,50 @@
|
|||
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
|
||||
import RemoteFollow from '../remote_follow/remote_follow.vue'
|
||||
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
|
||||
|
||||
const FollowCard = {
|
||||
props: [
|
||||
'user',
|
||||
'noFollowsYou'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
inProgress: false,
|
||||
requestSent: false,
|
||||
updated: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
BasicUserCard,
|
||||
RemoteFollow
|
||||
},
|
||||
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
|
||||
},
|
||||
loggedIn () {
|
||||
return this.$store.state.users.currentUser
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
followUser () {
|
||||
this.inProgress = true
|
||||
requestFollow(this.user, this.$store).then(({ sent, updated }) => {
|
||||
this.inProgress = false
|
||||
this.requestSent = sent
|
||||
this.updated = updated
|
||||
})
|
||||
},
|
||||
unfollowUser () {
|
||||
this.inProgress = true
|
||||
requestUnfollow(this.user, this.$store).then(({ updated }) => {
|
||||
this.inProgress = false
|
||||
this.updated = updated
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FollowCard
|
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<basic-user-card :user="user">
|
||||
<div class="follow-card-content-container">
|
||||
<span class="faint" v-if="!noFollowsYou && user.follows_you">
|
||||
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
|
||||
</span>
|
||||
<div class="follow-card-follow-button" v-if="showFollow && !loggedIn">
|
||||
<RemoteFollow :user="user" />
|
||||
</div>
|
||||
<button
|
||||
v-if="showFollow && loggedIn"
|
||||
class="btn btn-default follow-card-follow-button"
|
||||
@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 follow-card-follow-button 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>
|
||||
</div>
|
||||
</basic-user-card>
|
||||
</template>
|
||||
|
||||
<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-button {
|
||||
margin-top: 0.5em;
|
||||
margin-left: auto;
|
||||
width: 10em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,20 @@
|
|||
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
|
||||
|
||||
const FollowRequestCard = {
|
||||
props: ['user'],
|
||||
components: {
|
||||
BasicUserCard
|
||||
},
|
||||
methods: {
|
||||
approveUser () {
|
||||
this.$store.state.api.backendInteractor.approveUser(this.user.id)
|
||||
this.$store.dispatch('removeFollowRequest', this.user)
|
||||
},
|
||||
denyUser () {
|
||||
this.$store.state.api.backendInteractor.denyUser(this.user.id)
|
||||
this.$store.dispatch('removeFollowRequest', this.user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FollowRequestCard
|
|
@ -0,0 +1,29 @@
|
|||
<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>
|
||||
</div>
|
||||
</basic-user-card>
|
||||
</template>
|
||||
|
||||
<script src="./follow_request_card.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.follow-request-card-content-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
button {
|
||||
margin-top: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
flex: 1 1;
|
||||
max-width: 12em;
|
||||
min-width: 8em;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,14 @@
|
|||
import FollowRequestCard from '../follow_request_card/follow_request_card.vue'
|
||||
|
||||
const FollowRequests = {
|
||||
components: {
|
||||
FollowRequestCard
|
||||
},
|
||||
computed: {
|
||||
requests () {
|
||||
return this.$store.state.api.followRequests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FollowRequests
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div class="settings panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{{$t('nav.friend_requests')}}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<FollowRequestCard v-for="request in requests" :key="request.id" :user="request" class="list-item"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./follow_requests.js"></script>
|
|
@ -0,0 +1,58 @@
|
|||
import { set } from 'vue'
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
lValue: this.value,
|
||||
availableOptions: [
|
||||
this.noInherit ? '' : 'inherit',
|
||||
'custom',
|
||||
...(this.options || []),
|
||||
'serif',
|
||||
'monospace',
|
||||
'sans-serif'
|
||||
].filter(_ => _)
|
||||
}
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.lValue = this.value
|
||||
},
|
||||
computed: {
|
||||
present () {
|
||||
return typeof this.lValue !== 'undefined'
|
||||
},
|
||||
dValue () {
|
||||
return this.lValue || this.fallback || {}
|
||||
},
|
||||
family: {
|
||||
get () {
|
||||
return this.dValue.family
|
||||
},
|
||||
set (v) {
|
||||
set(this.lValue, 'family', v)
|
||||
this.$emit('input', this.lValue)
|
||||
}
|
||||
},
|
||||
isCustom () {
|
||||
return this.preset === 'custom'
|
||||
},
|
||||
preset: {
|
||||
get () {
|
||||
if (this.family === 'serif' ||
|
||||
this.family === 'sans-serif' ||
|
||||
this.family === 'monospace' ||
|
||||
this.family === 'inherit') {
|
||||
return this.family
|
||||
} else {
|
||||
return 'custom'
|
||||
}
|
||||
},
|
||||
set (v) {
|
||||
this.family = v === 'custom' ? '' : v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<div class="font-control style-control" :class="{ custom: isCustom }">
|
||||
<label :for="preset === 'custom' ? name : name + '-font-switcher'" class="label">
|
||||
{{label}}
|
||||
</label>
|
||||
<input
|
||||
v-if="typeof fallback !== 'undefined'"
|
||||
class="opt exlcude-disabled"
|
||||
type="checkbox"
|
||||
:id="name + '-o'"
|
||||
:checked="present"
|
||||
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)">
|
||||
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
|
||||
<label :for="name + '-font-switcher'" class="select" :disabled="!present">
|
||||
<select
|
||||
:disabled="!present"
|
||||
v-model="preset"
|
||||
class="font-switcher"
|
||||
:id="name + '-font-switcher'">
|
||||
<option v-for="option in availableOptions" :value="option">
|
||||
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="icon-down-open"/>
|
||||
</label>
|
||||
<input
|
||||
v-if="isCustom"
|
||||
class="custom-font"
|
||||
type="text"
|
||||
:id="name"
|
||||
v-model="family">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./font_control.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
.font-control {
|
||||
input.custom-font {
|
||||
min-width: 10em;
|
||||
}
|
||||
&.custom {
|
||||
.select {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.custom-font {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,55 @@
|
|||
import Attachment from '../attachment/attachment.vue'
|
||||
import { chunk, last, dropRight } from 'lodash'
|
||||
|
||||
const Gallery = {
|
||||
data: () => ({
|
||||
width: 500
|
||||
}),
|
||||
props: [
|
||||
'attachments',
|
||||
'nsfw',
|
||||
'setMedia'
|
||||
],
|
||||
components: { Attachment },
|
||||
mounted () {
|
||||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
},
|
||||
computed: {
|
||||
rows () {
|
||||
if (!this.attachments) {
|
||||
return []
|
||||
}
|
||||
const rows = chunk(this.attachments, 3)
|
||||
if (last(rows).length === 1 && rows.length > 1) {
|
||||
// if 1 attachment on last row -> add it to the previous row instead
|
||||
const lastAttachment = last(rows)[0]
|
||||
const allButLastRow = dropRight(rows)
|
||||
last(allButLastRow).push(lastAttachment)
|
||||
return allButLastRow
|
||||
}
|
||||
return rows
|
||||
},
|
||||
rowHeight () {
|
||||
return itemsPerRow => ({ 'height': `${(this.width / (itemsPerRow + 0.6))}px` })
|
||||
},
|
||||
useContainFit () {
|
||||
return this.$store.state.config.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Gallery
|
|
@ -0,0 +1,65 @@
|
|||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src='./gallery.js'></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.gallery-row {
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-content: stretch;
|
||||
flex-grow: 1;
|
||||
margin-top: 0.5em;
|
||||
|
||||
.attachments, .attachment {
|
||||
margin: 0 0.5em 0 0;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
// to make failed images a bit more noticeable on chromium
|
||||
min-width: 2em;
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-attachment {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.contain-fit {
|
||||
img, video {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&.cover-fit {
|
||||
img, video {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,146 @@
|
|||
import Cropper from 'cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
|
||||
const ImageCropper = {
|
||||
props: {
|
||||
trigger: {
|
||||
type: [String, window.Element],
|
||||
required: true
|
||||
},
|
||||
submitHandler: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
cropperOptions: {
|
||||
type: Object,
|
||||
default () {
|
||||
return {
|
||||
aspectRatio: 1,
|
||||
autoCropArea: 1,
|
||||
viewMode: 1,
|
||||
movable: false,
|
||||
zoomable: false,
|
||||
guides: false
|
||||
}
|
||||
}
|
||||
},
|
||||
mimes: {
|
||||
type: String,
|
||||
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
|
||||
},
|
||||
saveButtonLabel: {
|
||||
type: String
|
||||
},
|
||||
saveWithoutCroppingButtonlabel: {
|
||||
type: String
|
||||
},
|
||||
cancelButtonLabel: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
cropper: undefined,
|
||||
dataUrl: undefined,
|
||||
filename: undefined,
|
||||
submitting: false,
|
||||
submitError: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
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')
|
||||
},
|
||||
submitErrorMsg () {
|
||||
return this.submitError && this.submitError instanceof Error ? this.submitError.toString() : this.submitError
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
destroy () {
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy()
|
||||
}
|
||||
this.$refs.input.value = ''
|
||||
this.dataUrl = undefined
|
||||
this.$emit('close')
|
||||
},
|
||||
submit () {
|
||||
this.submitting = true
|
||||
this.avatarUploadError = null
|
||||
this.submitHandler(this.cropper, this.file)
|
||||
.then(() => this.destroy())
|
||||
.catch((err) => {
|
||||
this.submitError = err
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
},
|
||||
submitWithoutCropping () {
|
||||
this.submitting = true
|
||||
this.avatarUploadError = null
|
||||
this.submitHandler(false, this.dataUrl)
|
||||
.then(() => this.destroy())
|
||||
.catch((err) => {
|
||||
this.submitError = err
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
},
|
||||
pickImage () {
|
||||
this.$refs.input.click()
|
||||
},
|
||||
createCropper () {
|
||||
this.cropper = new Cropper(this.$refs.img, this.cropperOptions)
|
||||
},
|
||||
getTriggerDOM () {
|
||||
return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger)
|
||||
},
|
||||
readFile () {
|
||||
const fileInput = this.$refs.input
|
||||
if (fileInput.files != null && fileInput.files[0] != null) {
|
||||
this.file = fileInput.files[0]
|
||||
let reader = new window.FileReader()
|
||||
reader.onload = (e) => {
|
||||
this.dataUrl = e.target.result
|
||||
this.$emit('open')
|
||||
}
|
||||
reader.readAsDataURL(this.file)
|
||||
this.$emit('changed', this.file, reader)
|
||||
}
|
||||
},
|
||||
clearError () {
|
||||
this.submitError = null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// listen for click event on trigger
|
||||
const trigger = this.getTriggerDOM()
|
||||
if (!trigger) {
|
||||
this.$emit('error', 'No image make trigger found.', 'user')
|
||||
} else {
|
||||
trigger.addEventListener('click', this.pickImage)
|
||||
}
|
||||
// listen for input file changes
|
||||
const fileInput = this.$refs.input
|
||||
fileInput.addEventListener('change', this.readFile)
|
||||
},
|
||||
beforeDestroy: function () {
|
||||
// remove the event listeners
|
||||
const trigger = this.getTriggerDOM()
|
||||
if (trigger) {
|
||||
trigger.removeEventListener('click', this.pickImage)
|
||||
}
|
||||
const fileInput = this.$refs.input
|
||||
fileInput.removeEventListener('change', this.readFile)
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageCropper
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="image-cropper">
|
||||
<div v-if="dataUrl">
|
||||
<div class="image-cropper-image-container">
|
||||
<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>
|
||||
<button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button>
|
||||
<i class="icon-spin4 animate-spin" v-if="submitting"></i>
|
||||
</div>
|
||||
<div class="alert error" v-if="submitError">
|
||||
{{submitErrorMsg}}
|
||||
<i class="button-icon icon-cancel" @click="clearError"></i>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="input" type="file" class="image-cropper-img-input" :accept="mimes">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./image_cropper.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.image-cropper {
|
||||
&-img-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-image-container {
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-buttons-wrapper {
|
||||
margin-top: 10px;
|
||||
|
||||
button {
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,7 +1,10 @@
|
|||
const InstanceSpecificPanel = {
|
||||
computed: {
|
||||
instanceSpecificPanelContent () {
|
||||
return this.$store.state.config.instanceSpecificPanelContent
|
||||
return this.$store.state.instance.instanceSpecificPanelContent
|
||||
},
|
||||
show () {
|
||||
return !this.$store.state.config.hideISP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="instance-specific-panel">
|
||||
<div v-if="show" class="instance-specific-panel">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<div v-html="instanceSpecificPanelContent">
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<div>
|
||||
<label for="interface-language-switcher">
|
||||
{{ $t('settings.interfaceLanguage') }}
|
||||
</label>
|
||||
<label for="interface-language-switcher" class='select'>
|
||||
<select id="interface-language-switcher" v-model="language">
|
||||
<option v-for="(langCode, i) in languageCodes" :value="langCode">
|
||||
{{ languageNames[i] }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="icon-down-open"/>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import languagesObject from '../../i18n/messages'
|
||||
import ISO6391 from 'iso-639-1'
|
||||
import _ from 'lodash'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
languageCodes () {
|
||||
return Object.keys(languagesObject)
|
||||
},
|
||||
|
||||
languageNames () {
|
||||
return _.map(this.languageCodes, ISO6391.getName)
|
||||
},
|
||||
|
||||
language: {
|
||||
get: function () { return this.$store.state.config.interfaceLanguage },
|
||||
set: function (val) {
|
||||
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
|
||||
this.$i18n.locale = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,21 @@
|
|||
const LinkPreview = {
|
||||
name: 'LinkPreview',
|
||||
props: [
|
||||
'card',
|
||||
'size',
|
||||
'nsfw'
|
||||
],
|
||||
computed: {
|
||||
useImage () {
|
||||
// Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid
|
||||
// as it makes sure to hide the image if somehow NSFW tagged preview can
|
||||
// exist.
|
||||
return this.card.image && !this.nsfw && this.size !== 'hide'
|
||||
},
|
||||
useDescription () {
|
||||
return this.card.description && /\S/.test(this.card.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LinkPreview
|
|
@ -0,0 +1,76 @@
|
|||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./link-preview.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.link-preview-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5em;
|
||||
|
||||
.card-image {
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
max-width: 25%;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: $fallback--attachmentRadius;
|
||||
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
|
||||
}
|
||||
}
|
||||
|
||||
.small-image {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
max-height: 100%;
|
||||
margin: 0.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-host {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin: 0.5em 0 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
line-height: 1.2em;
|
||||
// cap description at 3 lines, the 1px is to clean up some stray pixels
|
||||
// TODO: fancier fade-out at the bottom to show off that it's too long?
|
||||
max-height: calc(1.2em * 3 - 1px);
|
||||
}
|
||||
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: $fallback--attachmentRadius;
|
||||
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
|
||||
border-color: $fallback--border;
|
||||
border-color: var(--border, $fallback--border);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div class="list">
|
||||
<div v-for="item in items" class="list-item" :key="getKey(item)">
|
||||
<slot name="item" :item="item" />
|
||||
</div>
|
||||
<div class="list-empty-content faint" v-if="items.length === 0 && !!$slots.empty">
|
||||
<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,22 +1,54 @@
|
|||
import oauthApi from '../../services/new_api/oauth.js'
|
||||
const LoginForm = {
|
||||
data: () => ({
|
||||
user: {},
|
||||
authError: false
|
||||
}),
|
||||
computed: {
|
||||
loginMethod () { return this.$store.state.instance.loginMethod },
|
||||
loggingIn () { return this.$store.state.users.loggingIn },
|
||||
registrationOpen () { return this.$store.state.config.registrationOpen }
|
||||
registrationOpen () { return this.$store.state.instance.registrationOpen }
|
||||
},
|
||||
methods: {
|
||||
oAuthLogin () {
|
||||
oauthApi.login({
|
||||
oauth: this.$store.state.oauth,
|
||||
instance: this.$store.state.instance.server,
|
||||
commit: this.$store.commit
|
||||
})
|
||||
},
|
||||
submit () {
|
||||
this.$store.dispatch('loginUser', this.user).then(
|
||||
() => {},
|
||||
(error) => {
|
||||
this.authError = error
|
||||
this.user.username = ''
|
||||
this.user.password = ''
|
||||
}
|
||||
)
|
||||
const data = {
|
||||
oauth: this.$store.state.oauth,
|
||||
instance: this.$store.state.instance.server
|
||||
}
|
||||
this.clearError()
|
||||
oauthApi.getOrCreateApp(data).then((app) => {
|
||||
oauthApi.getTokenWithCredentials(
|
||||
{
|
||||
app,
|
||||
instance: data.instance,
|
||||
username: this.user.username,
|
||||
password: this.user.password
|
||||
}
|
||||
).then(async (result) => {
|
||||
if (result.error) {
|
||||
this.authError = result.error
|
||||
this.user.password = ''
|
||||
return
|
||||
}
|
||||
this.$store.commit('setToken', result.access_token)
|
||||
try {
|
||||
await this.$store.dispatch('loginUser', result.access_token)
|
||||
this.$router.push({name: 'friends'})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
clearError () {
|
||||
this.authError = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{{$t('login.login')}}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form v-on:submit.prevent='submit(user)' class='login-form'>
|
||||
<form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'>
|
||||
<div class='form-group'>
|
||||
<label for='username'>{{$t('login.username')}}</label>
|
||||
<input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')">
|
||||
|
@ -20,10 +20,26 @@
|
|||
<button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authError" class='form-group'>
|
||||
<div class='alert error'>{{authError}}</div>
|
||||
</form>
|
||||
|
||||
<form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form">
|
||||
<div class="form-group">
|
||||
<p>{{$t('login.description')}}</p>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<div class='login-bottom'>
|
||||
<div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div>
|
||||
<button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="authError" class='form-group'>
|
||||
<div class='alert error'>
|
||||
{{authError}}
|
||||
<i class="button-icon icon-cancel" @click="clearError"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -39,10 +55,6 @@
|
|||
width: 10em;
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.register {
|
||||
flex: 1 1;
|
||||
}
|
||||
|
@ -55,4 +67,14 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.login {
|
||||
.error {
|
||||
text-align: center;
|
||||
|
||||
animation-name: shakeError;
|
||||
animation-duration: 0.4s;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import StillImage from '../still-image/still-image.vue'
|
||||
import VideoAttachment from '../video_attachment/video_attachment.vue'
|
||||
import fileTypeService from '../../services/file_type/file_type.service.js'
|
||||
|
||||
const MediaModal = {
|
||||
components: {
|
||||
StillImage,
|
||||
VideoAttachment
|
||||
},
|
||||
computed: {
|
||||
showing () {
|
||||
return this.$store.state.mediaViewer.activated
|
||||
},
|
||||
media () {
|
||||
return this.$store.state.mediaViewer.media
|
||||
},
|
||||
currentIndex () {
|
||||
return this.$store.state.mediaViewer.currentIndex
|
||||
},
|
||||
currentMedia () {
|
||||
return this.media[this.currentIndex]
|
||||
},
|
||||
canNavigate () {
|
||||
return this.media.length > 1
|
||||
},
|
||||
type () {
|
||||
return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hide () {
|
||||
this.$store.dispatch('closeMediaViewer')
|
||||
},
|
||||
goPrev () {
|
||||
if (this.canNavigate) {
|
||||
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
|
||||
this.$store.dispatch('setCurrent', this.media[prevIndex])
|
||||
}
|
||||
},
|
||||
goNext () {
|
||||
if (this.canNavigate) {
|
||||
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
|
||||
this.$store.dispatch('setCurrent', this.media[nextIndex])
|
||||
}
|
||||
},
|
||||
handleKeyupEvent (e) {
|
||||
if (this.showing && e.keyCode === 27) { // escape
|
||||
this.hide()
|
||||
}
|
||||
},
|
||||
handleKeydownEvent (e) {
|
||||
if (!this.showing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.keyCode === 39) { // arrow right
|
||||
this.goNext()
|
||||
} else if (e.keyCode === 37) { // arrow left
|
||||
this.goPrev()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
document.addEventListener('keyup', this.handleKeyupEvent)
|
||||
document.addEventListener('keydown', this.handleKeydownEvent)
|
||||
},
|
||||
destroyed () {
|
||||
document.removeEventListener('keyup', this.handleKeyupEvent)
|
||||
document.removeEventListener('keydown', this.handleKeydownEvent)
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaModal
|
|
@ -0,0 +1,102 @@
|
|||
<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
|
||||
class="modal-image"
|
||||
v-if="type === 'video'"
|
||||
:attachment="currentMedia"
|
||||
:controls="true"
|
||||
@click.stop.native="">
|
||||
</VideoAttachment>
|
||||
<button
|
||||
: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
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<script src="./media_modal.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.media-modal-view {
|
||||
&:hover {
|
||||
.modal-view-button-arrow {
|
||||
opacity: 0.75;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-image {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-view-button-arrow {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 50%;
|
||||
margin-top: -50px;
|
||||
width: 70px;
|
||||
height: 100px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
appearance: none;
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
transition: opacity 333ms cubic-bezier(.4,0,.22,1);
|
||||
|
||||
.arrow-icon {
|
||||
position: absolute;
|
||||
top: 35px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
color: #FFF;
|
||||
text-align: center;
|
||||
background-color: rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
&--prev {
|
||||
left: 0;
|
||||
.arrow-icon {
|
||||
left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&--next {
|
||||
right: 0;
|
||||
.arrow-icon {
|
||||
right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -1,26 +1,26 @@
|
|||
/* eslint-env browser */
|
||||
import statusPosterService from '../../services/status_poster/status_poster.service.js'
|
||||
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
|
||||
|
||||
const mediaUpload = {
|
||||
mounted () {
|
||||
const input = this.$el.querySelector('input')
|
||||
|
||||
input.addEventListener('change', ({target}) => {
|
||||
const file = target.files[0]
|
||||
this.uploadFile(file)
|
||||
})
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
uploading: false
|
||||
uploading: false,
|
||||
uploadReady: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
uploadFile (file) {
|
||||
const self = this
|
||||
const store = this.$store
|
||||
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})
|
||||
return
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('media', file)
|
||||
formData.append('file', file)
|
||||
|
||||
self.$emit('uploading')
|
||||
self.uploading = true
|
||||
|
@ -30,7 +30,7 @@ const mediaUpload = {
|
|||
self.$emit('uploaded', fileData)
|
||||
self.uploading = false
|
||||
}, (error) => { // eslint-disable-line handle-callback-err
|
||||
self.$emit('upload-failed')
|
||||
self.$emit('upload-failed', 'default')
|
||||
self.uploading = false
|
||||
})
|
||||
},
|
||||
|
@ -47,6 +47,18 @@ const mediaUpload = {
|
|||
} else {
|
||||
e.dataTransfer.dropEffect = 'none'
|
||||
}
|
||||
},
|
||||
clearFile () {
|
||||
this.uploadReady = false
|
||||
this.$nextTick(() => {
|
||||
this.uploadReady = true
|
||||
})
|
||||
},
|
||||
change ({target}) {
|
||||
for (var i = 0; i < target.files.length; i++) {
|
||||
let file = target.files[i]
|
||||
this.uploadFile(file)
|
||||
}
|
||||
}
|
||||
},
|
||||
props: [
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="media-upload" @drop.prevent @dragover.prevent="fileDrag" @drop="fileDrop">
|
||||
<label class="btn btn-default">
|
||||
<label class="btn btn-default" :title="$t('tool_tip.media_upload')">
|
||||
<i class="icon-spin4 animate-spin" v-if="uploading"></i>
|
||||
<i class="icon-upload" v-if="!uploading"></i>
|
||||
<input type=file style="position: fixed; top: -100em"></input>
|
||||
<input type="file" v-if="uploadReady" @change="change" style="position: fixed; top: -100em" multiple="true"></input>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import SideDrawer from '../side_drawer/side_drawer.vue'
|
||||
import Notifications from '../notifications/notifications.vue'
|
||||
import MobilePostStatusModal from '../mobile_post_status_modal/mobile_post_status_modal.vue'
|
||||
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
|
||||
import GestureService from '../../services/gesture_service/gesture_service'
|
||||
|
||||
const MobileNav = {
|
||||
components: {
|
||||
SideDrawer,
|
||||
Notifications,
|
||||
MobilePostStatusModal
|
||||
},
|
||||
data: () => ({
|
||||
notificationsCloseGesture: undefined,
|
||||
notificationsOpen: false
|
||||
}),
|
||||
created () {
|
||||
this.notificationsCloseGesture = GestureService.swipeGesture(
|
||||
GestureService.DIRECTION_RIGHT,
|
||||
this.closeMobileNotifications,
|
||||
50
|
||||
)
|
||||
},
|
||||
computed: {
|
||||
currentUser () {
|
||||
return this.$store.state.users.currentUser
|
||||
},
|
||||
unseenNotifications () {
|
||||
return unseenNotificationsFromStore(this.$store)
|
||||
},
|
||||
unseenNotificationsCount () {
|
||||
return this.unseenNotifications.length
|
||||
},
|
||||
sitename () { return this.$store.state.instance.name }
|
||||
},
|
||||
methods: {
|
||||
toggleMobileSidebar () {
|
||||
this.$refs.sideDrawer.toggleDrawer()
|
||||
},
|
||||
openMobileNotifications () {
|
||||
this.notificationsOpen = true
|
||||
},
|
||||
closeMobileNotifications () {
|
||||
if (this.notificationsOpen) {
|
||||
// make sure to mark notifs seen only when the notifs were open and not
|
||||
// from close-calls.
|
||||
this.notificationsOpen = false
|
||||
this.markNotificationsAsSeen()
|
||||
}
|
||||
},
|
||||
notificationsTouchStart (e) {
|
||||
GestureService.beginSwipe(e, this.notificationsCloseGesture)
|
||||
},
|
||||
notificationsTouchMove (e) {
|
||||
GestureService.updateSwipe(e, this.notificationsCloseGesture)
|
||||
},
|
||||
scrollToTop () {
|
||||
window.scrollTo(0, 0)
|
||||
},
|
||||
logout () {
|
||||
this.$router.replace('/main/public')
|
||||
this.$store.dispatch('logout')
|
||||
},
|
||||
markNotificationsAsSeen () {
|
||||
this.$refs.notifications.markAsSeen()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route () {
|
||||
// handles closing notificaitons when you press any router-link on the
|
||||
// notifications.
|
||||
this.closeMobileNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MobileNav
|
|
@ -0,0 +1,140 @@
|
|||
<template>
|
||||
<nav class='nav-bar container' id="nav">
|
||||
<div class='mobile-inner-nav' @click="scrollToTop()">
|
||||
<div class='item'>
|
||||
<a href="#" class="mobile-nav-button" @click.stop.prevent="toggleMobileSidebar()">
|
||||
<i class="button-icon icon-menu"></i>
|
||||
</a>
|
||||
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
|
||||
</div>
|
||||
<div class='item right'>
|
||||
<a class="mobile-nav-button" v-if="currentUser" href="#" @click.stop.prevent="openMobileNotifications()">
|
||||
<i class="button-icon icon-bell-alt"></i>
|
||||
<div class="alert-dot" v-if="unseenNotificationsCount"></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<SideDrawer ref="sideDrawer" :logout="logout"/>
|
||||
<div v-if="currentUser"
|
||||
class="mobile-notifications-drawer"
|
||||
:class="{ 'closed': !notificationsOpen }"
|
||||
@touchstart="notificationsTouchStart"
|
||||
@touchmove="notificationsTouchMove"
|
||||
>
|
||||
<div class="mobile-notifications-header">
|
||||
<span class="title">{{$t('notifications.notifications')}}</span>
|
||||
<a class="mobile-nav-button" @click.stop.prevent="closeMobileNotifications()">
|
||||
<i class="button-icon icon-cancel"/>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="currentUser" class="mobile-notifications">
|
||||
<Notifications ref="notifications" noHeading="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<MobilePostStatusModal />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script src="./mobile_nav.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.mobile-inner-nav {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-nav-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.mobile-notifications-drawer {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
|
||||
box-shadow: var(--panelShadow);
|
||||
transition-property: transform;
|
||||
transition-duration: 0.25s;
|
||||
transform: translateX(0);
|
||||
|
||||
&.closed {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-notifications-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
position: absolute;
|
||||
color: var(--topBarText);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--topBar, $fallback--fg);
|
||||
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
|
||||
box-shadow: var(--topBarShadow);
|
||||
|
||||
.title {
|
||||
font-size: 1.3em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-notifications {
|
||||
margin-top: 50px;
|
||||
width: 100vw;
|
||||
height: calc(100vh - 50px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--bg, $fallback--bg);
|
||||
|
||||
.notifications {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.panel:after {
|
||||
border-radius: 0;
|
||||
}
|
||||
.panel .panel-heading {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,91 @@
|
|||
import PostStatusForm from '../post_status_form/post_status_form.vue'
|
||||
import { throttle } from 'lodash'
|
||||
|
||||
const MobilePostStatusModal = {
|
||||
components: {
|
||||
PostStatusForm
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
hidden: false,
|
||||
postFormOpen: false,
|
||||
scrollingDown: false,
|
||||
inputActive: false,
|
||||
oldScrollPos: 0,
|
||||
amountScrolled: 0
|
||||
}
|
||||
},
|
||||
created () {
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
window.addEventListener('resize', this.handleOSK)
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
window.removeEventListener('resize', this.handleOSK)
|
||||
},
|
||||
computed: {
|
||||
currentUser () {
|
||||
return this.$store.state.users.currentUser
|
||||
},
|
||||
isHidden () {
|
||||
return this.hidden || this.inputActive
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openPostForm () {
|
||||
this.postFormOpen = true
|
||||
this.hidden = true
|
||||
|
||||
const el = this.$el.querySelector('textarea')
|
||||
this.$nextTick(function () {
|
||||
el.focus()
|
||||
})
|
||||
},
|
||||
closePostForm () {
|
||||
this.postFormOpen = false
|
||||
this.hidden = false
|
||||
},
|
||||
handleOSK () {
|
||||
// This is a big hack: we're guessing from changed window sizes if the
|
||||
// on-screen keyboard is active or not. This is only really important
|
||||
// for phones in portrait mode and it's more important to show the button
|
||||
// in normal scenarios on all phones, than it is to hide it when the
|
||||
// keyboard is active.
|
||||
// Guesswork based on https://www.mydevice.io/#compare-devices
|
||||
|
||||
// for example, iphone 4 and android phones from the same time period
|
||||
const smallPhone = window.innerWidth < 350
|
||||
const smallPhoneKbOpen = smallPhone && window.innerHeight < 345
|
||||
|
||||
const biggerPhone = !smallPhone && window.innerWidth < 450
|
||||
const biggerPhoneKbOpen = biggerPhone && window.innerHeight < 560
|
||||
if (smallPhoneKbOpen || biggerPhoneKbOpen) {
|
||||
this.inputActive = true
|
||||
} else {
|
||||
this.inputActive = false
|
||||
}
|
||||
},
|
||||
handleScroll: throttle(function () {
|
||||
const scrollAmount = window.scrollY - this.oldScrollPos
|
||||
const scrollingDown = scrollAmount > 0
|
||||
|
||||
if (scrollingDown !== this.scrollingDown) {
|
||||
this.amountScrolled = 0
|
||||
this.scrollingDown = scrollingDown
|
||||
if (!scrollingDown) {
|
||||
this.hidden = false
|
||||
}
|
||||
} else if (scrollingDown) {
|
||||
this.amountScrolled += scrollAmount
|
||||
if (this.amountScrolled > 100 && !this.hidden) {
|
||||
this.hidden = true
|
||||
}
|
||||
}
|
||||
|
||||
this.oldScrollPos = window.scrollY
|
||||
this.scrollingDown = scrollingDown
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
export default MobilePostStatusModal
|
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<div v-if="currentUser">
|
||||
<div
|
||||
class="post-form-modal-view modal-view"
|
||||
v-show="postFormOpen"
|
||||
@click="closePostForm"
|
||||
>
|
||||
<div class="post-form-modal-panel panel" @click.stop="">
|
||||
<div class="panel-heading">{{$t('post_status.new_status')}}</div>
|
||||
<PostStatusForm class="panel-body" @posted="closePostForm"/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="new-status-button"
|
||||
:class="{ 'hidden': isHidden }"
|
||||
@click="openPostForm"
|
||||
>
|
||||
<i class="icon-edit" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./mobile_post_status_modal.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.post-form-modal-view {
|
||||
max-height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.post-form-modal-panel {
|
||||
flex-shrink: 0;
|
||||
margin: 25% 0 4em 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.new-status-button {
|
||||
width: 5em;
|
||||
height: 5em;
|
||||
border-radius: 100%;
|
||||
position: fixed;
|
||||
bottom: 1.5em;
|
||||
right: 1.5em;
|
||||
// TODO: this needs its own color, it has to stand out enough and link color
|
||||
// is not very optimal for this particular use.
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--btn, $fallback--fg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
|
||||
transition: 0.35s transform;
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
|
||||
&.hidden {
|
||||
transform: translateY(150%);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.5em;
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 801px) {
|
||||
.new-status-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,106 @@
|
|||
import DialogModal from '../dialog_modal/dialog_modal.vue'
|
||||
import Popper from 'vue-popperjs/src/component/popper.js.vue'
|
||||
|
||||
const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
|
||||
const STRIP_MEDIA = 'mrf_tag:media-strip'
|
||||
const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
|
||||
const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription'
|
||||
const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription'
|
||||
const SANDBOX = 'mrf_tag:sandbox'
|
||||
const QUARANTINE = 'mrf_tag:quarantine'
|
||||
|
||||
const ModerationTools = {
|
||||
props: [
|
||||
'user'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
showDropDown: false,
|
||||
tags: {
|
||||
FORCE_NSFW,
|
||||
STRIP_MEDIA,
|
||||
FORCE_UNLISTED,
|
||||
DISABLE_REMOTE_SUBSCRIPTION,
|
||||
DISABLE_ANY_SUBSCRIPTION,
|
||||
SANDBOX,
|
||||
QUARANTINE
|
||||
},
|
||||
showDeleteUserDialog: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
DialogModal,
|
||||
Popper
|
||||
},
|
||||
computed: {
|
||||
tagsSet () {
|
||||
return new Set(this.user.tags)
|
||||
},
|
||||
hasTagPolicy () {
|
||||
return this.$store.state.instance.tagPolicyAvailable
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleMenu () {
|
||||
this.showDropDown = !this.showDropDown
|
||||
},
|
||||
hasTag (tagName) {
|
||||
return this.tagsSet.has(tagName)
|
||||
},
|
||||
toggleTag (tag) {
|
||||
const store = this.$store
|
||||
if (this.tagsSet.has(tag)) {
|
||||
store.state.api.backendInteractor.untagUser(this.user, tag).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('untagUser', {user: this.user, tag})
|
||||
})
|
||||
} else {
|
||||
store.state.api.backendInteractor.tagUser(this.user, tag).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('tagUser', {user: this.user, tag})
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleRight (right) {
|
||||
const store = this.$store
|
||||
if (this.user.rights[right]) {
|
||||
store.state.api.backendInteractor.deleteRight(this.user, right).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('updateRight', {user: this.user, right: right, value: false})
|
||||
})
|
||||
} else {
|
||||
store.state.api.backendInteractor.addRight(this.user, right).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('updateRight', {user: this.user, right: right, value: true})
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleActivationStatus () {
|
||||
const store = this.$store
|
||||
const status = !!this.user.deactivated
|
||||
store.state.api.backendInteractor.setActivationStatus(this.user, status).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('updateActivationStatus', {user: this.user, status: status})
|
||||
})
|
||||
},
|
||||
deleteUserDialog (show) {
|
||||
this.showDeleteUserDialog = show
|
||||
},
|
||||
deleteUser () {
|
||||
const store = this.$store
|
||||
const user = this.user
|
||||
const {id, name} = user
|
||||
store.state.api.backendInteractor.deleteUser(user)
|
||||
.then(e => {
|
||||
this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
|
||||
const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
|
||||
const isTargetUser = this.$route.params.name === name || this.$route.params.id === id
|
||||
if (isProfile && isTargetUser) {
|
||||
window.history.back()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ModerationTools
|
|
@ -0,0 +1,158 @@
|
|||
<template>
|
||||
<div class='block' style='position: relative'>
|
||||
<Popper
|
||||
trigger="click"
|
||||
@hide='showDropDown = false'
|
||||
append-to-body
|
||||
:options="{
|
||||
placement: 'bottom-end',
|
||||
modifiers: {
|
||||
arrow: { enabled: true },
|
||||
offset: { offset: '0, 5px' },
|
||||
}
|
||||
}">
|
||||
<div class="popper-wrapper">
|
||||
<div class="dropdown-menu">
|
||||
<span v-if='user.is_local'>
|
||||
<button class="dropdown-item" @click='toggleRight("admin")'>
|
||||
{{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click='toggleRight("moderator")'>
|
||||
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
|
||||
</button>
|
||||
<div role="separator" class="dropdown-divider"></div>
|
||||
</span>
|
||||
<button class="dropdown-item" @click='toggleActivationStatus()'>
|
||||
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click='deleteUserDialog(true)'>
|
||||
{{ $t('user_card.admin_menu.delete_account') }}
|
||||
</button>
|
||||
<div role="separator" class="dropdown-divider" v-if='hasTagPolicy'></div>
|
||||
<span v-if='hasTagPolicy'>
|
||||
<button class="dropdown-item" @click='toggleTag(tags.FORCE_NSFW)'>
|
||||
{{ $t('user_card.admin_menu.force_nsfw') }}
|
||||
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"></span>
|
||||
</button>
|
||||
<button class="dropdown-item" @click='toggleTag(tags.STRIP_MEDIA)'>
|
||||
{{ $t('user_card.admin_menu.strip_media') }}
|
||||
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"></span>
|
||||
</button>
|
||||
<button class="dropdown-item" @click='toggleTag(tags.FORCE_UNLISTED)'>
|
||||
{{ $t('user_card.admin_menu.force_unlisted') }}
|
||||
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"></span>
|
||||
</button>
|
||||
<button class="dropdown-item" @click='toggleTag(tags.SANDBOX)'>
|
||||
{{ $t('user_card.admin_menu.sandbox') }}
|
||||
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"></span>
|
||||
</button>
|
||||
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)'>
|
||||
{{ $t('user_card.admin_menu.disable_remote_subscription') }}
|
||||
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"></span>
|
||||
</button>
|
||||
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)'>
|
||||
{{ $t('user_card.admin_menu.disable_any_subscription') }}
|
||||
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"></span>
|
||||
</button>
|
||||
<button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.QUARANTINE)'>
|
||||
{{ $t('user_card.admin_menu.quarantine') }}
|
||||
<span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button slot="reference" v-bind:class="{ pressed: showDropDown }" @click='toggleMenu'>
|
||||
{{ $t('user_card.admin_menu.moderation') }}
|
||||
</button>
|
||||
</Popper>
|
||||
<DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'>
|
||||
<span slot="header">{{ $t('user_card.admin_menu.delete_user') }}</span>
|
||||
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
|
||||
<span slot="footer">
|
||||
<button @click='deleteUserDialog(false)'>
|
||||
{{ $t('general.cancel') }}
|
||||
</button>
|
||||
<button class="danger" @click='deleteUser()'>
|
||||
{{ $t('user_card.admin_menu.delete_user') }}
|
||||
</button>
|
||||
</span>
|
||||
</DialogModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./moderation_tools.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import '../popper/popper.scss';
|
||||
|
||||
.dropdown-menu {
|
||||
display: block;
|
||||
padding: .5rem 0;
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
list-style: none;
|
||||
max-width: 100vw;
|
||||
z-index: 10;
|
||||
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
|
||||
box-shadow: var(--panelShadow);
|
||||
border: none;
|
||||
border-radius: $fallback--btnRadius;
|
||||
border-radius: var(--btnRadius, $fallback--btnRadius);
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--bg, $fallback--bg);
|
||||
|
||||
.dropdown-divider {
|
||||
height: 0;
|
||||
margin: .5rem 0;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid $fallback--border;
|
||||
border-top: 1px solid var(--border, $fallback--border);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
line-height: 21px;
|
||||
margin-right: 5px;
|
||||
overflow: auto;
|
||||
display: block;
|
||||
padding: .25rem 1.0rem .25rem 1.5rem;
|
||||
clear: both;
|
||||
font-weight: 400;
|
||||
text-align: inherit;
|
||||
white-space: normal;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
// TODO: improve the look on breeze themes
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--btn, $fallback--fg);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-checkbox {
|
||||
float: right;
|
||||
min-width: 22px;
|
||||
max-width: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
border-radius: 0px;
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--input, $fallback--fg);
|
||||
box-shadow: 0px 0px 2px black inset;
|
||||
box-shadow: var(--inputShadow);
|
||||
|
||||
&.menu-checkbox-checked::after {
|
||||
content: '✔';
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,37 @@
|
|||
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
|
||||
|
||||
const MuteCard = {
|
||||
props: ['userId'],
|
||||
data () {
|
||||
return {
|
||||
progress: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user () {
|
||||
return this.$store.getters.findUser(this.userId)
|
||||
},
|
||||
muted () {
|
||||
return this.user.muted
|
||||
}
|
||||
},
|
||||
components: {
|
||||
BasicUserCard
|
||||
},
|
||||
methods: {
|
||||
unmuteUser () {
|
||||
this.progress = true
|
||||
this.$store.dispatch('unmuteUser', this.user.id).then(() => {
|
||||
this.progress = false
|
||||
})
|
||||
},
|
||||
muteUser () {
|
||||
this.progress = true
|
||||
this.$store.dispatch('muteUser', this.user.id).then(() => {
|
||||
this.progress = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MuteCard
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<basic-user-card :user="user">
|
||||
<div class="mute-card-content-container">
|
||||
<button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted">
|
||||
<template v-if="progress">
|
||||
{{ $t('user_card.unmute_progress') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('user_card.unmute') }}
|
||||
</template>
|
||||
</button>
|
||||
<button class="btn btn-default" @click="muteUser" :disabled="progress" v-else>
|
||||
<template v-if="progress">
|
||||
{{ $t('user_card.mute_progress') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('user_card.mute') }}
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</basic-user-card>
|
||||
</template>
|
||||
|
||||
<script src="./mute_card.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.mute-card-content-container {
|
||||
margin-top: 0.5em;
|
||||
text-align: right;
|
||||
button {
|
||||
width: 10em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,10 +1,23 @@
|
|||
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
|
||||
|
||||
const NavPanel = {
|
||||
created () {
|
||||
if (this.currentUser && this.currentUser.locked) {
|
||||
const store = this.$store
|
||||
const credentials = store.state.users.currentUser.credentials
|
||||
|
||||
followRequestFetcher.startFetching({ store, credentials })
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentUser () {
|
||||
return this.$store.state.users.currentUser
|
||||
},
|
||||
chat () {
|
||||
return this.$store.state.chat.channel
|
||||
},
|
||||
followRequestCount () {
|
||||
return this.$store.state.api.followRequests.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="panel panel-default">
|
||||
<ul>
|
||||
<li v-if='currentUser'>
|
||||
<router-link to='/main/friends'>
|
||||
<router-link :to="{ name: 'friends' }">
|
||||
{{ $t("nav.timeline") }}
|
||||
</router-link>
|
||||
</li>
|
||||
|
@ -12,13 +12,26 @@
|
|||
{{ $t("nav.mentions") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if='currentUser'>
|
||||
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
|
||||
{{ $t("nav.dms") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if='currentUser && currentUser.locked'>
|
||||
<router-link :to="{ name: 'friend-requests' }">
|
||||
{{ $t("nav.friend_requests")}}
|
||||
<span v-if='followRequestCount > 0' class="badge follow-request-count">
|
||||
{{followRequestCount}}
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to='/main/public'>
|
||||
<router-link :to="{ name: 'public-timeline' }">
|
||||
{{ $t("nav.public_tl") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to='/main/all'>
|
||||
<router-link :to="{ name: 'public-external-timeline' }">
|
||||
{{ $t("nav.twkn") }}
|
||||
</router-link>
|
||||
</li>
|
||||
|
@ -34,6 +47,7 @@
|
|||
|
||||
.nav-panel .panel {
|
||||
overflow: hidden;
|
||||
box-shadow: var(--panelShadow);
|
||||
}
|
||||
.nav-panel ul {
|
||||
list-style: none;
|
||||
|
@ -41,6 +55,12 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.follow-request-count {
|
||||
margin: -6px 10px;
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--input, $fallback--faint);
|
||||
}
|
||||
|
||||
.nav-panel li {
|
||||
border-bottom: 1px solid;
|
||||
border-color: $fallback--border;
|
||||
|
|
|
@ -1,22 +1,48 @@
|
|||
import Status from '../status/status.vue'
|
||||
import StillImage from '../still-image/still-image.vue'
|
||||
import UserCardContent from '../user_card_content/user_card_content.vue'
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import UserCard from '../user_card/user_card.vue'
|
||||
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
|
||||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
|
||||
const Notification = {
|
||||
data () {
|
||||
return {
|
||||
userExpanded: false
|
||||
userExpanded: false,
|
||||
betterShadow: this.$store.state.interface.browserSupport.cssFilter
|
||||
}
|
||||
},
|
||||
props: [
|
||||
'notification'
|
||||
],
|
||||
props: [ 'notification' ],
|
||||
components: {
|
||||
Status, StillImage, UserCardContent
|
||||
Status, UserAvatar, UserCard
|
||||
},
|
||||
methods: {
|
||||
toggleUserExpanded () {
|
||||
this.userExpanded = !this.userExpanded
|
||||
},
|
||||
userProfileLink (user) {
|
||||
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
|
||||
},
|
||||
getUser (notification) {
|
||||
return this.$store.state.users.usersObject[notification.from_profile.id]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userClass () {
|
||||
return highlightClass(this.notification.from_profile)
|
||||
},
|
||||
userStyle () {
|
||||
const highlight = this.$store.state.config.highlight
|
||||
const user = this.notification.from_profile
|
||||
return highlightStyle(highlight[user.screen_name])
|
||||
},
|
||||
userInStore () {
|
||||
return this.$store.getters.findUser(this.notification.from_profile.id)
|
||||
},
|
||||
user () {
|
||||
if (this.userInStore) {
|
||||
return this.userInStore
|
||||
}
|
||||
return this.notification.from_profile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,26 @@
|
|||
<template>
|
||||
<status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status>
|
||||
<div class="non-mention" v-else>
|
||||
<a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
|
||||
<StillImage class='avatar-compact' :src="notification.action.user.profile_image_url_original"/>
|
||||
<status
|
||||
v-if="notification.type === 'mention'"
|
||||
:compact="true"
|
||||
:statusoid="notification.status"
|
||||
>
|
||||
</status>
|
||||
<div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]" v-else>
|
||||
<a class='avatar-container' :href="notification.from_profile.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
|
||||
<UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.from_profile.profile_image_url_original" />
|
||||
</a>
|
||||
<div class='notification-right'>
|
||||
<div class="usercard notification-usercard" v-if="userExpanded">
|
||||
<user-card-content :user="notification.action.user" :switcher="false"></user-card-content>
|
||||
</div>
|
||||
<UserCard :user="getUser(notification)" :rounded="true" :bordered="true" v-if="userExpanded" />
|
||||
<span class="notification-details">
|
||||
<div class="name-and-action">
|
||||
<span class="username" :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
|
||||
<span v-if="notification.type === 'favorite'">
|
||||
<span class="username" v-if="!!notification.from_profile.name_html" :title="'@'+notification.from_profile.screen_name" v-html="notification.from_profile.name_html"></span>
|
||||
<span class="username" v-else :title="'@'+notification.from_profile.screen_name">{{ notification.from_profile.name }}</span>
|
||||
<span v-if="notification.type === 'like'">
|
||||
<i class="fa icon-star lit"></i>
|
||||
<small>{{$t('notifications.favorited_you')}}</small>
|
||||
</span>
|
||||
<span v-if="notification.type === 'repeat'">
|
||||
<i class="fa icon-retweet lit"></i>
|
||||
<i class="fa icon-retweet lit" :title="$t('tool_tip.repeat')"></i>
|
||||
<small>{{$t('notifications.repeated_you')}}</small>
|
||||
</span>
|
||||
<span v-if="notification.type === 'follow'">
|
||||
|
@ -24,12 +28,25 @@
|
|||
<small>{{$t('notifications.followed_you')}}</small>
|
||||
</span>
|
||||
</div>
|
||||
<small class="timeago"><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
|
||||
<div class="timeago" v-if="notification.type === 'follow'">
|
||||
<span class="faint">
|
||||
<timeago :since="notification.created_at" :auto-update="240"></timeago>
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeago" v-else>
|
||||
<router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link">
|
||||
<timeago :since="notification.created_at" :auto-update="240"></timeago>
|
||||
</router-link>
|
||||
</div>
|
||||
</span>
|
||||
<div class="follow-text" v-if="notification.type === 'follow'">
|
||||
<router-link :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{notification.action.user.screen_name}}</router-link>
|
||||
<router-link :to="userProfileLink(notification.from_profile)">
|
||||
@{{notification.from_profile.screen_name}}
|
||||
</router-link>
|
||||
</div>
|
||||
<status v-else class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status>
|
||||
<template v-else>
|
||||
<status class="faint" :compact="true" :statusoid="notification.action" :noHeading="true"></status>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,28 +1,38 @@
|
|||
import Notification from '../notification/notification.vue'
|
||||
|
||||
import { sortBy, take, filter } from 'lodash'
|
||||
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
|
||||
import {
|
||||
notificationsFromStore,
|
||||
visibleNotificationsFromStore,
|
||||
unseenNotificationsFromStore
|
||||
} from '../../services/notification_utils/notification_utils.js'
|
||||
|
||||
const Notifications = {
|
||||
props: [
|
||||
'noHeading'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
visibleNotificationCount: 20
|
||||
bottomedOut: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
notifications () {
|
||||
return this.$store.state.statuses.notifications
|
||||
return notificationsFromStore(this.$store)
|
||||
},
|
||||
error () {
|
||||
return this.$store.state.statuses.notifications.error
|
||||
},
|
||||
unseenNotifications () {
|
||||
return filter(this.notifications, ({seen}) => !seen)
|
||||
return unseenNotificationsFromStore(this.$store)
|
||||
},
|
||||
visibleNotifications () {
|
||||
// Don't know why, but sortBy([seen, -action.id]) doesn't work.
|
||||
let sortedNotifications = sortBy(this.notifications, ({action}) => -action.id)
|
||||
sortedNotifications = sortBy(sortedNotifications, 'seen')
|
||||
return take(sortedNotifications, this.visibleNotificationCount)
|
||||
return visibleNotificationsFromStore(this.$store)
|
||||
},
|
||||
unseenCount () {
|
||||
return this.unseenNotifications.length
|
||||
},
|
||||
loading () {
|
||||
return this.$store.state.statuses.notifications.loading
|
||||
}
|
||||
},
|
||||
components: {
|
||||
|
@ -39,7 +49,22 @@ const Notifications = {
|
|||
},
|
||||
methods: {
|
||||
markAsSeen () {
|
||||
this.$store.commit('markNotificationsAsSeen', this.visibleNotifications)
|
||||
this.$store.dispatch('markNotificationsAsSeen')
|
||||
},
|
||||
fetchOlderNotifications () {
|
||||
const store = this.$store
|
||||
const credentials = store.state.users.currentUser.credentials
|
||||
store.commit('setNotificationsLoading', { value: true })
|
||||
notificationsFetcher.fetchAndUpdate({
|
||||
store,
|
||||
credentials,
|
||||
older: true
|
||||
}).then(notifs => {
|
||||
store.commit('setNotificationsLoading', { value: false })
|
||||
if (notifs.length === 0) {
|
||||
this.bottomedOut = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,50 +4,28 @@
|
|||
// a bit of a hack to allow scrolling below notifications
|
||||
padding-bottom: 15em;
|
||||
|
||||
.panel {
|
||||
background: $fallback--bg;
|
||||
background: var(--bg, $fallback--bg)
|
||||
.loadmore-error {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
border-color: $fallback--border;
|
||||
border-color: var(--border, $fallback--border)
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
// force the text to stay centered, while keeping
|
||||
// the button in the right side of the panel heading
|
||||
.notification {
|
||||
position: relative;
|
||||
background: $fallback--btn;
|
||||
background: var(--btn, $fallback--btn);
|
||||
color: $fallback--fg;
|
||||
color: var(--fg, $fallback--fg);
|
||||
.read-button {
|
||||
|
||||
.notification-overlay {
|
||||
position: absolute;
|
||||
right: 0.7em;
|
||||
height: 1.8em;
|
||||
line-height: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.unseen-count {
|
||||
display: inline-block;
|
||||
background-color: $fallback--cRed;
|
||||
background-color: var(--cRed, $fallback--cRed);
|
||||
text-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5);
|
||||
min-width: 1.3em;
|
||||
border-radius: 1.3em;
|
||||
margin: 0 0.2em 0 -0.4em;
|
||||
color: white;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
|
||||
.unseen {
|
||||
border-left: 4px solid $fallback--cRed;
|
||||
border-left: 4px solid var(--cRed, $fallback--cRed);
|
||||
padding-left: 0;
|
||||
&.unseen {
|
||||
.notification-overlay {
|
||||
background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,21 +33,8 @@
|
|||
box-sizing: border-box;
|
||||
display: flex;
|
||||
border-bottom: 1px solid;
|
||||
border-bottom-color: inherit;
|
||||
padding-left: 4px;
|
||||
|
||||
.avatar-compact {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: $fallback--avatarAltRadius;
|
||||
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
|
||||
&.animated::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
border-color: $fallback--border;
|
||||
border-color: var(--border, $fallback--border);
|
||||
|
||||
&:hover .animated.avatar {
|
||||
canvas {
|
||||
|
@ -80,10 +45,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.notification-usercard {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.non-mention {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
@ -99,6 +60,9 @@
|
|||
padding: 0.25em 0;
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
a {
|
||||
color: var(--faintLink);
|
||||
}
|
||||
}
|
||||
padding: 0;
|
||||
.media-body {
|
||||
|
@ -135,6 +99,7 @@
|
|||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
|
||||
.name-and-action {
|
||||
flex: 1;
|
||||
|
@ -147,10 +112,17 @@
|
|||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain
|
||||
}
|
||||
}
|
||||
|
||||
.timeago {
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
margin-right: .2em;
|
||||
}
|
||||
|
||||
.icon-retweet.lit {
|
||||
|
@ -196,15 +168,4 @@
|
|||
margin-bottom: 0.3em;
|
||||
}
|
||||
}
|
||||
|
||||
// ugly as heck
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
|
||||
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
|
||||
.status-el {
|
||||
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
|
||||
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,33 @@
|
|||
<template>
|
||||
<div class="notifications">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span>
|
||||
{{$t('notifications.notifications')}}
|
||||
<div v-if="!noHeading" class="panel-heading">
|
||||
<div class="title">
|
||||
{{$t('notifications.notifications')}}
|
||||
<span class="badge badge-notification unseen-count" v-if="unseenCount">{{unseenCount}}</span>
|
||||
</div>
|
||||
<div @click.prevent class="loadmore-error alert error" v-if="error">
|
||||
{{$t('timeline.error_fetching')}}
|
||||
</div>
|
||||
<button v-if="unseenCount" @click.prevent="markAsSeen" class="read-button">{{$t('notifications.read')}}</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div v-for="notification in visibleNotifications" :key="notification.action.id" class="notification" :class='{"unseen": !notification.seen}'>
|
||||
<div v-for="notification in visibleNotifications" :key="notification.id" class="notification" :class='{"unseen": !notification.seen}'>
|
||||
<div class="notification-overlay"></div>
|
||||
<notification :notification="notification"></notification>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<div v-if="bottomedOut" class="new-status-notification text-center panel-footer faint">
|
||||
{{$t('notifications.no_more_notifications')}}
|
||||
</div>
|
||||
<a v-else-if="!loading" href="#" v-on:click.prevent="fetchOlderNotifications()">
|
||||
<div class="new-status-notification text-center panel-footer">{{$t('notifications.load_older')}}</div>
|
||||
</a>
|
||||
<div v-else class="new-status-notification text-center panel-footer">
|
||||
<i class="icon-spin3 animate-spin"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import oauth from '../../services/new_api/oauth.js'
|
||||
|
||||
const oac = {
|
||||
props: ['code'],
|
||||
mounted () {
|
||||
if (this.code) {
|
||||
oauth.getToken({
|
||||
app: this.$store.state.oauth,
|
||||
instance: this.$store.state.instance.server,
|
||||
code: this.code
|
||||
}).then((result) => {
|
||||
this.$store.commit('setToken', result.access_token)
|
||||
this.$store.dispatch('loginUser', result.access_token)
|
||||
this.$router.push({name: 'friends'})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default oac
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<h1>...</h1>
|
||||
</template>
|
||||
|
||||
<script src="./oauth_callback.js"></script>
|
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<div class="opacity-control style-control" :class="{ disabled: !present || disabled }">
|
||||
<label :for="name" class="label">
|
||||
{{$t('settings.style.common.opacity')}}
|
||||
</label>
|
||||
<input
|
||||
v-if="typeof fallback !== 'undefined'"
|
||||
class="opt exclude-disabled"
|
||||
:id="name + '-o'"
|
||||
type="checkbox"
|
||||
:checked="present"
|
||||
@input="$emit('input', !present ? fallback : undefined)">
|
||||
<label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'"></label>
|
||||
<input
|
||||
:id="name"
|
||||
class="input-number"
|
||||
type="number"
|
||||
:value="value || fallback"
|
||||
:disabled="!present || disabled"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
max="1"
|
||||
min="0"
|
||||
step=".05">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: [
|
||||
'name', 'value', 'fallback', 'disabled'
|
||||
],
|
||||
computed: {
|
||||
present () {
|
||||
return typeof this.value !== 'undefined'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,70 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.popper-wrapper {
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.popper-wrapper .popper__arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
position: absolute;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.popper-wrapper[x-placement^="top"] {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.popper-wrapper[x-placement^="top"] .popper__arrow {
|
||||
border-width: 5px 5px 0 5px;
|
||||
border-color: $fallback--bg transparent transparent transparent;
|
||||
border-color: var(--bg, $fallback--bg) transparent transparent transparent;
|
||||
bottom: -5px;
|
||||
left: calc(50% - 5px);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popper-wrapper[x-placement^="bottom"] {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.popper-wrapper[x-placement^="bottom"] .popper__arrow {
|
||||
border-width: 0 5px 5px 5px;
|
||||
border-color: transparent transparent $fallback--bg transparent;
|
||||
border-color: transparent transparent var(--bg, $fallback--bg) transparent;
|
||||
top: -5px;
|
||||
left: calc(50% - 5px);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popper-wrapper[x-placement^="right"] {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.popper-wrapper[x-placement^="right"] .popper__arrow {
|
||||
border-width: 5px 5px 5px 0;
|
||||
border-color: transparent $fallback--bg transparent transparent;
|
||||
border-color: transparent var(--bg, $fallback--bg) transparent transparent;
|
||||
left: -5px;
|
||||
top: calc(50% - 5px);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.popper-wrapper[x-placement^="left"] {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.popper-wrapper[x-placement^="left"] .popper__arrow {
|
||||
border-width: 5px 0 5px 5px;
|
||||
border-color: transparent transparent transparent $fallback--bg;
|
||||
border-color: transparent transparent transparent var(--bg, $fallback--bg);
|
||||
right: -5px;
|
||||
top: calc(50% - 5px);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import statusPoster from '../../services/status_poster/status_poster.service.js'
|
||||
import MediaUpload from '../media_upload/media_upload.vue'
|
||||
import ScopeSelector from '../scope_selector/scope_selector.vue'
|
||||
import EmojiInput from '../emoji-input/emoji-input.vue'
|
||||
import fileTypeService from '../../services/file_type/file_type.service.js'
|
||||
import Completion from '../../services/completion/completion.js'
|
||||
import { take, filter, reject, map, uniqBy } from 'lodash'
|
||||
|
@ -16,30 +18,52 @@ const buildMentionsString = ({user, attentions}, currentUser) => {
|
|||
return `@${attention.screen_name}`
|
||||
})
|
||||
|
||||
return mentions.join(' ') + ' '
|
||||
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
|
||||
}
|
||||
|
||||
const PostStatusForm = {
|
||||
props: [
|
||||
'replyTo',
|
||||
'repliedUser',
|
||||
'attentions'
|
||||
'attentions',
|
||||
'copyMessageScope',
|
||||
'subject'
|
||||
],
|
||||
components: {
|
||||
MediaUpload
|
||||
MediaUpload,
|
||||
ScopeSelector,
|
||||
EmojiInput
|
||||
},
|
||||
mounted () {
|
||||
this.resize(this.$refs.textarea)
|
||||
const textLength = this.$refs.textarea.value.length
|
||||
this.$refs.textarea.setSelectionRange(textLength, textLength)
|
||||
|
||||
if (this.replyTo) {
|
||||
this.$refs.textarea.focus()
|
||||
}
|
||||
},
|
||||
data () {
|
||||
const preset = this.$route.query.message
|
||||
let statusText = preset || ''
|
||||
|
||||
const scopeCopy = typeof this.$store.state.config.scopeCopy === 'undefined'
|
||||
? this.$store.state.instance.scopeCopy
|
||||
: this.$store.state.config.scopeCopy
|
||||
|
||||
if (this.replyTo) {
|
||||
const currentUser = this.$store.state.users.currentUser
|
||||
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
|
||||
}
|
||||
|
||||
const scope = (this.copyMessageScope && scopeCopy || this.copyMessageScope === 'direct')
|
||||
? this.copyMessageScope
|
||||
: this.$store.state.users.currentUser.default_scope
|
||||
|
||||
const contentType = typeof this.$store.state.config.postContentType === 'undefined'
|
||||
? this.$store.state.instance.postContentType
|
||||
: this.$store.state.config.postContentType
|
||||
|
||||
return {
|
||||
dropFiles: [],
|
||||
submitDisabled: false,
|
||||
|
@ -47,27 +71,25 @@ const PostStatusForm = {
|
|||
posting: false,
|
||||
highlighted: 0,
|
||||
newStatus: {
|
||||
spoilerText: this.subject || '',
|
||||
status: statusText,
|
||||
nsfw: false,
|
||||
files: [],
|
||||
visibility: null
|
||||
visibility: scope,
|
||||
contentType
|
||||
},
|
||||
caret: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
vis () {
|
||||
return {
|
||||
public: { selected: !this.newStatus.visibility || this.newStatus.visibility === 'public' },
|
||||
unlisted: { selected: this.newStatus.visibility === 'unlisted' },
|
||||
private: { selected: this.newStatus.visibility === 'private' },
|
||||
direct: { selected: this.newStatus.visibility === 'direct' }
|
||||
}
|
||||
},
|
||||
candidates () {
|
||||
const firstchar = this.textAtCaret.charAt(0)
|
||||
if (firstchar === '@') {
|
||||
const matchedUsers = filter(this.users, (user) => (String(user.name + user.screen_name)).toUpperCase()
|
||||
.match(this.textAtCaret.slice(1).toUpperCase()))
|
||||
const query = this.textAtCaret.slice(1).toUpperCase()
|
||||
const matchedUsers = filter(this.users, (user) => {
|
||||
return user.screen_name.toUpperCase().startsWith(query) ||
|
||||
user.name && user.name.toUpperCase().startsWith(query)
|
||||
})
|
||||
if (matchedUsers.length <= 0) {
|
||||
return false
|
||||
}
|
||||
|
@ -81,16 +103,16 @@ const PostStatusForm = {
|
|||
}))
|
||||
} else if (firstchar === ':') {
|
||||
if (this.textAtCaret === ':') { return }
|
||||
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.match(this.textAtCaret.slice(1)))
|
||||
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
|
||||
if (matchedEmoji.length <= 0) {
|
||||
return false
|
||||
}
|
||||
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
|
||||
// eslint-disable-next-line camelcase
|
||||
screen_name: `:${shortcode}:`,
|
||||
name: '',
|
||||
utf: utf || '',
|
||||
img: image_url,
|
||||
// eslint-disable-next-line camelcase
|
||||
img: utf ? '' : this.$store.state.instance.server + image_url,
|
||||
highlighted: index === this.highlighted
|
||||
}))
|
||||
} else {
|
||||
|
@ -107,29 +129,59 @@ const PostStatusForm = {
|
|||
users () {
|
||||
return this.$store.state.users.users
|
||||
},
|
||||
userDefaultScope () {
|
||||
return this.$store.state.users.currentUser.default_scope
|
||||
},
|
||||
showAllScopes () {
|
||||
const minimalScopesMode = typeof this.$store.state.config.minimalScopesMode === 'undefined'
|
||||
? this.$store.state.instance.minimalScopesMode
|
||||
: this.$store.state.config.minimalScopesMode
|
||||
return !minimalScopesMode
|
||||
},
|
||||
emoji () {
|
||||
return this.$store.state.config.emoji || []
|
||||
return this.$store.state.instance.emoji || []
|
||||
},
|
||||
customEmoji () {
|
||||
return this.$store.state.config.customEmoji || []
|
||||
return this.$store.state.instance.customEmoji || []
|
||||
},
|
||||
statusLength () {
|
||||
return this.newStatus.status.length
|
||||
},
|
||||
spoilerTextLength () {
|
||||
return this.newStatus.spoilerText.length
|
||||
},
|
||||
statusLengthLimit () {
|
||||
return this.$store.state.config.textlimit
|
||||
return this.$store.state.instance.textlimit
|
||||
},
|
||||
hasStatusLengthLimit () {
|
||||
return this.statusLengthLimit > 0
|
||||
},
|
||||
charactersLeft () {
|
||||
return this.statusLengthLimit - this.statusLength
|
||||
return this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
|
||||
},
|
||||
isOverLengthLimit () {
|
||||
return this.hasStatusLengthLimit && (this.statusLength > this.statusLengthLimit)
|
||||
return this.hasStatusLengthLimit && (this.charactersLeft < 0)
|
||||
},
|
||||
scopeOptionsEnabled () {
|
||||
return this.$store.state.config.scopeOptionsEnabled
|
||||
minimalScopesMode () {
|
||||
return this.$store.state.instance.minimalScopesMode
|
||||
},
|
||||
alwaysShowSubject () {
|
||||
if (typeof this.$store.state.config.alwaysShowSubjectInput !== 'undefined') {
|
||||
return this.$store.state.config.alwaysShowSubjectInput
|
||||
} else if (typeof this.$store.state.instance.alwaysShowSubjectInput !== 'undefined') {
|
||||
return this.$store.state.instance.alwaysShowSubjectInput
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
formattingOptionsEnabled () {
|
||||
return this.$store.state.instance.formattingOptionsEnabled
|
||||
},
|
||||
postFormats () {
|
||||
return this.$store.state.instance.postFormats || []
|
||||
},
|
||||
safeDMEnabled () {
|
||||
return this.$store.state.instance.safeDM
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -178,6 +230,9 @@ const PostStatusForm = {
|
|||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
onKeydown (e) {
|
||||
e.stopPropagation()
|
||||
},
|
||||
setCaret ({target: {selectionStart}}) {
|
||||
this.caret = selectionStart
|
||||
},
|
||||
|
@ -199,19 +254,25 @@ const PostStatusForm = {
|
|||
status: newStatus.status,
|
||||
spoilerText: newStatus.spoilerText || null,
|
||||
visibility: newStatus.visibility,
|
||||
sensitive: newStatus.nsfw,
|
||||
media: newStatus.files,
|
||||
store: this.$store,
|
||||
inReplyToStatusId: this.replyTo
|
||||
inReplyToStatusId: this.replyTo,
|
||||
contentType: newStatus.contentType
|
||||
}).then((data) => {
|
||||
if (!data.error) {
|
||||
this.newStatus = {
|
||||
status: '',
|
||||
spoilerText: '',
|
||||
files: [],
|
||||
visibility: newStatus.visibility
|
||||
visibility: newStatus.visibility,
|
||||
contentType: newStatus.contentType
|
||||
}
|
||||
this.$refs.mediaUpload.clearFile()
|
||||
this.$emit('posted')
|
||||
let el = this.$el.querySelector('textarea')
|
||||
el.style.height = '16px'
|
||||
el.style.height = 'auto'
|
||||
el.style.height = undefined
|
||||
this.error = null
|
||||
} else {
|
||||
this.error = data.error
|
||||
|
@ -227,6 +288,11 @@ const PostStatusForm = {
|
|||
let index = this.newStatus.files.indexOf(fileInfo)
|
||||
this.newStatus.files.splice(index, 1)
|
||||
},
|
||||
uploadFailed (errString, templateArgs) {
|
||||
templateArgs = templateArgs || {}
|
||||
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
|
||||
this.enableSubmit()
|
||||
},
|
||||
disableSubmit () {
|
||||
this.submitDisabled = true
|
||||
},
|
||||
|
@ -238,6 +304,8 @@ const PostStatusForm = {
|
|||
},
|
||||
paste (e) {
|
||||
if (e.clipboardData.files.length > 0) {
|
||||
// prevent pasting of file as text
|
||||
e.preventDefault()
|
||||
// Strangely, files property gets emptied after event propagation
|
||||
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
|
||||
// to hold more than one file in clipboard.
|
||||
|
@ -254,13 +322,15 @@ const PostStatusForm = {
|
|||
e.dataTransfer.dropEffect = 'copy'
|
||||
},
|
||||
resize (e) {
|
||||
if (!e.target) { return }
|
||||
const vertPadding = Number(window.getComputedStyle(e.target)['padding-top'].substr(0, 1)) +
|
||||
Number(window.getComputedStyle(e.target)['padding-bottom'].substr(0, 1))
|
||||
e.target.style.height = 'auto'
|
||||
e.target.style.height = `${e.target.scrollHeight - vertPadding}px`
|
||||
if (e.target.value === '') {
|
||||
e.target.style.height = '16px'
|
||||
const target = e.target || e
|
||||
if (!(target instanceof window.Element)) { return }
|
||||
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
|
||||
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
|
||||
// Auto is needed to make textbox shrink when removing lines
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${target.scrollHeight - vertPadding}px`
|
||||
if (target.value === '') {
|
||||
target.style.height = null
|
||||
}
|
||||
},
|
||||
clearError () {
|
||||
|
|
|
@ -2,16 +2,29 @@
|
|||
<div class="post-status-form">
|
||||
<form @submit.prevent="postStatus(newStatus)">
|
||||
<div class="form-group" >
|
||||
<input
|
||||
v-if="scopeOptionsEnabled"
|
||||
<i18n
|
||||
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
|
||||
path="post_status.account_not_locked_warning"
|
||||
tag="p"
|
||||
class="visibility-notice">
|
||||
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
|
||||
</i18n>
|
||||
<p v-if="newStatus.visibility === 'direct'" class="visibility-notice">
|
||||
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
|
||||
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
|
||||
</p>
|
||||
<EmojiInput
|
||||
v-if="newStatus.spoilerText || alwaysShowSubject"
|
||||
type="text"
|
||||
:placeholder="$t('post_status.content_warning')"
|
||||
v-model="newStatus.spoilerText"
|
||||
class="form-cw">
|
||||
classname="form-control"
|
||||
/>
|
||||
<textarea
|
||||
ref="textarea"
|
||||
@click="setCaret"
|
||||
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
|
||||
@keydown="onKeydown"
|
||||
@keydown.down="cycleForward"
|
||||
@keydown.up="cycleBackward"
|
||||
@keydown.shift.tab="cycleBackward"
|
||||
|
@ -22,28 +35,47 @@
|
|||
@drop="fileDrop"
|
||||
@dragover.prevent="fileDrag"
|
||||
@input="resize"
|
||||
@paste="paste">
|
||||
@paste="paste"
|
||||
:disabled="posting"
|
||||
>
|
||||
</textarea>
|
||||
<div v-if="scopeOptionsEnabled" class="visibility-tray">
|
||||
<i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct"></i>
|
||||
<i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private"></i>
|
||||
<i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted"></i>
|
||||
<i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public"></i>
|
||||
<div class="visibility-tray">
|
||||
<span class="text-format" v-if="formattingOptionsEnabled">
|
||||
<label for="post-content-type" class="select">
|
||||
<select id="post-content-type" v-model="newStatus.contentType" class="form-control">
|
||||
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
|
||||
{{$t(`post_status.content_type["${postFormat}"]`)}}
|
||||
</option>
|
||||
</select>
|
||||
<i class="icon-down-open"></i>
|
||||
</label>
|
||||
</span>
|
||||
|
||||
<scope-selector
|
||||
:showAll="showAllScopes"
|
||||
:userDefault="userDefaultScope"
|
||||
:originalScope="copyMessageScope"
|
||||
:initialScope="newStatus.visibility"
|
||||
:onScopeChange="changeVis"/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:relative;" v-if="candidates">
|
||||
<div class="autocomplete-panel">
|
||||
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
|
||||
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
|
||||
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
|
||||
<span v-else>{{candidate.utf}}</span>
|
||||
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
|
||||
</div>
|
||||
<div class="autocomplete-panel" v-if="candidates">
|
||||
<div class="autocomplete-panel-body">
|
||||
<div
|
||||
v-for="(candidate, index) in candidates"
|
||||
:key="index"
|
||||
@click="replace(candidate.utf || (candidate.screen_name + ' '))"
|
||||
class="autocomplete-item"
|
||||
:class="{ highlighted: candidate.highlighted }"
|
||||
>
|
||||
<span v-if="candidate.img"><img :src="candidate.img" /></span>
|
||||
<span v-else>{{candidate.utf}}</span>
|
||||
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='form-bottom'>
|
||||
<media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload>
|
||||
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
|
||||
|
||||
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
|
||||
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>
|
||||
|
@ -54,17 +86,23 @@
|
|||
</div>
|
||||
<div class='alert error' v-if="error">
|
||||
Error: {{ error }}
|
||||
<i class="icon-cancel" @click="clearError"></i>
|
||||
<i class="button-icon icon-cancel" @click="clearError"></i>
|
||||
</div>
|
||||
<div class="attachments">
|
||||
<div class="media-upload-container attachment" v-for="file in newStatus.files">
|
||||
<i class="fa icon-cancel" @click="removeMediaFile(file)"></i>
|
||||
<img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
|
||||
<video v-if="type(file) === 'video'" :src="file.image" controls></video>
|
||||
<audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
|
||||
<a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
|
||||
<div class="media-upload-wrapper" v-for="file in newStatus.files">
|
||||
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i>
|
||||
<div class="media-upload-container attachment">
|
||||
<img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img>
|
||||
<video v-if="type(file) === 'video'" :src="file.url" controls></video>
|
||||
<audio v-if="type(file) === 'audio'" :src="file.url" controls></audio>
|
||||
<a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload_settings" v-if="newStatus.files.length > 0">
|
||||
<input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw">
|
||||
<label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -91,14 +129,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.post-status-form .visibility-tray {
|
||||
font-size: 1.2em;
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
.selected {
|
||||
color: $fallback--lightFg;
|
||||
color: var(--lightFg, $fallback--lightFg);
|
||||
.post-status-form {
|
||||
.visibility-tray {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -123,14 +158,49 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.media-upload-wrapper {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
min-width: 50px;
|
||||
margin-right: .2em;
|
||||
margin-bottom: .5em;
|
||||
|
||||
.icon-cancel {
|
||||
display: inline-block;
|
||||
position: static;
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
margin-left: $fallback--attachmentRadius;
|
||||
margin-left: var(--attachmentRadius, $fallback--attachmentRadius);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--btn, $fallback--fg);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
padding: 0 0.5em;
|
||||
|
||||
.attachment {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid $fallback--border;
|
||||
border: 1px solid var(--border, $fallback--border);
|
||||
margin: 0.5em 0.8em 0.2em 0;
|
||||
text-align: center;
|
||||
|
||||
audio {
|
||||
min-width: 300px;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
padding: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
|
@ -199,50 +269,5 @@
|
|||
cursor: pointer;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.autocomplete-panel {
|
||||
margin: 0 0.5em 0 0.5em;
|
||||
border-radius: $fallback--tooltipRadius;
|
||||
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
min-width: 75%;
|
||||
background: $fallback--bg;
|
||||
background: var(--bg, $fallback--bg);
|
||||
color: $fallback--lightFg;
|
||||
color: var(--lightFg, $fallback--lightFg);
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
cursor: pointer;
|
||||
padding: 0.2em 0.4em 0.2em 0.4em;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: $fallback--avatarRadius;
|
||||
border-radius: var(--avatarRadius, $fallback--avatarRadius);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
span {
|
||||
line-height: 24px;
|
||||
margin: 0 0.1em 0 0.2em;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-left: .5em;
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
background-color: $fallback--btn;
|
||||
background-color: var(--btn, $fallback--btn);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<button :disabled="progress || disabled" @click="onClick">
|
||||
<template v-if="progress">
|
||||
<slot name="progress" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean
|
||||
},
|
||||
click: { // click event handler. Must return a promise
|
||||
type: Function,
|
||||
default: () => Promise.resolve()
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
progress: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClick () {
|
||||
this.progress = true
|
||||
this.click().then(() => { this.progress = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -7,7 +7,7 @@ const PublicAndExternalTimeline = {
|
|||
timeline () { return this.$store.state.statuses.timelines.publicAndExternal }
|
||||
},
|
||||
created () {
|
||||
this.$store.dispatch('startFetching', 'publicAndExternal')
|
||||
this.$store.dispatch('startFetchingTimeline', { timeline: 'publicAndExternal' })
|
||||
},
|
||||
destroyed () {
|
||||
this.$store.dispatch('stopFetching', 'publicAndExternal')
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue