Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • pleroma/pleroma-fe
  • eal/pleroma-fe
  • peterspark/pleroma-fe
  • hb2k8/pleroma-fe
  • tibike/pleroma-fe
  • obrez/pleroma-fe
  • partial/pleroma-fe
  • href/pleroma-fe
  • hakabahitoyo/pleroma-fe
  • hsgw/pleroma-fe
  • Azurolu/pleroma-fe
  • cobalto/pleroma-fe
  • qwexvf/pleroma-fe
  • boner.engineer/pleroma-fe
  • f0x/pleroma-fe
  • ataalik/pleroma-fe
  • normandy/pleroma-fe
  • Sir_Boops/pleroma-fe
  • morguldir/pleroma-fe
  • csaurus/pleroma-fe
  • kaniini/pleroma-fe
  • bhtooefr/pleroma-fe
  • andarna/pleroma-fe
  • ktsukik/pleroma-fe
  • Steph/pleroma-fe
  • andrewzah/pleroma-fe
  • lanodan/pleroma-fe
  • pea/pleroma-fe
  • fotfd/pleroma-fe
  • pizzaiolo/pleroma-fe
  • Syldexia/pleroma-fe
  • riking/pleroma-fe
  • dr1ft/pleroma-fe
  • animeirl/pleroma-fe
  • elomatreb/pleroma-fe
  • viv/pleroma-fe
  • goofy/pleroma-fe
  • hoodie/pleroma-fe
  • stolas/pleroma-fe
  • peterpan/pleroma-fe
  • Lumitas/pleroma-fe
  • Toromino/pleroma-fe
  • galen/pleroma-fe
  • scarlett/pleroma-fe
  • ButterflyOfFire/pleroma-fe
  • vaartis/pleroma-fe
  • meireikei/pleroma-fe
  • darko/pleroma-fe
  • pony/pleroma-fe
  • succfemboi/pleroma-fe
  • fadelkon/pleroma-fe
  • dgold/pleroma-fe
  • nebula_moe/pleroma-fe
  • vinzv/pleroma-fe
  • slice/pleroma-fe
  • rinpatch/pleroma-fe
  • maxf/pleroma-fe
  • raeno/pleroma-fe
  • oceanvald/pleroma-fe
  • nuklearfiziks/pleroma-fe
  • feld/pleroma-fe
  • minibikini/pleroma-fe
  • link0ff/pleroma-fe
  • qadeer/pleroma-fe
  • FloatingGhost/pleroma-fe
  • cascode/pleroma-fe
  • hikaruaikawa/pleroma-fe
  • kjwon15/pleroma-fe
  • ukrop/pleroma-fe
  • ilja/pleroma-fe
  • shadowfacts/pleroma-fe
  • edijs/pleroma-fe
  • jdorman632/pleroma-fe
  • xruselfmadex/pleroma-fe
  • futureweb/pleroma-fe
  • eugenijm/pleroma-fe
  • tae/pleroma-fe
  • Dave/pleroma-fe
  • jasper/pleroma-fe
  • Lidar/pleroma-fe
  • parallel588/pleroma-fe
  • jaredr/pleroma-fe
  • rondnelly/pleroma-fe
  • Aditoo/pleroma-fe
  • FongWan/pleroma-fe
  • mkljczk/pleroma-fe
  • nik/pleroma-fe
  • brendenbice1222/pleroma-fe
  • Satak/pleroma-fe
  • xse/pleroma-fe
  • moonman/pleroma-fe
  • Artik/pleroma-fe
  • ssuprunenko/pleroma-fe
  • uncletrunks/pleroma-fe
  • absturztaube/pleroma-fe
  • wyatt777/pleroma-fe
  • hauvophuoc/pleroma-fe
  • dashie/pleroma-fe
  • shmibs/pleroma-fe
  • Elepow/pleroma-fe
  • raven/pleroma-fe
  • buoyantair/pleroma-fe
  • Exilat_a_Tolosa/pleroma-fe
  • matrixsasuke/pleroma-fe
  • njoseph/pleroma-fe
  • ELR/pleroma-fe
  • sjw/pleroma-fe
  • davidyin/pleroma-fe
  • pescetarian/pleroma-fe
  • kphrx/pleroma-fe
  • mewmew/pleroma-fe
  • h3poteto/pleroma-fe
  • Alexpono/pleroma-fe
  • seven/pleroma-fe
  • mparvin/pleroma-fe
  • tuxcrafting/pleroma-fe
  • nekojanai/pleroma-fe
  • xenofem/pleroma-fe
  • p/pleroma-fe
  • creme/pleroma-fe
  • jp/pleroma-fe
  • Jeder/pleroma-fe
  • gensogrips/pleroma-fe
  • caskd/pleroma-fe
  • arkSong/pleroma-fe
  • Hikali/pleroma-fe
  • Duponin/pleroma-fe
  • gashapwn/pleroma-fe
  • fence/pleroma-fe
  • Duder-onomy/pleroma-fe
  • translate/pleroma-fe
  • okl/pleroma-fe
  • bird/pleroma-fe
  • NEETzsche/pleroma-fe
  • Ewoke19CMR/pleroma-fe
  • shevek/pleroma-fe
  • cutienautica/pleroma-fe
  • Nakaya/pleroma-fe
  • Snow/pleroma-fe
  • seanking/pleroma-fe
  • kkcake/pleroma-fe
  • Testacc/pleroma-fe
  • flxy/pleroma-fe
  • xerz/pleroma-fe
  • maronu/pleroma-fe
  • matildepark/pleroma-fe
  • Craftplacer/pleroma-fe
147 results
Show changes
Showing
with 1010 additions and 55 deletions
import Timeline from '../timeline/timeline.vue'
const Mentions = {
computed: {
timeline () {
return this.$store.state.statuses.timelines.mentions
}
},
components: {
Timeline
}
}
export default Mentions
<template>
<Timeline :title="$t('nav.mentions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/>
</template>
<script src="./mentions.js"></script>
const NavPanel = { const NavPanel = {
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
chat () {
return this.$store.state.chat.channel
}
}
} }
export default NavPanel export default NavPanel
<template> <template>
<div class="nav-panel"> <div class="nav-panel">
<div class="panel panel-default"> <div class="panel panel-default base01-background">
<ul> <ul class="base03-border">
<li ng-if='currentUser'> <li v-if='currentUser'>
<router-link to='/main/friends'> <router-link class="base00-background" to='/main/friends'>
Timeline {{ $t("nav.timeline") }}
</router-link>
</li>
<li v-if='chat && currentUser'>
<router-link class="base00-background" to='/chat'>
{{ $t("nav.chat") }}
</router-link>
</li>
<li v-if='currentUser'>
<router-link class="base00-background" :to="{ name: 'mentions', params: { username: currentUser.screen_name } }">
{{ $t("nav.mentions") }}
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link to='/main/public'> <router-link class="base00-background" to='/main/public'>
Public Timeline {{ $t("nav.public_tl") }}
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link to='/main/all'> <router-link class="base00-background" to='/main/all'>
The Whole Known Network {{ $t("nav.twkn") }}
</router-link> </router-link>
</li> </li>
</ul> </ul>
...@@ -25,7 +35,6 @@ ...@@ -25,7 +35,6 @@
<script src="./nav_panel.js" ></script> <script src="./nav_panel.js" ></script>
<style lang="scss"> <style lang="scss">
.nav-panel ul { .nav-panel ul {
list-style: none; list-style: none;
margin: 0; margin: 0;
...@@ -33,9 +42,17 @@ ...@@ -33,9 +42,17 @@
} }
.nav-panel li { .nav-panel li {
border-bottom: 1px solid silver; border-bottom: 1px solid;
padding: 0.5em; border-color: inherit;
padding-left: 1em; padding: 0;
&:first-child a {
border-top-right-radius: 10px;
border-top-left-radius: 10px;
}
&:last-child a {
border-bottom-right-radius: 10px;
border-bottom-left-radius: 10px;
}
} }
.nav-panel li:last-child { .nav-panel li:last-child {
...@@ -44,10 +61,16 @@ ...@@ -44,10 +61,16 @@
.nav-panel a { .nav-panel a {
display: block; display: block;
width: 100%; padding: 0.8em 0.85em;
&:hover {
background-color: transparent;
}
&.router-link-active { &.router-link-active {
font-weight: bold font-weight: bolder;
background-color: transparent;
&:hover {
text-decoration: underline;
}
} }
} }
......
import Status from '../status/status.vue'
import { sortBy, take, filter } from 'lodash'
const Notifications = {
data () {
return {
visibleNotificationCount: 10
}
},
computed: {
notifications () {
return this.$store.state.statuses.notifications
},
unseenNotifications () {
return filter(this.notifications, ({seen}) => !seen)
},
visibleNotifications () {
// Don't know why, but sortBy([seen, -action.id]) doesn't work.
let sortedNotifications = sortBy(this.notifications, ({action}) => -action.id)
sortedNotifications = sortBy(sortedNotifications, 'seen')
return take(sortedNotifications, this.visibleNotificationCount)
},
unseenCount () {
return this.unseenNotifications.length
},
hiderStyle () {
return {
background: `linear-gradient(to bottom, rgba(0, 0, 0, 0), ${this.$store.state.config.colors['base00']} 80%)`
}
}
},
components: {
Status
},
watch: {
unseenCount (count) {
if (count > 0) {
this.$store.dispatch('setPageTitle', `(${count})`)
} else {
this.$store.dispatch('setPageTitle', '')
}
}
},
methods: {
markAsSeen () {
this.$store.commit('markNotificationsAsSeen', this.visibleNotifications)
}
}
}
export default Notifications
@import '../../_variables.scss';
.notifications {
// a bit of a hack to allow scrolling below notifications
padding-bottom: 15em;
.panel-heading {
// force the text to stay centered, while keeping
// the button in the right side of the panel heading
position: relative;
.read-button {
position: absolute;
right: 0.7em;
height: 1.8em;
line-height: 100%;
}
}
.unseen-count {
display: inline-block;
background-color: rgba(255, 16, 8, 0.8);
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;
}
.notification {
// Will have to use pixels here to ensure consistent distance with
// pad alone and pad + border, browsers bad at rounding this with em,
// they love to give a 1 pixel ghost offset with 0.7em vs 0.3em + 0.4em,
// which does not happen with 10px vs 4px + 6px.
padding: 0.4em 0 0 10px;
display: flex;
border-bottom: 1px solid;
border-bottom-color: inherit;
.text {
min-width: 0px;
word-wrap: break-word;
line-height:18px;
position: relative;
overflow: hidden;
.icon-retweet.lit {
color: $green;
}
.icon-user-plus.lit {
color: $blue;
}
.icon-reply.lit {
color: $blue;
}
.icon-star.lit {
color: orange;
}
.status-content {
margin: 0;
max-height: 300px;
}
h1 {
word-break: break-all;
margin: 0 0 0.3em;
padding: 0;
font-size: 1em;
line-height:20px;
small {
font-weight: lighter;
}
}
padding: 0.3em 0.8em 0.5em;
p {
margin: 0;
margin-top: 0;
margin-bottom: 0.3em;
}
}
.avatar {
padding-top: 0.3em;
width: 32px;
height: 32px;
border-radius: 50%;
}
&:last-child {
border-bottom: none;
border-radius: 0 0 10px 10px;
}
}
.notification-content {
max-height: 12em;
overflow-y: hidden;
//text-overflow: ellipsis;
}
.notification-gradient {
position: absolute;
width: 100%;
height: 4em;
margin-top:8em;
}
.unseen {
border-left: 4px solid rgba(255, 16, 8, 0.75);
padding-left: 6px;
}
}
<template>
<div class="notifications">
<div class="panel panel-default base00-background">
<div class="panel-heading base02-background base04">
<span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span>
{{$t('notifications.notifications')}}
<button v-if="unseenCount" @click.prevent="markAsSeen" class="base04 base02-background read-button">{{$t('notifications.read')}}</button>
</div>
<div class="panel-body base03-border">
<div v-for="notification in visibleNotifications" :key="notification" class="notification" :class='{"unseen": !notification.seen}'>
<div>
<a :href="notification.action.user.statusnet_profile_url" target="_blank">
<img class='avatar' :src="notification.action.user.profile_image_url_original">
</a>
</div>
<div class='text' style="width: 100%;">
<div v-if="notification.type === 'favorite'">
<h1>
<span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<i class="fa icon-star lit"></i>
<small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
</h1>
<div class="notification-gradient" :style="hiderStyle"></div>
<div class="notification-content" v-html="notification.status.statusnet_html"></div>
</div>
<div v-if="notification.type === 'repeat'">
<h1>
<span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<i class="fa icon-retweet lit"></i>
<small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
</h1>
<div class="notification-gradient" :style="hiderStyle"></div>
<div class="notification-content" v-html="notification.status.statusnet_html"></div>
</div>
<div v-if="notification.type === 'mention'">
<h1>
<span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<i class="fa icon-reply lit"></i>
<small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
</h1>
<status :compact="true" :statusoid="notification.status"></status>
</div>
<div v-if="notification.type === 'follow'">
<h1>
<span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<i class="fa icon-user-plus lit"></i>
</h1>
<div>
<router-link :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{ notification.action.user.screen_name }}</router-link> {{$t('notifications.followed_you')}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./notifications.js"></script>
<style lang="scss" src="./notifications.scss"></style>
import statusPoster from '../../services/status_poster/status_poster.service.js' import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue' import MediaUpload from '../media_upload/media_upload.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { reject, map, uniqBy } from 'lodash' import Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => { const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions] let allAttentions = [...attentions]
...@@ -36,28 +37,148 @@ const PostStatusForm = { ...@@ -36,28 +37,148 @@ const PostStatusForm = {
} }
return { return {
dropFiles: [],
submitDisabled: false,
error: null,
posting: false,
newStatus: { newStatus: {
status: statusText, status: statusText,
files: [] files: []
},
caret: 0
}
},
computed: {
candidates () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === '@') {
const matchedUsers = filter(this.users, (user) => (String(user.name + user.screen_name)).match(this.textAtCaret.slice(1)))
if (matchedUsers.length <= 0) {
return false
}
// eslint-disable-next-line camelcase
return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}) => ({
// eslint-disable-next-line camelcase
screen_name: `@${screen_name}`,
name: name,
img: profile_image_url_original
}))
} else if (firstchar === ':') {
const matchedEmoji = filter(this.emoji, (emoji) => emoji.shortcode.match(this.textAtCaret.slice(1)))
if (matchedEmoji.length <= 0) {
return false
}
return map(take(matchedEmoji, 5), ({shortcode, image_url}) => ({
// eslint-disable-next-line camelcase
screen_name: `:${shortcode}:`,
name: '',
img: image_url
}))
} else {
return false
} }
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {}
return word
},
users () {
return this.$store.state.users.users
},
emoji () {
return this.$store.state.config.emoji || []
} }
}, },
methods: { methods: {
replace (replacement) {
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
const el = this.$el.querySelector('textarea')
el.focus()
this.caret = 0
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
},
postStatus (newStatus) { postStatus (newStatus) {
if (this.posting) { return }
if (this.newStatus.status === '') {
if (this.newStatus.files.length > 0) {
this.newStatus.status = '\u200b' // hack
} else {
this.error = 'Cannot post an empty status with no files'
return
}
}
this.posting = true
statusPoster.postStatus({ statusPoster.postStatus({
status: newStatus.status, status: newStatus.status,
media: newStatus.files, media: newStatus.files,
store: this.$store, store: this.$store,
inReplyToStatusId: this.replyTo inReplyToStatusId: this.replyTo
}).then((data) => {
if (!data.error) {
this.newStatus = {
status: '',
files: []
}
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = '16px'
this.error = null
} else {
this.error = data.error
}
this.posting = false
}) })
this.newStatus = {
status: '',
files: []
}
this.$emit('posted')
}, },
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
this.enableSubmit()
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
},
disableSubmit () {
this.submitDisabled = true
},
enableSubmit () {
this.submitDisabled = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
if (e.clipboardData.files.length > 0) {
// 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.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop (e) {
if (e.dataTransfer.files.length > 0) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
}
},
fileDrag (e) {
e.dataTransfer.dropEffect = 'copy'
},
resize (e) {
e.target.style.height = 'auto'
e.target.style.height = `${e.target.scrollHeight - 10}px`
if (e.target.value === '') {
e.target.style.height = '16px'
}
},
clearError () {
this.error = null
} }
} }
} }
......
<template> <template>
<div class="post-status-form"> <div class="post-status-form">
<form v-on:submit.prevent="postStatus(newStatus)"> <form @submit.prevent="postStatus(newStatus)">
<div class="form-group" > <div class="form-group base03-border" >
<textarea v-model="newStatus.status" placeholder="Just landed in L.A." rows="3" class="form-control"></textarea> <textarea @click="setCaret" @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" @keydown.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize" @paste="paste"></textarea>
</div> </div>
<div class="attachments"> <div style="position:relative;" v-if="candidates">
<div class="attachment" v-for="file in newStatus.files"> <div class="autocomplete-panel base05-background">
<img class="thumbnail media-upload" :src="file.image"></img> <div v-for="candidate in candidates" @click="replace(candidate.screen_name + ' ')" class="autocomplete base02">
<img :src="candidate.img"></img>
<span>
{{candidate.screen_name}}
<small class="base02">{{candidate.name}}</small>
</span>
</div>
</div> </div>
</div> </div>
<div class='form-bottom'> <div class='form-bottom'>
<media-upload v-on:uploaded="addMediaFile"></media-upload> <media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload>
<button type="submit" class="btn btn-default" >Submit</button> <button v-if="posting" disabled class="btn btn-default base05 base02-background">{{$t('post_status.posting')}}</button>
<button v-else :disabled="submitDisabled" type="submit" class="btn btn-default base05 base02-background">{{$t('general.submit')}}</button>
</div>
<div class='error' v-if="error">
Error: {{ error }}
<i class="icon-cancel" @click="clearError"></i>
</div>
<div class="attachments">
<div class="media-upload-container attachment base03-border" 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>
</div> </div>
</form> </form>
</div> </div>
...@@ -20,30 +40,140 @@ ...@@ -20,30 +40,140 @@
<script src="./post_status_form.js"></script> <script src="./post_status_form.js"></script>
<style lang="scss"> <style lang="scss">
.tribute-container {
ul {
padding: 0px;
li {
display: flex;
align-items: center;
}
}
img {
padding: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
}
}
.post-status-form, .login { .post-status-form, .login {
.form-bottom { .form-bottom {
display: flex; display: flex;
padding: 0.5em; padding: 0.5em;
height: 32px;
button { button {
flex: 2; width: 10em;
} }
} }
.error {
border-radius: 5px;
text-align: center;
background-color: rgba(255, 48, 16, 0.65);
padding: 0.25em;
margin: 0.35em;
display: flex;
}
.attachments { .attachments {
padding: 0.5em; padding: 0 0.5em;
.attachment {
position: relative;
margin: 0.5em 0.8em 0.2em 0;
}
i {
position: absolute;
margin: 10px;
padding: 5px;
background: rgba(230,230,230,0.6);
border-radius: 5px;
font-weight: bold;
}
} }
.btn {
cursor: pointer;
}
.btn[disabled] {
cursor: not-allowed;
}
.icon-cancel {
cursor: pointer;
}
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.5em; padding: 0.6em;
} }
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.5em; padding: 0.3em 0.5em 0.6em;
line-height:24px;
}
form textarea {
border: solid;
border-width: 1px;
border-color: inherit;
border-radius: 5px;
line-height:16px;
padding: 5px;
resize: none;
overflow: hidden;
}
form textarea:focus {
min-height: 48px;
}
.btn {
cursor: pointer;
}
.btn[disabled] {
cursor: not-allowed;
}
.icon-cancel {
cursor: pointer;
z-index: 4;
}
.autocomplete-panel {
margin: 0 0.5em 0 0.5em;
border-radius: 5px;
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
min-width: 75%;
}
.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: 2px;
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
font-style: italic;
}
} }
} }
......
...@@ -5,6 +5,12 @@ const PublicAndExternalTimeline = { ...@@ -5,6 +5,12 @@ const PublicAndExternalTimeline = {
}, },
computed: { computed: {
timeline () { return this.$store.state.statuses.timelines.publicAndExternal } timeline () { return this.$store.state.statuses.timelines.publicAndExternal }
},
created () {
this.$store.dispatch('startFetching', 'publicAndExternal')
},
destroyed () {
this.$store.dispatch('stopFetching', 'publicAndExternal')
} }
} }
......
<template> <template>
<div class="timeline panel panel-default"> <Timeline :title="$t('nav.twkn')"v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/>
<div class="panel-heading">THE WHOLE KNOWN NETWORK</div>
<div class="panel-body">
<Timeline v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/>
</div>
</div>
</template> </template>
<script src="./public_and_external_timeline.js"></script> <script src="./public_and_external_timeline.js"></script>
...@@ -5,7 +5,14 @@ const PublicTimeline = { ...@@ -5,7 +5,14 @@ const PublicTimeline = {
}, },
computed: { computed: {
timeline () { return this.$store.state.statuses.timelines.public } timeline () { return this.$store.state.statuses.timelines.public }
},
created () {
this.$store.dispatch('startFetching', 'public')
},
destroyed () {
this.$store.dispatch('stopFetching', 'public')
} }
} }
export default PublicTimeline export default PublicTimeline
<template> <template>
<div class="timeline panel panel-default"> <Timeline :title="$t('nav.public_tl')" v-bind:timeline="timeline" v-bind:timeline-name="'public'"/>
<div class="panel-heading">Public Timeline</div>
<div class="panel-body">
<Timeline v-bind:timeline="timeline" v-bind:timeline-name="'public'"/>
</div>
</div>
</template> </template>
<script src="./public_timeline.js"></script> <script src="./public_timeline.js"></script>
const registration = {
data: () => ({
user: {},
error: false,
registering: false
}),
created () {
if (!this.$store.state.config.registrationOpen || !!this.$store.state.users.currentUser) {
this.$router.push('/main/all')
}
},
computed: {
termsofservice () { return this.$store.state.config.tos }
},
methods: {
submit () {
this.registering = true
this.user.nickname = this.user.username
this.$store.state.api.backendInteractor.register(this.user).then(
(response) => {
if (response.ok) {
this.$store.dispatch('loginUser', this.user)
this.$router.push('/main/all')
this.registering = false
} else {
this.registering = false
response.json().then((data) => {
this.error = data.error
})
}
}
)
}
}
}
export default registration
<template>
<div class="settings panel panel-default base00-background">
<div class="panel-heading base02-background base04">
{{$t('registration.registration')}}
</div>
<div class="panel-body">
<form v-on:submit.prevent='submit(user)' class='registration-form'>
<div class='container'>
<div class='text-fields'>
<div class='form-group'>
<label for='username'>{{$t('login.username')}}</label>
<input :disabled="registering" v-model='user.username' class='form-control' id='username' placeholder='e.g. lain'>
</div>
<div class='form-group'>
<label for='fullname'>{{$t('registration.fullname')}}</label>
<input :disabled="registering" v-model='user.fullname' class='form-control' id='fullname' placeholder='e.g. Lain Iwakura'>
</div>
<div class='form-group'>
<label for='email'>{{$t('registration.email')}}</label>
<input :disabled="registering" v-model='user.email' class='form-control' id='email' type="email">
</div>
<div class='form-group'>
<label for='bio'>{{$t('registration.bio')}}</label>
<input :disabled="registering" v-model='user.bio' class='form-control' id='bio'>
</div>
<div class='form-group'>
<label for='password'>{{$t('login.password')}}</label>
<input :disabled="registering" v-model='user.password' class='form-control' id='password' type='password'>
</div>
<div class='form-group'>
<label for='password_confirmation'>{{$t('registration.password_confirm')}}</label>
<input :disabled="registering" v-model='user.confirm' class='form-control' id='password_confirmation' type='password'>
</div>
<!--
<div class='form-group'>
<label for='captcha'>Captcha</label>
<img src='/qvittersimplesecurity/captcha.jpg' alt='captcha' class='captcha'>
<input :disabled="registering" v-model='user.captcha' placeholder='Enter captcha' type='test' class='form-control' id='captcha'>
</div>
-->
<div class='form-group'>
<button :disabled="registering" type='submit' class='btn btn-default base05 base02-background'>{{$t('general.submit')}}</button>
</div>
</div>
<div class='terms-of-service' v-html="termsofservice">
</div>
</div>
<div v-if="error" class='form-group'>
<div class='error base05'>{{error}}</div>
</div>
</form>
</div>
</div>
</template>
<script src="./registration.js"></script>
<style lang="scss">
.registration-form {
display: flex;
flex-direction: column;
margin: 0.6em;
.container {
display: flex;
flex-direction: row;
//margin-bottom: 1em;
}
.terms-of-service {
flex: 0 1 50%;
margin: 0.8em;
}
.text-fields {
margin-top: 0.6em;
flex: 1 0;
display: flex;
flex-direction: column;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.3em 0.0em 0.3em;
line-height:24px;
}
form textarea {
border: solid;
border-width: 1px;
border-color: silver;
border-radius: 5px;
line-height:16px;
padding: 5px;
resize: vertical;
}
input {
border-width: 1px;
border-style: solid;
border-color: silver;
border-radius: 5px;
padding: 0.1em 0.2em 0.2em 0.2em;
}
.captcha {
max-width: 350px;
margin-bottom: 0.4em;
}
.btn {
//align-self: flex-start;
//width: 10em;
margin-top: 0.6em;
height: 28px;
}
.error {
border-radius: 5px;
text-align: center;
margin: 0.5em 0.6em 0;
background-color: rgba(255, 48, 16, 0.65);
min-height: 28px;
line-height: 28px;
}
}
@media all and (max-width: 959px) {
.registration-form .container {
flex-direction: column-reverse;
}
}
</style>
const RetweetButton = { const RetweetButton = {
props: [ 'status' ], props: ['status'],
data () {
return {
animated: false
}
},
methods: { methods: {
retweet () { retweet () {
if (!this.status.repeated) { if (!this.status.repeated) {
this.$store.dispatch('retweet', {id: this.status.id}) this.$store.dispatch('retweet', {id: this.status.id})
} }
this.animated = true
setTimeout(() => {
this.animated = false
}, 500)
} }
}, },
computed: { computed: {
classes () { classes () {
return { return {
'retweeted': this.status.repeated 'retweeted': this.status.repeated,
'animate-spin': this.animated
} }
} }
} }
......
<template> <template>
<div> <div>
<i :class='classes' class='icon-retweet fa' v-on:click.prevent='retweet()'></i> <i :class='classes' class='icon-retweet base09' v-on:click.prevent='retweet()'></i>
<span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span> <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span>
</div> </div>
</template> </template>
...@@ -11,12 +11,12 @@ ...@@ -11,12 +11,12 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.icon-retweet { .icon-retweet {
cursor: pointer; cursor: pointer;
animation-duration: 0.6s;
&:hover { &:hover {
color: $green; color: $green;
} }
} }
.retweeted { .icon-retweet.retweeted {
cursor: auto;
color: $green; color: $green;
} }
</style> </style>
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import { filter, trim } from 'lodash'
const settings = {
data () {
return {
hideAttachmentsLocal: this.$store.state.config.hideAttachments,
hideAttachmentsInConvLocal: this.$store.state.config.hideAttachmentsInConv,
hideNsfwLocal: this.$store.state.config.hideNsfw,
muteWordsString: this.$store.state.config.muteWords.join('\n'),
autoLoadLocal: this.$store.state.config.autoLoad,
streamingLocal: this.$store.state.config.streaming,
hoverPreviewLocal: this.$store.state.config.hoverPreview
}
},
components: {
StyleSwitcher
},
computed: {
user () {
return this.$store.state.users.currentUser
}
},
watch: {
hideAttachmentsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideAttachments', value })
},
hideAttachmentsInConvLocal (value) {
this.$store.dispatch('setOption', { name: 'hideAttachmentsInConv', value })
},
hideNsfwLocal (value) {
this.$store.dispatch('setOption', { name: 'hideNsfw', value })
},
autoLoadLocal (value) {
this.$store.dispatch('setOption', { name: 'autoLoad', value })
},
streamingLocal (value) {
this.$store.dispatch('setOption', { name: 'streaming', value })
},
hoverPreviewLocal (value) {
this.$store.dispatch('setOption', { name: 'hoverPreview', value })
},
muteWordsString (value) {
value = filter(value.split('\n'), (word) => trim(word).length > 0)
this.$store.dispatch('setOption', { name: 'muteWords', value })
}
}
}
export default settings
<template>
<div class="settings panel panel-default base00-background">
<div class="panel-heading base02-background base04">
{{$t('settings.settings')}}
</div>
<div class="panel-body">
<div class="setting-item">
<h2>{{$t('settings.theme')}}</h2>
<style-switcher></style-switcher>
</div>
<div class="setting-item">
<h2>{{$t('settings.filtering')}}</h2>
<p>{{$t('settings.filtering_explanation')}}</p>
<textarea id="muteWords" v-model="muteWordsString"></textarea>
</div>
<div class="setting-item">
<h2>{{$t('settings.attachments')}}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal">
<label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label>
</li>
<li>
<input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal">
<label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label>
</li>
<li>
<input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal">
<label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label>
</li>
<li>
<input type="checkbox" id="autoLoad" v-model="autoLoadLocal">
<label for="autoLoad">{{$t('settings.autoload')}}</label>
</li>
<li>
<input type="checkbox" id="streaming" v-model="streamingLocal">
<label for="streaming">{{$t('settings.streaming')}}</label>
</li>
<li>
<input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal">
<label for="hoverPreview">{{$t('settings.reply_link_preview')}}</label>
</li>
</ul>
</div>
</div>
</div>
</template>
<script src="./settings.js">
</script>
<style lang="scss">
.setting-item {
margin: 1em 1em 1.4em;
textarea {
width: 100%;
height: 100px;
}
.old-avatar {
width: 128px;
border-radius: 5px;
}
.new-avatar {
object-fit: cover;
width: 128px;
height: 128px;
border-radius: 5px;
}
.btn {
margin-top: 1em;
min-height: 28px;
width: 10em;
}
}
.setting-list {
list-style-type: none;
}
</style>
import Attachment from '../attachment/attachment.vue' import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue' import FavoriteButton from '../favorite_button/favorite_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue'
import DeleteButton from '../delete_button/delete_button.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue' import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
import { filter, find } from 'lodash'
const Status = { const Status = {
props: [ 'statusoid' ], props: [
'statusoid',
'expandable',
'inConversation',
'focused',
'highlight',
'compact',
'replies'
],
data: () => ({ data: () => ({
replying: false replying: false,
expanded: false,
unmuted: false,
userExpanded: false,
preview: null,
showPreview: false
}), }),
computed: { computed: {
muteWords () {
return this.$store.state.config.muteWords
},
hideAttachments () {
return (this.$store.state.config.hideAttachments && !this.inConversation) ||
(this.$store.state.config.hideAttachmentsInConv && this.inConversation)
},
retweet () { return !!this.statusoid.retweeted_status }, retweet () { return !!this.statusoid.retweeted_status },
retweeter () { return this.statusoid.user.name }, retweeter () { return this.statusoid.user.name },
status () { status () {
...@@ -20,17 +43,101 @@ const Status = { ...@@ -20,17 +43,101 @@ const Status = {
}, },
loggedIn () { loggedIn () {
return !!this.$store.state.users.currentUser return !!this.$store.state.users.currentUser
},
muteWordHits () {
const statusText = this.status.text.toLowerCase()
const hits = filter(this.muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase())
})
return hits
},
muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) },
isReply () { return !!this.status.in_reply_to_status_id },
borderColor () {
return {
borderBottomColor: this.$store.state.config.colors['base02']
}
},
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
} }
}, },
components: { components: {
Attachment, Attachment,
FavoriteButton, FavoriteButton,
RetweetButton, RetweetButton,
PostStatusForm DeleteButton,
PostStatusForm,
UserCardContent
}, },
methods: { methods: {
linkClicked ({target}) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
toggleReplying () { toggleReplying () {
this.replying = !this.replying this.replying = !this.replying
},
gotoOriginal (id) {
// only handled by conversation, not status_or_conversation
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded () {
this.$emit('toggleExpanded')
},
toggleMute () {
this.unmuted = !this.unmuted
},
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
replyEnter (id, event) {
this.showPreview = true
const targetId = Number(id)
const statuses = this.$store.state.statuses.allStatuses
if (!this.preview) {
// if we have the status somewhere already
this.preview = find(statuses, { 'id': targetId })
// or if we have to fetch it
if (!this.preview) {
this.$store.state.api.backendInteractor.fetchStatus({id}).then((status) => {
this.preview = status
})
}
} else if (this.preview.id !== targetId) {
this.preview = find(statuses, { 'id': targetId })
}
},
replyLeave () {
this.showPreview = false
}
},
watch: {
'highlight': function (id) {
id = Number(id)
if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
window.scrollBy(0, rect.top - 200)
} else if (rect.bottom > window.innerHeight - 50) {
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
} }
} }
} }
......