Improve desktop UX (#4262)

* wip

* wip

* wip

* wip

* wip

* wip

* Merge

* wip

* wip

* wip

* wip

* wip

* wip
This commit is contained in:
syuilo 2019-02-15 05:08:59 +09:00 committed by GitHub
parent 38ca514f53
commit 53422ffcb2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 1132 additions and 1222 deletions

View file

@ -1,49 +0,0 @@
<template>
<x-widgets-column v-if="column.type == 'widgets'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-notifications-column v-else-if="column.type == 'notifications'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'home'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'local'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-direct-column v-else-if="column.type == 'direct'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XTlColumn from './deck.tl-column.vue';
import XNotificationsColumn from './deck.notifications-column.vue';
import XWidgetsColumn from './deck.widgets-column.vue';
import XMentionsColumn from './deck.mentions-column.vue';
import XDirectColumn from './deck.direct-column.vue';
export default Vue.extend({
components: {
XTlColumn,
XNotificationsColumn,
XWidgetsColumn,
XMentionsColumn,
XDirectColumn
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: false,
default: false
}
},
methods: {
focus() {
this.$children[0].focus();
}
}
});
</script>

View file

@ -1,424 +0,0 @@
<template>
<div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs" :class="{ naked, narrow, active, isStacked, draghover, dragging, dropready }"
@dragover.prevent.stop="onDragover"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
v-hotkey="keymap">
<header :class="{ indicate: count > 0 }"
draggable="true"
@click="goTop"
@dragstart="onDragstart"
@dragend="onDragend"
@contextmenu.prevent.stop="onContextmenu">
<button class="toggleActive" @click="toggleActive" v-if="isStacked">
<template v-if="active"><fa icon="angle-up"/></template>
<template v-else><fa icon="angle-down"/></template>
</button>
<slot name="header"></slot>
<span class="count" v-if="count > 0">({{ count }})</span>
<button v-if="!isTemporaryColumn" class="menu" ref="menu" @click.stop="showMenu"><fa icon="caret-down"/></button>
<button v-else class="close" @click.stop="close"><fa icon="times"/></button>
</header>
<div ref="body" v-show="active">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import Menu from '../../../../common/views/components/menu.vue';
import { countIf } from '../../../../../../prelude/array';
export default Vue.extend({
i18n: i18n('deck'),
props: {
column: {
type: Object,
required: false,
default: null
},
isStacked: {
type: Boolean,
required: false,
default: false
},
name: {
type: String,
required: false
},
menu: {
type: Array,
required: false,
default: null
},
naked: {
type: Boolean,
required: false,
default: false
},
narrow: {
type: Boolean,
required: false,
default: false
}
},
computed: {
isTemporaryColumn(): boolean {
return this.column == null;
},
keymap(): any {
return {
'shift+up': () => this.$parent.$emit('parentFocus', 'up'),
'shift+down': () => this.$parent.$emit('parentFocus', 'down'),
'shift+left': () => this.$parent.$emit('parentFocus', 'left'),
'shift+right': () => this.$parent.$emit('parentFocus', 'right'),
};
}
},
inject: {
getColumnVm: { from: 'getColumnVm' }
},
data() {
return {
count: 0,
active: true,
dragging: false,
draghover: false,
dropready: false
};
},
watch: {
active(v) {
if (v && this.isScrollTop()) {
this.$emit('top');
}
},
dragging(v) {
this.$root.$emit(v ? 'deck.column.dragStart' : 'deck.column.dragEnd');
}
},
provide() {
return {
column: this,
isScrollTop: this.isScrollTop,
count: v => this.count = v
};
},
mounted() {
this.$refs.body.addEventListener('scroll', this.onScroll, { passive: true });
if (!this.isTemporaryColumn) {
this.$root.$on('deck.column.dragStart', this.onOtherDragStart);
this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd);
}
},
beforeDestroy() {
this.$refs.body.removeEventListener('scroll', this.onScroll);
if (!this.isTemporaryColumn) {
this.$root.$off('deck.column.dragStart', this.onOtherDragStart);
this.$root.$off('deck.column.dragEnd', this.onOtherDragEnd);
}
},
methods: {
onOtherDragStart() {
this.dropready = true;
},
onOtherDragEnd() {
this.dropready = false;
},
toggleActive() {
if (!this.isStacked) return;
const vms = this.$store.state.settings.deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id));
if (this.active && countIf(vm => vm.$el.classList.contains('active'), vms) == 1) return;
this.active = !this.active;
},
isScrollTop() {
return this.active && this.$refs.body.scrollTop == 0;
},
onScroll() {
if (this.isScrollTop()) {
this.$emit('top');
}
if (this.$store.state.settings.fetchOnScroll !== false) {
const current = this.$refs.body.scrollTop + this.$refs.body.clientHeight;
if (current > this.$refs.body.scrollHeight - 1) this.$emit('bottom');
}
},
getMenu() {
const items = [{
icon: 'pencil-alt',
text: this.$t('rename'),
action: () => {
this.$root.dialog({
title: this.$t('rename'),
input: {
default: this.name,
allowEmpty: false
}
}).then(({ canceled, result: name }) => {
if (canceled) return;
this.$store.dispatch('settings/renameDeckColumn', { id: this.column.id, name });
});
}
}, null, {
icon: 'arrow-left',
text: this.$t('swap-left'),
action: () => {
this.$store.dispatch('settings/swapLeftDeckColumn', this.column.id);
}
}, {
icon: 'arrow-right',
text: this.$t('swap-right'),
action: () => {
this.$store.dispatch('settings/swapRightDeckColumn', this.column.id);
}
}, this.isStacked ? {
icon: 'arrow-up',
text: this.$t('swap-up'),
action: () => {
this.$store.dispatch('settings/swapUpDeckColumn', this.column.id);
}
} : undefined, this.isStacked ? {
icon: 'arrow-down',
text: this.$t('swap-down'),
action: () => {
this.$store.dispatch('settings/swapDownDeckColumn', this.column.id);
}
} : undefined, null, {
icon: ['far', 'window-restore'],
text: this.$t('stack-left'),
action: () => {
this.$store.dispatch('settings/stackLeftDeckColumn', this.column.id);
}
}, this.isStacked ? {
icon: ['far', 'window-maximize'],
text: this.$t('pop-right'),
action: () => {
this.$store.dispatch('settings/popRightDeckColumn', this.column.id);
}
} : undefined, null, {
icon: ['far', 'trash-alt'],
text: this.$t('remove'),
action: () => {
this.$store.dispatch('settings/removeDeckColumn', this.column.id);
}
}];
if (this.menu) {
items.unshift(null);
for (const i of this.menu.reverse()) {
items.unshift(i);
}
}
return items;
},
onContextmenu(e) {
if (this.isTemporaryColumn) return;
this.$contextmenu(e, this.getMenu());
},
showMenu() {
this.$root.new(Menu, {
source: this.$refs.menu,
items: this.getMenu()
});
},
close() {
this.$store.commit('device/set', {
key: 'deckTemporaryColumn',
value: null
});
},
goTop() {
this.$refs.body.scrollTo({
top: 0,
behavior: 'smooth'
});
},
onDragstart(e) {
//
if (this.isTemporaryColumn) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('mk-deck-column', this.column.id);
this.dragging = true;
},
onDragend(e) {
this.dragging = false;
},
onDragover(e) {
//
if (this.isTemporaryColumn) {
e.dataTransfer.dropEffect = 'none';
return;
}
//
if (this.dragging) {
//
e.dataTransfer.dropEffect = 'none';
return;
}
const isDeckColumn = e.dataTransfer.types[0] == 'mk-deck-column';
e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
if (!this.dragging && isDeckColumn) this.draghover = true;
},
onDragleave() {
this.draghover = false;
},
onDrop(e) {
this.draghover = false;
this.$root.$emit('deck.column.dragEnd');
const id = e.dataTransfer.getData('mk-deck-column');
if (id != null && id != '') {
this.$store.dispatch('settings/swapDeckColumn', {
a: this.column.id,
b: id
});
}
}
}
});
</script>
<style lang="stylus" scoped>
.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs
$header-height = 42px
height 100%
background var(--face)
border-radius var(--round)
box-shadow var(--shadow)
overflow hidden
&.draghover
box-shadow 0 0 0 2px var(--primaryAlpha08)
&:after
content ""
display block
position absolute
z-index 1000
top 0
left 0
width 100%
height 100%
background var(--primaryAlpha02)
&.dragging
box-shadow 0 0 0 2px var(--primaryAlpha04)
&.dropready
*
pointer-events none
&:not(.active)
flex-basis $header-height
min-height $header-height
&:not(.isStacked).narrow
width 285px
min-width 285px
flex-grow 0 !important
&.naked
background var(--deckAcrylicColumnBg)
> header
background transparent
box-shadow none
> button
color var(--text)
> header
display flex
z-index 2
line-height $header-height
padding 0 16px
font-size 14px
color var(--faceHeaderText)
background var(--faceHeader)
box-shadow 0 var(--lineWidth) rgba(#000, 0.15)
cursor pointer
&, *
user-select none
*:not(button)
pointer-events none
&.indicate
box-shadow 0 3px 0 0 var(--primary)
> span
[data-icon]
margin-right 8px
> .count
margin-left 4px
opacity 0.5
> .toggleActive
> .menu
> .close
padding 0
width $header-height
line-height $header-height
font-size 16px
color var(--faceTextButton)
&:hover
color var(--faceTextButtonHover)
&:active
color var(--faceTextButtonActive)
> .toggleActive
margin-left -16px
> .menu
> .close
margin-left auto
margin-right -16px
> div
height "calc(100% - %s)" % $header-height
overflow auto
overflow-x hidden
</style>

View file

@ -1,46 +0,0 @@
<template>
<x-column :name="name" :column="column" :is-stacked="isStacked">
<span slot="header"><fa :icon="['far', 'envelope']"/>{{ name }}</span>
<x-direct/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XColumn from './deck.column.vue';
import XDirect from './deck.direct.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XDirect
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
return this.$t('@deck.direct');
}
},
methods: {
focus() {
this.$refs.tl.focus();
}
}
});
</script>

View file

@ -1,101 +0,0 @@
<template>
<x-notes ref="timeline" :more="existMore ? more : null"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
props: {
},
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
connection: null
};
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('mention', this.onNote);
this.fetch();
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
fetch() {
this.fetching = true;
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api('notes/mentions', {
limit: fetchLimit + 1,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
visibility: 'specified'
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
this.$emit('loaded');
}, rej);
}));
},
more() {
this.moreFetching = true;
const promise = this.$root.api('notes/mentions', {
limit: fetchLimit + 1,
untilId: (this.$refs.timeline as any).tail().id,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
visibility: 'specified'
});
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
},
onNote(note) {
// Prepend a note
if (note.visibility == 'specified') {
(this.$refs.timeline as any).prepend(note);
}
},
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View file

@ -1,112 +0,0 @@
<template>
<x-column>
<span slot="header">
<fa icon="hashtag"/><span>{{ tag }}</span>
</span>
<div class="xroyrflcmhhtmlwmyiwpfqiirqokfueb">
<div ref="chart" class="chart"></div>
<x-hashtag-tl :tag-tl="tagTl" class="tl"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import XColumn from './deck.column.vue';
import XHashtagTl from './deck.hashtag-tl.vue';
import ApexCharts from 'apexcharts';
export default Vue.extend({
components: {
XColumn,
XHashtagTl
},
props: {
tag: {
type: String,
required: true
}
},
computed: {
tagTl(): any {
return {
query: [[this.tag]]
};
}
},
mounted() {
this.$root.api('charts/hashtag', {
tag: this.tag,
span: 'hour',
limit: 24
}).then(stats => {
const local = [];
const remote = [];
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
const h = now.getHours();
for (let i = 0; i < 24; i++) {
const x = new Date(y, m, d, h - i);
local.push([x, stats.local.count[i]]);
remote.push([x, stats.remote.count[i]]);
}
const chart = new ApexCharts(this.$refs.chart, {
chart: {
type: 'area',
height: 70,
sparkline: {
enabled: true
},
},
grid: {
clipMarkers: false,
padding: {
top: 16,
right: 16,
bottom: 16,
left: 16
}
},
stroke: {
curve: 'straight',
width: 2
},
series: [{
name: 'Local',
data: local
}, {
name: 'Remote',
data: remote
}],
xaxis: {
type: 'datetime',
}
});
chart.render();
});
}
});
</script>
<style lang="stylus" scoped>
.xroyrflcmhhtmlwmyiwpfqiirqokfueb
background var(--deckColumnBg)
> .chart
margin-bottom 16px
background var(--face)
> .tl
background var(--face)
</style>

View file

@ -1,126 +0,0 @@
<template>
<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
props: {
tagTl: {
type: Object,
required: true
},
mediaOnly: {
type: Boolean,
required: false,
default: false
},
mediaView: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
connection: null
};
},
watch: {
mediaOnly() {
this.fetch();
}
},
mounted() {
if (this.connection) this.connection.close();
this.connection = this.$root.stream.connectToChannel('hashtag', {
q: this.tagTl.query
});
this.connection.on('note', this.onNote);
this.fetch();
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
fetch() {
this.fetching = true;
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api('notes/search_by_tag', {
limit: fetchLimit + 1,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
query: this.tagTl.query
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
this.$emit('loaded');
}, rej);
}));
},
more() {
this.moreFetching = true;
const promise = this.$root.api('notes/search_by_tag', {
limit: fetchLimit + 1,
untilId: (this.$refs.timeline as any).tail().id,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
query: this.tagTl.query
});
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
},
onNote(note) {
if (this.mediaOnly && note.files.length == 0) return;
// Prepend a note
(this.$refs.timeline as any).prepend(note);
},
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View file

@ -1,136 +0,0 @@
<template>
<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
props: {
list: {
type: Object,
required: true
},
mediaOnly: {
type: Boolean,
required: false,
default: false
},
mediaView: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
connection: null
};
},
watch: {
mediaOnly() {
this.fetch();
}
},
mounted() {
if (this.connection) this.connection.dispose();
this.connection = this.$root.stream.connectToChannel('userList', {
listId: this.list.id
});
this.connection.on('note', this.onNote);
this.connection.on('userAdded', this.onUserAdded);
this.connection.on('userRemoved', this.onUserRemoved);
this.fetch();
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
fetch() {
this.fetching = true;
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api('notes/user-list-timeline', {
listId: this.list.id,
limit: fetchLimit + 1,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
this.$emit('loaded');
}, rej);
}));
},
more() {
this.moreFetching = true;
const promise = this.$root.api('notes/user-list-timeline', {
listId: this.list.id,
limit: fetchLimit + 1,
untilId: (this.$refs.timeline as any).tail().id,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
});
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
},
onNote(note) {
if (this.mediaOnly && note.files.length == 0) return;
// Prepend a note
(this.$refs.timeline as any).prepend(note);
},
onUserAdded() {
this.fetch();
},
onUserRemoved() {
this.fetch();
},
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View file

@ -1,46 +0,0 @@
<template>
<x-column :name="name" :column="column" :is-stacked="isStacked">
<span slot="header"><fa icon="at"/>{{ name }}</span>
<x-mentions ref="tl"/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XColumn from './deck.column.vue';
import XMentions from './deck.mentions.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XMentions
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
return this.$t('@deck.mentions');
}
},
methods: {
focus() {
this.$refs.tl.focus();
}
}
});
</script>

View file

@ -1,97 +0,0 @@
<template>
<x-notes ref="timeline" :more="existMore ? more : null"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
props: {
},
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
connection: null
};
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('mention', this.onNote);
this.fetch();
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
fetch() {
this.fetching = true;
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api('notes/mentions', {
limit: fetchLimit + 1,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
this.$emit('loaded');
}, rej);
}));
},
more() {
this.moreFetching = true;
const promise = this.$root.api('notes/mentions', {
limit: fetchLimit + 1,
untilId: (this.$refs.timeline as any).tail().id,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
});
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
},
onNote(note) {
// Prepend a note
(this.$refs.timeline as any).prepend(note);
},
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View file

@ -1,70 +0,0 @@
<template>
<x-column>
<span slot="header">
<fa :icon="['far', 'comment-alt']"/><mk-user-name :user="note.user" v-if="note"/>
</span>
<div class="rvtscbadixhhbsczoorqoaygovdeecsx" v-if="note">
<div class="is-remote" v-if="note.user.host != null">
<details>
<summary><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-post') }}</summary>
<a :href="note.url || note.uri" target="_blank">{{ $t('@.view-on-remote') }}</a>
</details>
</div>
<x-note :note="note" :detail="true" :mini="true"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XColumn from './deck.column.vue';
import XNotes from './deck.notes.vue';
import XNote from '../../components/note.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XNotes,
XNote
},
props: {
noteId: {
type: String,
required: true
}
},
data() {
return {
note: null,
fetching: true
};
},
created() {
this.$root.api('notes/show', { noteId: this.noteId }).then(note => {
this.note = note;
this.fetching = false;
});
}
});
</script>
<style lang="stylus" scoped>
.rvtscbadixhhbsczoorqoaygovdeecsx
> .is-remote
padding 8px 16px
font-size 12px
&.is-remote
color var(--remoteInfoFg)
background var(--remoteInfoBg)
> a
font-weight bold
</style>

View file

@ -1,245 +0,0 @@
<template>
<div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu">
<slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot>
<div class="placeholder" v-if="fetching">
<template v-for="i in 10">
<mk-note-skeleton :key="i"/>
</template>
</div>
<mk-error v-if="!fetching && requestInitPromise != null" @retry="resolveInitPromise"/>
<!-- トランジションを有効にするとなぜかメモリリークする -->
<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition notes" ref="notes" tag="div">
<template v-for="(note, i) in _notes">
<x-note
:note="note"
:key="note.id"
@update:note="onNoteUpdated(i, $event)"
:media-view="mediaView"
:compact="true"
:mini="true"/>
<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
<span><fa icon="angle-up"/>{{ note._datetext }}</span>
<span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span>
</p>
</template>
</component>
<footer v-if="more">
<button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $t('@.load-more') }}</template>
<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>
</button>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import shouldMuteNote from '../../../../common/scripts/should-mute-note';
import XNote from '../../components/note.vue';
const displayLimit = 20;
export default Vue.extend({
i18n: i18n(),
components: {
XNote
},
inject: ['column', 'isScrollTop', 'count'],
props: {
more: {
type: Function,
required: false
},
mediaView: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
rootEl: null,
requestInitPromise: null as () => Promise<any[]>,
notes: [],
queue: [],
fetching: true,
moreFetching: false
};
},
computed: {
_notes(): any[] {
return (this.notes as any).map(note => {
const date = new Date(note.createdAt).getDate();
const month = new Date(note.createdAt).getMonth() + 1;
note._date = date;
note._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString());
return note;
});
}
},
watch: {
queue(q) {
this.count(q.length);
}
},
created() {
this.column.$on('top', this.onTop);
this.column.$on('bottom', this.onBottom);
},
beforeDestroy() {
this.column.$off('top', this.onTop);
this.column.$off('bottom', this.onBottom);
},
methods: {
focus() {
(this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus();
},
onNoteUpdated(i, note) {
Vue.set((this as any).notes, i, note);
},
init(promiseGenerator: () => Promise<any[]>) {
this.requestInitPromise = promiseGenerator;
this.resolveInitPromise();
},
resolveInitPromise() {
this.queue = [];
this.notes = [];
this.fetching = true;
const promise = this.requestInitPromise();
promise.then(notes => {
this.notes = notes;
this.requestInitPromise = null;
this.fetching = false;
}, e => {
this.fetching = false;
});
},
prepend(note, silent = false) {
//
if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return;
//
if (document.hidden) {
this.$store.commit('pushBehindNote', note);
}
if (this.isScrollTop()) {
// Prepend the note
this.notes.unshift(note);
// 稿
if (this.notes.length >= displayLimit) {
this.notes = this.notes.slice(0, displayLimit);
}
} else {
this.queue.push(note);
}
},
append(note) {
this.notes.push(note);
},
tail() {
return this.notes[this.notes.length - 1];
},
releaseQueue() {
for (const n of this.queue) {
this.prepend(n, true);
}
this.queue = [];
},
async loadMore() {
if (this.more == null) return;
if (this.moreFetching) return;
this.moreFetching = true;
await this.more();
this.moreFetching = false;
},
onTop() {
this.releaseQueue();
},
onBottom() {
this.loadMore();
}
}
});
</script>
<style lang="stylus" scoped>
.eamppglmnmimdhrlzhplwpvyeaqmmhxu
.transition
.mk-notes-enter
.mk-notes-leave-to
opacity 0
transform translateY(-30px)
> *
transition transform .3s ease, opacity .3s ease
> .placeholder
padding 16px
opacity 0.3
> .notes
> .date
display block
margin 0
line-height 28px
font-size 12px
text-align center
color var(--dateDividerFg)
background var(--dateDividerBg)
border-bottom solid var(--lineWidth) var(--faceDivider)
span
margin 0 16px
[data-icon]
margin-right 8px
> footer
> button
display block
margin 0
padding 16px
width 100%
text-align center
color #ccc
background var(--face)
border-top solid var(--lineWidth) var(--faceDivider)
border-bottom-left-radius 6px
border-bottom-right-radius 6px
&:hover
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
&:active
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
</style>

View file

@ -1,193 +0,0 @@
<template>
<div class="dsfykdcjpuwfvpefwufddclpjhzktmpw">
<div class="notification reaction" v-if="notification.type == 'reaction'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<mk-reaction-icon :reaction="notification.reaction"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
<router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
<fa icon="quote-left"/>
<mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/>
<fa icon="quote-right"/>
</router-link>
</div>
</div>
<div class="notification renote" v-if="notification.type == 'renote'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<fa icon="retweet"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
<router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)">
<fa icon="quote-left"/>
<mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/>
<fa icon="quote-right"/>
</router-link>
</div>
</div>
<div class="notification follow" v-if="notification.type == 'follow'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<fa icon="user-plus"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
</div>
</div>
<div class="notification followRequest" v-if="notification.type == 'receiveFollowRequest'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<fa icon="user-clock"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
</div>
</div>
<div class="notification poll_vote" v-if="notification.type == 'poll_vote'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<fa icon="chart-pie"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
<router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
<fa icon="quote-left"/>
<mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/>
<fa icon="quote-right"/>
</router-link>
</div>
</div>
<template v-if="notification.type == 'quote'">
<x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/>
</template>
<template v-if="notification.type == 'reply'">
<x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/>
</template>
<template v-if="notification.type == 'mention'">
<x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import getNoteSummary from '../../../../../../misc/get-note-summary';
import XNote from '../../components/note.vue';
export default Vue.extend({
components: {
XNote
},
props: ['notification'],
data() {
return {
getNoteSummary
};
},
methods: {
onNoteUpdated(note) {
switch (this.notification.type) {
case 'quote':
case 'reply':
case 'mention':
Vue.set(this.notification, 'note', note);
break;
}
}
}
});
</script>
<style lang="stylus" scoped>
.dsfykdcjpuwfvpefwufddclpjhzktmpw
> .notification
padding 16px
font-size 12px
overflow-wrap break-word
&:after
content ""
display block
clear both
> .avatar
display block
float left
width 36px
height 36px
border-radius 6px
> div
float right
width calc(100% - 36px)
padding-left 8px
> header
display flex
align-items baseline
white-space nowrap
[data-icon], .mk-reaction-icon
margin-right 4px
> .mk-time
margin-left auto
color var(--noteHeaderInfo)
font-size 0.9em
> .note-preview
color var(--noteText)
> .note-ref
color var(--noteText)
display inline-block
width: 100%
overflow hidden
white-space nowrap
text-overflow ellipsis
[data-icon]
font-size 1em
font-weight normal
font-style normal
display inline-block
margin-right 3px
&.renote
> div > header [data-icon]
color #77B255
&.follow
> div > header [data-icon]
color #53c7ce
&.receiveFollowRequest
> div > header [data-icon]
color #888
</style>

View file

@ -1,40 +0,0 @@
<template>
<x-column :name="name" :column="column" :is-stacked="isStacked">
<span slot="header"><fa :icon="['far', 'bell']"/>{{ name }}</span>
<x-notifications/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XColumn from './deck.column.vue';
import XNotifications from './deck.notifications.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XNotifications
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
return this.$t('@deck.notifications');
}
},
});
</script>

View file

@ -1,223 +0,0 @@
<template>
<div class="oxynyeqmfvracxnglgulyqfgqxnxmehl">
<div class="placeholder" v-if="fetching">
<template v-for="i in 10">
<mk-note-skeleton :key="i"/>
</template>
</div>
<!-- トランジションを有効にするとなぜかメモリリークする -->
<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications" tag="div">
<template v-for="(notification, i) in _notifications">
<x-notification class="notification" :notification="notification" :key="notification.id"/>
<p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'">
<span><fa icon="angle-up"/>{{ notification._datetext }}</span>
<span><fa icon="angle-down"/>{{ _notifications[i + 1]._datetext }}</span>
</p>
</template>
</component>
<button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
<template v-if="fetchingMoreNotifications"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreNotifications ? this.$t('@.loading') : this.$t('@.load-more') }}
</button>
<p class="empty" v-if="notifications.length == 0 && !fetching">{{ $t('empty') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XNotification from './deck.notification.vue';
const displayLimit = 20;
export default Vue.extend({
i18n: i18n(),
components: {
XNotification
},
inject: ['column', 'isScrollTop', 'count'],
data() {
return {
fetching: true,
fetchingMoreNotifications: false,
notifications: [],
queue: [],
moreNotifications: false,
connection: null
};
},
computed: {
_notifications(): any[] {
return (this.notifications as any).map(notification => {
const date = new Date(notification.createdAt).getDate();
const month = new Date(notification.createdAt).getMonth() + 1;
notification._date = date;
notification._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString());
return notification;
});
}
},
watch: {
queue(q) {
this.count(q.length);
}
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('notification', this.onNotification);
this.column.$on('top', this.onTop);
this.column.$on('bottom', this.onBottom);
const max = 10;
this.$root.api('i/notifications', {
limit: max + 1
}).then(notifications => {
if (notifications.length == max + 1) {
this.moreNotifications = true;
notifications.pop();
}
this.notifications = notifications;
this.fetching = false;
});
},
beforeDestroy() {
this.connection.dispose();
this.column.$off('top', this.onTop);
this.column.$off('bottom', this.onBottom);
},
methods: {
fetchMoreNotifications() {
this.fetchingMoreNotifications = true;
const max = 20;
this.$root.api('i/notifications', {
limit: max + 1,
untilId: this.notifications[this.notifications.length - 1].id
}).then(notifications => {
if (notifications.length == max + 1) {
this.moreNotifications = true;
notifications.pop();
} else {
this.moreNotifications = false;
}
this.notifications = this.notifications.concat(notifications);
this.fetchingMoreNotifications = false;
});
},
onNotification(notification) {
// TODO: ()
this.$root.stream.send('readNotification', {
id: notification.id
});
this.prepend(notification);
},
prepend(notification) {
if (this.isScrollTop()) {
// Prepend the notification
this.notifications.unshift(notification);
//
if (this.notifications.length >= displayLimit) {
this.notifications = this.notifications.slice(0, displayLimit);
}
} else {
this.queue.push(notification);
}
},
releaseQueue() {
for (const n of this.queue) {
this.prepend(n);
}
this.queue = [];
},
onTop() {
this.releaseQueue();
},
onBottom() {
this.fetchMoreNotifications();
}
}
});
</script>
<style lang="stylus" scoped>
.oxynyeqmfvracxnglgulyqfgqxnxmehl
.transition
.mk-notifications-enter
.mk-notifications-leave-to
opacity 0
transform translateY(-30px)
> *
transition transform .3s ease, opacity .3s ease
> .placeholder
padding 16px
opacity 0.3
> .notifications
> .notification:not(:last-child)
border-bottom solid var(--lineWidth) var(--faceDivider)
> .date
display block
margin 0
line-height 28px
text-align center
font-size 12px
color var(--dateDividerFg)
background var(--dateDividerBg)
border-bottom solid var(--lineWidth) var(--faceDivider)
span
margin 0 16px
[data-icon]
margin-right 8px
> .more
display block
width 100%
padding 16px
color #555
border-top solid var(--lineWidth) rgba(#000, 0.05)
&:hover
background rgba(#000, 0.025)
&:active
background rgba(#000, 0.05)
&.fetching
cursor wait
> [data-icon]
margin-right 4px
> .empty
margin 0
padding 16px
text-align center
color var(--text)
</style>

View file

@ -1,105 +0,0 @@
<template>
<x-column :menu="menu" :name="name" :column="column" :is-stacked="isStacked">
<span slot="header">
<fa v-if="column.type == 'home'" icon="home"/>
<fa v-if="column.type == 'local'" :icon="['far', 'comments']"/>
<fa v-if="column.type == 'hybrid'" icon="share-alt"/>
<fa v-if="column.type == 'global'" icon="globe"/>
<fa v-if="column.type == 'list'" icon="list"/>
<fa v-if="column.type == 'hashtag'" icon="hashtag"/>
<span>{{ name }}</span>
</span>
<div class="editor" style="padding:12px" v-if="edit">
<ui-switch v-model="column.isMediaOnly" @change="onChangeSettings">{{ $t('is-media-only') }}</ui-switch>
<ui-switch v-model="column.isMediaView" @change="onChangeSettings">{{ $t('is-media-view') }}</ui-switch>
</div>
<x-list-tl v-if="column.type == 'list'"
:list="column.list"
:media-only="column.isMediaOnly"
:media-view="column.isMediaView"
ref="tl"
/>
<x-hashtag-tl v-else-if="column.type == 'hashtag'"
:tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)"
:media-only="column.isMediaOnly"
:media-view="column.isMediaView"
ref="tl"
/>
<x-tl v-else
:src="column.type"
:media-only="column.isMediaOnly"
:media-view="column.isMediaView"
ref="tl"
/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XColumn from './deck.column.vue';
import XTl from './deck.tl.vue';
import XListTl from './deck.list-tl.vue';
import XHashtagTl from './deck.hashtag-tl.vue';
export default Vue.extend({
i18n: i18n('deck/deck.tl-column.vue'),
components: {
XColumn,
XTl,
XListTl,
XHashtagTl
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
edit: false,
menu: [{
icon: 'cog',
text: this.$t('edit'),
action: () => {
this.edit = !this.edit;
}
}]
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
switch (this.column.type) {
case 'home': return this.$t('@deck.home');
case 'local': return this.$t('@deck.local');
case 'hybrid': return this.$t('@deck.hybrid');
case 'global': return this.$t('@deck.global');
case 'list': return this.column.list.title;
case 'hashtag': return this.$store.state.settings.tagTimelines.find(x => x.id == this.column.tagTlId).title;
}
}
},
methods: {
onChangeSettings(v) {
this.$store.dispatch('settings/saveDeck');
},
focus() {
this.$refs.tl.focus();
}
}
});
</script>

View file

@ -1,151 +0,0 @@
<template>
<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
props: {
src: {
type: String,
required: false,
default: 'home'
},
mediaOnly: {
type: Boolean,
required: false,
default: false
},
mediaView: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
connection: null
};
},
computed: {
stream(): any {
switch (this.src) {
case 'home': return this.$root.stream.useSharedConnection('homeTimeline');
case 'local': return this.$root.stream.useSharedConnection('localTimeline');
case 'hybrid': return this.$root.stream.useSharedConnection('hybridTimeline');
case 'global': return this.$root.stream.useSharedConnection('globalTimeline');
}
},
endpoint(): string {
switch (this.src) {
case 'home': return 'notes/timeline';
case 'local': return 'notes/local-timeline';
case 'hybrid': return 'notes/hybrid-timeline';
case 'global': return 'notes/global-timeline';
}
},
},
watch: {
mediaOnly() {
this.fetch();
}
},
mounted() {
this.connection = this.stream;
this.connection.on('note', this.onNote);
if (this.src == 'home') {
this.connection.on('follow', this.onChangeFollowing);
this.connection.on('unfollow', this.onChangeFollowing);
}
this.fetch();
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
fetch() {
this.fetching = true;
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api(this.endpoint, {
limit: fetchLimit + 1,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
this.$emit('loaded');
}, rej);
}));
},
more() {
this.moreFetching = true;
const promise = this.$root.api(this.endpoint, {
limit: fetchLimit + 1,
withFiles: this.mediaOnly,
untilId: (this.$refs.timeline as any).tail().id,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
});
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
},
onNote(note) {
if (this.mediaOnly && note.files.length == 0) return;
// Prepend a note
(this.$refs.timeline as any).prepend(note);
},
onChangeFollowing() {
this.fetch();
},
focus() {
(this.$refs.timeline as any).focus();
}
}
});
</script>

View file

@ -1,505 +0,0 @@
<template>
<x-column>
<span slot="header">
<fa icon="user"/><mk-user-name :user="user" v-if="user"/>
</span>
<div class="zubukjlciycdsyynicqrnlsmdwmymzqu" v-if="user">
<div class="is-remote" v-if="user.host != null">
<details>
<summary><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}</summary>
<a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a>
</details>
</div>
<header :style="bannerStyle">
<div>
<button class="menu" @click="menu" ref="menu"><fa icon="ellipsis-h"/></button>
<mk-follow-button v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" class="follow" mini/>
<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
<span class="name">
<mk-user-name :user="user"/>
</span>
<span class="acct">@{{ user | acct }} <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/></span>
<span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span>
</div>
</header>
<div class="info">
<div class="description">
<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
</div>
<div class="fields" v-if="user.fields">
<dl class="field" v-for="(field, i) in user.fields" :key="i">
<dt class="name">
<mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/>
</dt>
<dd class="value">
<mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
</dd>
</dl>
</div>
<div class="counts">
<div>
<b>{{ user.notesCount | number }}</b>
<span>{{ $t('posts') }}</span>
</div>
<div>
<b>{{ user.followingCount | number }}</b>
<span>{{ $t('following') }}</span>
</div>
<div>
<b>{{ user.followersCount | number }}</b>
<span>{{ $t('followers') }}</span>
</div>
</div>
</div>
<div class="pinned" v-if="user.pinnedNotes && user.pinnedNotes.length > 0">
<p class="caption" @click="toggleShowPinned"><fa icon="thumbtack"/> {{ $t('pinned-notes') }}</p>
<span class="angle" v-if="showPinned"><fa icon="angle-up"/></span>
<span class="angle" v-else><fa icon="angle-down"/></span>
<div class="notes" v-show="showPinned">
<x-note v-for="n in user.pinnedNotes" :key="n.id" :note="n" :mini="true"/>
</div>
</div>
<div class="images" v-if="images.length > 0">
<p class="caption" @click="toggleShowImages"><fa :icon="['far', 'images']"/> {{ $t('images') }}</p>
<span class="angle" v-if="showImages"><fa icon="angle-up"/></span>
<span class="angle" v-else><fa icon="angle-down"/></span>
<div v-show="showImages">
<router-link v-for="image in images"
:style="`background-image: url(${image.thumbnailUrl})`"
:key="`${image.id}:${image._note.id}`"
:to="image._note | notePage"
:title="`${image.name}\n${(new Date(image.createdAt)).toLocaleString()}`"
></router-link>
</div>
</div>
<div class="activity">
<p class="caption" @click="toggleShowActivity"><fa :icon="['far', 'chart-bar']"/> {{ $t('activity') }}</p>
<span class="angle" v-if="showActivity"><fa icon="angle-up"/></span>
<span class="angle" v-else><fa icon="angle-down"/></span>
<div v-show="showActivity">
<div ref="chart"></div>
</div>
</div>
<div class="tl">
<p class="caption"><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</p>
<div>
<x-notes ref="timeline" :more="existMore ? fetchMoreNotes : null"/>
</div>
</div>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import parseAcct from '../../../../../../misc/acct/parse';
import XColumn from './deck.column.vue';
import XNotes from './deck.notes.vue';
import XNote from '../../components/note.vue';
import XUserMenu from '../../../../common/views/components/user-menu.vue';
import { concat } from '../../../../../../prelude/array';
import ApexCharts from 'apexcharts';
const fetchLimit = 10;
export default Vue.extend({
i18n: i18n('deck/deck.user-column.vue'),
components: {
XColumn,
XNotes,
XNote
},
props: {
acct: {
type: String,
required: true
}
},
data() {
return {
user: null,
fetching: true,
existMore: false,
moreFetching: false,
withFiles: false,
images: [],
showPinned: true,
showImages: true,
showActivity: true
};
},
computed: {
bannerStyle(): any {
if (this.user == null) return {};
if (this.user.bannerUrl == null) return {};
return {
backgroundColor: this.user.bannerColor && this.user.bannerColor.length == 3 ? `rgb(${ this.user.bannerColor.join(',') })` : null,
backgroundImage: `url(${ this.user.bannerUrl })`
};
},
},
created() {
this.$root.api('users/show', parseAcct(this.acct)).then(user => {
this.user = user;
this.fetching = false;
this.$nextTick(() => {
(this.$refs.timeline as any).init(() => this.initTl());
});
const image = [
'image/jpeg',
'image/png',
'image/gif'
];
this.$root.api('users/notes', {
userId: this.user.id,
fileType: image,
excludeNsfw: !this.$store.state.device.alwaysShowNsfw,
limit: 9,
untilDate: new Date().getTime() + 1000 * 86400 * 365
}).then(notes => {
for (const note of notes) {
for (const file of note.files) {
file._note = note;
}
}
const files = concat(notes.map((n: any): any[] => n.files));
this.images = files.filter(f => image.includes(f.type)).slice(0, 9);
});
this.$root.api('charts/user/notes', {
userId: this.user.id,
span: 'day',
limit: 21
}).then(stats => {
const normal = [];
const reply = [];
const renote = [];
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
for (let i = 0; i < 21; i++) {
const x = new Date(y, m, d - i);
normal.push([
x,
stats.diffs.normal[i]
]);
reply.push([
x,
stats.diffs.reply[i]
]);
renote.push([
x,
stats.diffs.renote[i]
]);
}
const chart = new ApexCharts(this.$refs.chart, {
chart: {
type: 'bar',
stacked: true,
height: 100,
sparkline: {
enabled: true
},
},
plotOptions: {
bar: {
columnWidth: '90%'
}
},
grid: {
clipMarkers: false,
padding: {
top: 16,
right: 16,
bottom: 16,
left: 16
}
},
tooltip: {
shared: true,
intersect: false
},
series: [{
name: 'Normal',
data: normal
}, {
name: 'Reply',
data: reply
}, {
name: 'Renote',
data: renote
}],
xaxis: {
type: 'datetime',
crosshairs: {
width: 1,
opacity: 1
}
}
});
chart.render();
});
});
},
methods: {
initTl() {
return new Promise((res, rej) => {
this.$root.api('users/notes', {
userId: this.user.id,
limit: fetchLimit + 1,
untilDate: new Date().getTime() + 1000 * 86400 * 365,
withFiles: this.withFiles,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
}, rej);
});
},
fetchMoreNotes() {
this.moreFetching = true;
const promise = this.$root.api('users/notes', {
userId: this.user.id,
limit: fetchLimit + 1,
untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime(),
withFiles: this.withFiles,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
});
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) (this.$refs.timeline as any).append(n);
this.moreFetching = false;
});
return promise;
},
menu() {
this.$root.new(XUserMenu, {
source: this.$refs.menu,
user: this.user
});
},
toggleShowPinned() {
this.showPinned = !this.showPinned;
},
toggleShowImages() {
this.showImages = !this.showImages;
},
toggleShowActivity() {
this.showActivity = !this.showActivity;
}
}
});
</script>
<style lang="stylus" scoped>
.zubukjlciycdsyynicqrnlsmdwmymzqu
background var(--deckColumnBg)
> .is-remote
padding 8px 16px
font-size 12px
&.is-remote
color var(--remoteInfoFg)
background var(--remoteInfoBg)
> a
font-weight bold
> header
overflow hidden
background-size cover
background-position center
> div
padding 32px
background rgba(#000, 0.5)
color #fff
text-align center
> .menu
position absolute
top 8px
left 8px
padding 8px
font-size 16px
text-shadow 0 0 8px #000
> .follow
position absolute
top 16px
right 16px
> .avatar
display block
width 64px
height 64px
margin 0 auto
> .name
display block
margin-top 8px
font-weight bold
text-shadow 0 0 8px #000
> .acct
display block
font-size 14px
opacity 0.7
text-shadow 0 0 8px #000
> .locked
opacity 0.8
> .followed
display inline-block
font-size 12px
background rgba(0, 0, 0, 0.5)
opacity 0.7
margin-top: 2px
padding 4px
border-radius 4px
> .info
padding 16px
font-size 12px
color var(--text)
text-align center
background var(--face)
&:before
content ""
display blcok
position absolute
top -32px
left 0
right 0
width 0px
margin 0 auto
border-top solid 16px transparent
border-left solid 16px transparent
border-right solid 16px transparent
border-bottom solid 16px var(--face)
> .fields
margin-top 8px
> .field
display flex
padding 0
margin 0
align-items center
> .name
padding 4px
margin 4px
width 30%
overflow hidden
white-space nowrap
text-overflow ellipsis
font-weight bold
> .value
padding 4px
margin 4px
width 70%
overflow hidden
white-space nowrap
text-overflow ellipsis
> .counts
display grid
grid-template-columns 2fr 2fr 2fr
margin-top 8px
border-top solid var(--lineWidth) var(--faceDivider)
> div
padding 8px 8px 0 8px
text-align center
> b
display block
font-size 110%
> span
display block
font-size 80%
opacity 0.7
> *
> p.caption
margin 0
padding 8px 16px
font-size 12px
color var(--text)
& + .angle
position absolute
top 0
right 8px
padding 6px
font-size 14px
color var(--text)
> .pinned
> .notes
background var(--face)
> .images
> div
display grid
grid-template-columns 1fr 1fr 1fr
gap 8px
padding 16px
background var(--face)
> *
height 70px
background-position center center
background-size cover
background-clip content-box
border-radius 4px
> .activity
> div
background var(--face)
> .tl
> div
background var(--face)
</style>

View file

@ -1,433 +0,0 @@
<template>
<mk-ui :class="$style.root">
<div class="qlvquzbjribqcaozciifydkngcwtyzje" ref="body" :style="style" :class="`${$store.state.device.deckColumnAlign} ${$store.state.device.deckColumnWidth}`" v-hotkey.global="keymap">
<template v-for="ids in layout">
<div v-if="ids.length > 1" class="folder">
<template v-for="id, i in ids">
<x-column-core :ref="id" :key="id" :column="columns.find(c => c.id == id)" :is-stacked="true" @parentFocus="moveFocus(id, $event)"/>
</template>
</div>
<x-column-core v-else :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id == ids[0])" @parentFocus="moveFocus(ids[0], $event)"/>
</template>
<template v-if="temporaryColumn">
<x-user-column v-if="temporaryColumn.type == 'user'" :acct="temporaryColumn.acct" :key="temporaryColumn.acct"/>
<x-note-column v-else-if="temporaryColumn.type == 'note'" :note-id="temporaryColumn.noteId" :key="temporaryColumn.noteId"/>
<x-hashtag-column v-else-if="temporaryColumn.type == 'tag'" :tag="temporaryColumn.tag" :key="temporaryColumn.tag"/>
</template>
<button ref="add" @click="add" :title="$t('@deck.add-column')"><fa icon="plus"/></button>
</div>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XColumnCore from './deck.column-core.vue';
import Menu from '../../../../common/views/components/menu.vue';
import MkUserListsWindow from '../../components/user-lists-window.vue';
import * as uuid from 'uuid';
export default Vue.extend({
i18n: i18n('deck'),
components: {
XColumnCore,
XUserColumn: () => import('./deck.user-column.vue').then(m => m.default),
XNoteColumn: () => import('./deck.note-column.vue').then(m => m.default),
XHashtagColumn: () => import('./deck.hashtag-column.vue').then(m => m.default)
},
computed: {
columns(): any[] {
if (this.$store.state.settings.deck == null) return [];
return this.$store.state.settings.deck.columns;
},
layout(): any[] {
if (this.$store.state.settings.deck == null) return [];
if (this.$store.state.settings.deck.layout == null) return this.$store.state.settings.deck.columns.map(c => [c.id]);
return this.$store.state.settings.deck.layout;
},
style(): any {
return {
height: `calc(100vh - ${this.$store.state.uiHeaderHeight}px)`
};
},
temporaryColumn(): any {
return this.$store.state.device.deckTemporaryColumn;
},
keymap(): any {
return {
't': this.focus
};
}
},
watch: {
temporaryColumn() {
if (this.temporaryColumn != null) {
this.$nextTick(() => {
this.$refs.body.scrollTo({
left: this.$refs.body.scrollWidth - this.$refs.body.clientWidth,
behavior: 'smooth'
});
});
}
}
},
provide() {
return {
getColumnVm: this.getColumnVm
};
},
created() {
this.$store.commit('navHook', this.onNav);
if (this.$store.state.settings.deck == null) {
const deck = {
columns: [/*{
type: 'widgets',
widgets: []
}, */{
id: uuid(),
type: 'home'
}, {
id: uuid(),
type: 'notifications'
}, {
id: uuid(),
type: 'local'
}, {
id: uuid(),
type: 'global'
}]
};
deck.layout = deck.columns.map(c => [c.id]);
this.$store.dispatch('settings/set', {
key: 'deck',
value: deck
});
}
//
if (this.$store.state.settings.deck != null && this.$store.state.settings.deck.layout == null) {
this.$store.dispatch('settings/set', {
key: 'deck',
value: Object.assign({}, this.$store.state.settings.deck, {
layout: this.$store.state.settings.deck.columns.map(c => [c.id])
})
});
}
},
mounted() {
document.title = this.$root.instanceName;
document.documentElement.style.overflow = 'hidden';
},
beforeDestroy() {
this.$store.commit('navHook', null);
document.documentElement.style.overflow = 'auto';
},
methods: {
getColumnVm(id) {
return this.$refs[id][0];
},
onNav(to) {
if (!this.$store.state.settings.deckNav) return false;
if (to.name == 'user') {
this.$store.commit('device/set', {
key: 'deckTemporaryColumn',
value: {
type: 'user',
acct: to.params.user
}
});
return true;
} else if (to.name == 'note') {
this.$store.commit('device/set', {
key: 'deckTemporaryColumn',
value: {
type: 'note',
noteId: to.params.note
}
});
return true;
} else if (to.name == 'tag') {
this.$store.commit('device/set', {
key: 'deckTemporaryColumn',
value: {
type: 'tag',
tag: to.params.tag
}
});
return true;
}
},
add() {
this.$root.new(Menu, {
source: this.$refs.add,
items: [{
icon: 'home',
text: this.$t('@deck.home'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'home'
});
}
}, {
icon: ['far', 'comments'],
text: this.$t('@deck.local'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'local'
});
}
}, {
icon: 'share-alt',
text: this.$t('@deck.hybrid'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'hybrid'
});
}
}, {
icon: 'globe',
text: this.$t('@deck.global'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'global'
});
}
}, {
icon: 'at',
text: this.$t('@deck.mentions'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'mentions'
});
}
}, {
icon: ['far', 'envelope'],
text: this.$t('@deck.direct'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'direct'
});
}
}, {
icon: 'list',
text: this.$t('@deck.list'),
action: () => {
const w = this.$root.new(MkUserListsWindow);
w.$once('choosen', list => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'list',
list: list
});
w.close();
});
}
}, {
icon: 'hashtag',
text: this.$t('@deck.hashtag'),
action: () => {
this.$root.dialog({
title: this.$t('enter-hashtag-tl-title'),
input: true
}).then(({ canceled, result: title }) => {
if (canceled) return;
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'hashtag',
tagTlId: this.$store.state.settings.tagTimelines.find(x => x.title == title).id
});
});
}
}, {
icon: ['far', 'bell'],
text: this.$t('@deck.notifications'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'notifications'
});
}
}, {
icon: 'calculator',
text: this.$t('@deck.widgets'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'widgets',
widgets: []
});
}
}]
});
},
focus() {
// Flatten array of arrays
const ids = [].concat.apply([], this.layout);
const firstTl = ids.find(id => this.isTlColumn(id));
if (firstTl) {
this.$refs[firstTl][0].focus();
}
},
moveFocus(id, direction) {
let targetColumn;
if (direction == 'right') {
const currentColumnIndex = this.layout.findIndex(ids => ids.includes(id));
this.layout.some((ids, i) => {
if (i <= currentColumnIndex) return false;
const tl = ids.find(id => this.isTlColumn(id));
if (tl) {
targetColumn = tl;
return true;
}
});
} else if (direction == 'left') {
const currentColumnIndex = [...this.layout].reverse().findIndex(ids => ids.includes(id));
[...this.layout].reverse().some((ids, i) => {
if (i <= currentColumnIndex) return false;
const tl = ids.find(id => this.isTlColumn(id));
if (tl) {
targetColumn = tl;
return true;
}
});
} else if (direction == 'down') {
const currentColumn = this.layout.find(ids => ids.includes(id));
const currentIndex = currentColumn.indexOf(id);
currentColumn.some((_id, i) => {
if (i <= currentIndex) return false;
if (this.isTlColumn(_id)) {
targetColumn = _id;
return true;
}
});
} else if (direction == 'up') {
const currentColumn = [...this.layout.find(ids => ids.includes(id))].reverse();
const currentIndex = currentColumn.indexOf(id);
currentColumn.some((_id, i) => {
if (i <= currentIndex) return false;
if (this.isTlColumn(_id)) {
targetColumn = _id;
return true;
}
});
}
if (targetColumn) {
this.$refs[targetColumn][0].focus();
}
},
isTlColumn(id) {
const column = this.columns.find(c => c.id === id);
return ['home', 'local', 'hybrid', 'global', 'list', 'hashtag', 'mentions', 'direct'].includes(column.type);
}
}
});
</script>
<style lang="stylus" module>
.root
height 100vh
</style>
<style lang="stylus" scoped>
.qlvquzbjribqcaozciifydkngcwtyzje
display flex
flex 1
padding 16px 0 16px 16px
overflow auto
> div
margin-right 8px
width 330px
min-width 330px
&:last-of-type
margin-right 0
&.folder
display flex
flex-direction column
> *:not(:last-child)
margin-bottom 8px
&.narrow
> div
width 303px
min-width 303px
&.narrower
> div
width 316.5px
min-width 316.5px
&.wider
> div
width 343.5px
min-width 343.5px
&.wide
> div
width 357px
min-width 357px
&.center
> *
&:first-child
margin-left auto
&:last-child
margin-right auto
&.:not(.flexible)
> *
flex-grow 0
flex-shrink 0
&.flexible
> *
flex-grow 1
flex-shrink 0
> button
padding 0 16px
color var(--faceTextButton)
flex-grow 0 !important
&:hover
color var(--faceTextButtonHover)
&:active
color var(--faceTextButtonActive)
</style>

View file

@ -1,172 +0,0 @@
<template>
<x-column :menu="menu" :naked="true" :narrow="true" :name="name" :column="column" :is-stacked="isStacked" class="wtdtxvecapixsepjtcupubtsmometobz">
<span slot="header"><fa icon="calculator"/>{{ name }}</span>
<div class="gqpwvtwtprsbmnssnbicggtwqhmylhnq">
<template v-if="edit">
<header>
<select v-model="widgetAdderSelected" @change="addWidget">
<option value="profile">{{ $t('@.widgets.profile') }}</option>
<option value="analog-clock">{{ $t('@.widgets.analog-clock') }}</option>
<option value="calendar">{{ $t('@.widgets.calendar') }}</option>
<option value="timemachine">{{ $t('@.widgets.timemachine') }}</option>
<option value="activity">{{ $t('@.widgets.activity') }}</option>
<option value="rss">{{ $t('@.widgets.rss') }}</option>
<option value="trends">{{ $t('@.widgets.trends') }}</option>
<option value="photo-stream">{{ $t('@.widgets.photo-stream') }}</option>
<option value="slideshow">{{ $t('@.widgets.slideshow') }}</option>
<option value="version">{{ $t('@.widgets.version') }}</option>
<option value="broadcast">{{ $t('@.widgets.broadcast') }}</option>
<option value="notifications">{{ $t('@.widgets.notifications') }}</option>
<option value="users">{{ $t('@.widgets.users') }}</option>
<option value="polls">{{ $t('@.widgets.polls') }}</option>
<option value="post-form">{{ $t('@.widgets.post-form') }}</option>
<option value="messaging">{{ $t('@.widgets.messaging') }}</option>
<option value="memo">{{ $t('@.widgets.memo') }}</option>
<option value="hashtags">{{ $t('@.widgets.hashtags') }}</option>
<option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option>
<option value="server">{{ $t('@.widgets.server') }}</option>
<option value="nav">{{ $t('@.widgets.nav') }}</option>
<option value="tips">{{ $t('@.widgets.tips') }}</option>
</select>
</header>
<x-draggable
:list="column.widgets"
:options="{ animation: 150 }"
@sort="onWidgetSort"
>
<div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="widgetFunc(widget.id)">
<button class="remove" @click="removeWidget(widget)"><fa icon="times"/></button>
<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="deck"/>
</div>
</x-draggable>
</template>
<template v-else>
<component class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="deck"/>
</template>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XColumn from './deck.column.vue';
import * as XDraggable from 'vuedraggable';
import * as uuid from 'uuid';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XDraggable
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
edit: false,
menu: null,
widgetAdderSelected: null
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
return this.$t('@deck.widgets');
}
},
created() {
this.menu = [{
icon: 'cog',
text: this.$t('edit'),
action: () => {
this.edit = !this.edit;
}
}];
},
methods: {
widgetFunc(id) {
const w = this.$refs[id][0];
if (w.func) w.func();
},
onWidgetSort() {
this.saveWidgets();
},
addWidget() {
this.$store.dispatch('settings/addDeckWidget', {
id: this.column.id,
widget: {
name: this.widgetAdderSelected,
id: uuid(),
data: {}
}
});
this.widgetAdderSelected = null;
},
removeWidget(widget) {
this.$store.dispatch('settings/removeDeckWidget', {
id: this.column.id,
widget
});
},
saveWidgets() {
this.$store.dispatch('settings/saveDeck');
}
}
});
</script>
<style lang="stylus" scoped>
.wtdtxvecapixsepjtcupubtsmometobz
.gqpwvtwtprsbmnssnbicggtwqhmylhnq
> header
padding 16px
> *
width 100%
padding 4px
.widget, .customize-container
margin 8px
&:first-of-type
margin-top 0
.customize-container
cursor move
> *:not(.remove)
pointer-events none
> .remove
position absolute
z-index 1
top 8px
right 8px
width 32px
height 32px
color #fff
background rgba(#000, 0.7)
border-radius 4px
</style>

View file

@ -1,87 +0,0 @@
<template>
<mk-ui>
<main v-if="!fetching">
<sequential-entrance animation="entranceFromTop" delay="25">
<template v-for="favorite in favorites">
<mk-note-detail class="post" :note="favorite.note" :key="favorite.note.id"/>
</template>
</sequential-entrance>
<div class="more" v-if="existMore">
<ui-button inline @click="more">{{ $t('@.load-more') }}</ui-button>
</div>
</main>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Progress from '../../../common/scripts/loading';
export default Vue.extend({
i18n: i18n('.vue'),
data() {
return {
fetching: true,
favorites: [],
existMore: false,
moreFetching: false
};
},
created() {
this.fetch();
},
methods: {
fetch() {
Progress.start();
this.fetching = true;
this.$root.api('i/favorites', {
limit: 11
}).then(favorites => {
if (favorites.length == 11) {
this.existMore = true;
favorites.pop();
}
this.favorites = favorites;
this.fetching = false;
Progress.done();
});
},
more() {
this.moreFetching = true;
this.$root.api('i/favorites', {
limit: 11,
untilId: this.favorites[this.favorites.length - 1].id
}).then(favorites => {
if (favorites.length == 11) {
this.existMore = true;
favorites.pop();
} else {
this.existMore = false;
}
this.favorites = this.favorites.concat(favorites);
this.moreFetching = false;
});
}
}
});
</script>
<style lang="stylus" scoped>
main
margin 0 auto
padding 16px
max-width 700px
> * > .post
margin-bottom 16px
> .more
margin 32px 16px 16px 16px
text-align center
</style>

View file

@ -1,3 +0,0 @@
<template>
<mk-home customize/>
</template>

View file

@ -1,39 +0,0 @@
<template>
<mk-ui>
<mk-home :mode="mode" @loaded="loaded" ref="home" v-hotkey.global="keymap"/>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
import Progress from '../../../common/scripts/loading';
export default Vue.extend({
props: {
mode: {
type: String,
default: 'timeline'
}
},
computed: {
keymap(): any {
return {
't': this.focus
};
}
},
mounted() {
document.title = this.$root.instanceName;
Progress.start();
},
methods: {
loaded() {
Progress.done();
},
focus() {
this.$refs.home.focus();
}
}
});
</script>

View file

@ -1,25 +0,0 @@
<template>
<component :is="page"></component>
</template>
<script lang="ts">
import Vue from 'vue';
import Home from './home.vue';
import Welcome from './welcome.vue';
import Deck from './deck/deck.vue';
export default Vue.extend({
components: {
Home,
Deck,
Welcome
},
computed: {
page(): string {
if (!this.$store.getters.isSignedIn) return 'welcome';
return this.$store.state.device.deckDefault ? 'deck' : 'home';
}
}
});
</script>

View file

@ -1,66 +0,0 @@
<template>
<mk-ui>
<main v-if="!fetching">
<mk-note-detail :note="note"/>
<footer>
<router-link v-if="note.next" :to="note.next"><fa icon="angle-left"/> {{ $t('next') }}</router-link>
<router-link v-if="note.prev" :to="note.prev">{{ $t('prev') }} <fa icon="angle-right"/></router-link>
</footer>
</main>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Progress from '../../../common/scripts/loading';
export default Vue.extend({
i18n: i18n('desktop/views/pages/note.vue'),
data() {
return {
fetching: true,
note: null
};
},
watch: {
$route: 'fetch'
},
created() {
this.fetch();
},
methods: {
fetch() {
Progress.start();
this.fetching = true;
this.$root.api('notes/show', {
noteId: this.$route.params.note
}).then(note => {
this.note = note;
this.fetching = false;
Progress.done();
});
}
}
});
</script>
<style lang="stylus" scoped>
main
padding 16px
text-align center
> footer
margin-top 16px
> a
display inline-block
margin 0 16px
> .mk-note-detail
margin 0 auto
width 640px
</style>

View file

@ -1,126 +0,0 @@
<template>
<mk-ui>
<header :class="$style.header">
<h1>#{{ $route.params.tag }}</h1>
</header>
<p :class="$style.empty" v-if="!fetching && empty"><fa icon="search"/> {{ $t('no-posts-found', { q: $route.params.tag }) }}</p>
<mk-notes ref="timeline" :class="$style.notes" :more="existMore ? more : null"/>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Progress from '../../../common/scripts/loading';
const limit = 20;
export default Vue.extend({
i18n: i18n('desktop/views/pages/tag.vue'),
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
offset: 0,
empty: false
};
},
watch: {
$route: 'fetch'
},
mounted() {
document.addEventListener('keydown', this.onDocumentKeydown);
window.addEventListener('scroll', this.onScroll, { passive: true });
this.fetch();
},
beforeDestroy() {
document.removeEventListener('keydown', this.onDocumentKeydown);
window.removeEventListener('scroll', this.onScroll);
},
methods: {
onDocumentKeydown(e) {
if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
if (e.which == 84) { // t
(this.$refs.timeline as any).focus();
}
}
},
fetch() {
this.fetching = true;
Progress.start();
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api('notes/search_by_tag', {
limit: limit + 1,
offset: this.offset,
tag: this.$route.params.tag
}).then(notes => {
if (notes.length == 0) this.empty = true;
if (notes.length == limit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
Progress.done();
}, rej);
}));
},
more() {
this.offset += limit;
const promise = this.$root.api('notes/search_by_tag', {
limit: limit + 1,
offset: this.offset,
tag: this.$route.params.tag
});
promise.then(notes => {
if (notes.length == limit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
}
}
});
</script>
<style lang="stylus" module>
.header
width 100%
max-width 600px
margin 0 auto
color #555
.notes
width 600px
margin 0 auto
border solid 1px rgba(#000, 0.075)
border-radius 6px
overflow hidden
.empty
display block
margin 0 auto
padding 32px
max-width 400px
text-align center
color #999
> [data-icon]
display block
margin-bottom 16px
font-size 3em
color #ccc
</style>

View file

@ -1,84 +0,0 @@
<template>
<div class="vahgrswmbzfdlmomxnqftuueyvwaafth">
<p class="title"><fa icon="users"/>{{ $t('title') }}</p>
<p class="initializing" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('loading') }}<mk-ellipsis/></p>
<div v-if="!fetching && users.length > 0">
<router-link v-for="user in users" :to="user | userPage" :key="user.id">
<img :src="user.avatarUrl" :alt="user | userName" v-user-preview="user.id"/>
</router-link>
</div>
<p class="empty" v-if="!fetching && users.length == 0">{{ $t('no-users') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.followers-you-know.vue'),
props: ['user'],
data() {
return {
users: [],
fetching: true
};
},
mounted() {
this.$root.api('users/followers', {
userId: this.user.id,
iknow: true,
limit: 16
}).then(x => {
this.users = x.users;
this.fetching = false;
});
}
});
</script>
<style lang="stylus" scoped>
.vahgrswmbzfdlmomxnqftuueyvwaafth
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
> .title
z-index 1
margin 0
padding 0 16px
line-height 42px
font-size 0.9em
font-weight bold
color var(--faceHeaderText)
box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
> div
padding 8px
> a
display inline-block
margin 4px
> img
display inline-block
text-align center
width 48px
height 48px
vertical-align bottom
border-radius 100%
> .initializing
> .empty
margin 0
padding 16px
text-align center
color var(--text)
> i
margin-right 4px
</style>

View file

@ -1,112 +0,0 @@
<template>
<div class="hozptpaliadatkehcmcayizwzwwctpbc">
<p class="title"><fa icon="users"/>{{ $t('title') }}</p>
<p class="initializing" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('loading') }}<mk-ellipsis/></p>
<template v-if="!fetching && users.length != 0">
<div class="user" v-for="friend in users">
<mk-avatar class="avatar" :user="friend"/>
<div class="body">
<router-link class="name" :to="friend | userPage" v-user-preview="friend.id"><mk-user-name :user="friend"/></router-link>
<p class="username">@{{ friend | acct }}</p>
</div>
</div>
</template>
<p class="empty" v-if="!fetching && users.length == 0">{{ $t('no-users') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.friends.vue'),
props: ['user'],
data() {
return {
users: [],
fetching: true
};
},
mounted() {
this.$root.api('users/get_frequently_replied_users', {
userId: this.user.id,
limit: 4
}).then(docs => {
this.users = docs.map(doc => doc.user);
this.fetching = false;
});
}
});
</script>
<style lang="stylus" scoped>
.hozptpaliadatkehcmcayizwzwwctpbc
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
overflow hidden
> .title
z-index 1
margin 0
padding 0 16px
line-height 42px
font-size 0.9em
font-weight bold
background var(--faceHeader)
color var(--faceHeaderText)
box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
> .initializing
> .empty
margin 0
padding 16px
text-align center
color var(--text)
> i
margin-right 4px
> .user
padding 16px
border-bottom solid 1px var(--faceDivider)
&:last-child
border-bottom none
&:after
content ""
display block
clear both
> .avatar
display block
float left
margin 0 12px 0 0
width 42px
height 42px
border-radius 8px
> .body
float left
width calc(100% - 54px)
> .name
margin 0
font-size 16px
line-height 24px
color var(--text)
> .username
display block
margin 0
font-size 15px
line-height 16px
color var(--text)
opacity 0.7
</style>

View file

@ -1,253 +0,0 @@
<template>
<div class="header" :data-is-dark-background="user.bannerUrl != null">
<div class="banner-container" :style="style">
<div class="banner" ref="banner" :style="style" @click="onBannerClick"></div>
<div class="fade"></div>
<div class="title">
<p class="name">
<mk-user-name :user="user"/>
</p>
<div>
<span class="username"><mk-acct :user="user" :detail="true" /></span>
<span v-if="user.isBot" :title="$t('is-bot')"><fa icon="robot"/></span>
</div>
</div>
</div>
<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
<div class="body">
<div class="description">
<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
</div>
<div class="fields" v-if="user.fields">
<dl class="field" v-for="(field, i) in user.fields" :key="i">
<dt class="name">
<mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/>
</dt>
<dd class="value">
<mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
</dd>
</dl>
</div>
<div class="info">
<span class="location" v-if="user.host === null && user.profile.location"><fa icon="map-marker"/> {{ user.profile.location }}</span>
<span class="birthday" v-if="user.host === null && user.profile.birthday"><fa icon="birthday-cake"/> {{ user.profile.birthday.replace('-', $t('year')).replace('-', $t('month')) + $t('day') }} ({{ $t('years-old', { age }) }})</span>
</div>
<div class="status">
<span class="notes-count"><b>{{ user.notesCount | number }}</b>{{ $t('posts') }}</span>
<router-link :to="user | userPage('following')" class="following clickable"><b>{{ user.followingCount | number }}</b>{{ $t('following') }}</router-link>
<router-link :to="user | userPage('followers')" class="followers clickable"><b>{{ user.followersCount | number }}</b>{{ $t('followers') }}</router-link>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import * as age from 's-age';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.header.vue'),
props: ['user'],
computed: {
style(): any {
if (this.user.bannerUrl == null) return {};
return {
backgroundColor: this.user.bannerColor && this.user.bannerColor.length == 3 ? `rgb(${ this.user.bannerColor.join(',') })` : null,
backgroundImage: `url(${ this.user.bannerUrl })`
};
},
age(): number {
return age(this.user.profile.birthday);
}
},
mounted() {
if (this.user.bannerUrl) {
//window.addEventListener('load', this.onScroll);
//window.addEventListener('scroll', this.onScroll, { passive: true });
//window.addEventListener('resize', this.onScroll);
}
},
beforeDestroy() {
if (this.user.bannerUrl) {
//window.removeEventListener('load', this.onScroll);
//window.removeEventListener('scroll', this.onScroll);
//window.removeEventListener('resize', this.onScroll);
}
},
methods: {
mention() {
this.$post({ mention: this.user });
},
onScroll() {
const banner = this.$refs.banner as any;
const top = window.scrollY;
const z = 1.25; // ()
const pos = -(top / z);
banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
const blur = top / 32
if (blur <= 10) banner.style.filter = `blur(${blur}px)`;
},
onBannerClick() {
if (!this.$store.getters.isSignedIn || this.$store.state.i.id != this.user.id) return;
this.$updateBanner().then(i => {
this.user.bannerUrl = i.bannerUrl;
});
}
}
});
</script>
<style lang="stylus" scoped>
.header
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
overflow hidden
&[data-is-dark-background]
> .banner-container
> .banner
background-color #383838
> .fade
background linear-gradient(transparent, rgba(#000, 0.7))
> .title
color #fff
> .name
text-shadow 0 0 8px #000
> .banner-container
height 250px
overflow hidden
background-size cover
background-position center
> .banner
height 100%
background-color #bfccd0
background-size cover
background-position center
> .fade
position absolute
bottom 0
left 0
width 100%
height 78px
> .title
position absolute
bottom 0
left 0
width 100%
padding 0 0 8px 154px
color #5e6367
> .name
display block
margin 0
line-height 32px
font-weight bold
font-size 1.8em
> div
> *
display inline-block
margin-right 16px
line-height 20px
opacity 0.8
&.username
font-weight bold
> .avatar
display block
position absolute
top 170px
left 16px
z-index 2
width 120px
height 120px
box-shadow 1px 1px 3px rgba(#000, 0.2)
> &.cat::before,
> &.cat::after
border-width 8px
> .body
padding 16px 16px 16px 154px
color var(--text)
> .fields
margin-top 16px
> .field
display flex
padding 0
margin 0
align-items center
> .name
border-right solid 1px var(--faceDivider)
padding 4px
margin 4px
width 30%
overflow hidden
white-space nowrap
text-overflow ellipsis
font-weight bold
text-align center
> .value
padding 4px
margin 4px
width 70%
overflow hidden
white-space nowrap
text-overflow ellipsis
> .info
margin-top 16px
padding-top 16px
border-top solid 1px var(--faceDivider)
> *
margin-right 16px
> .status
margin-top 16px
padding-top 16px
border-top solid 1px var(--faceDivider)
font-size 80%
> *
display inline-block
padding-right 16px
margin-right 16px
color inherit
&:not(:last-child)
border-right solid 1px var(--faceDivider)
&.clickable
cursor pointer
&:hover
color var(--faceTextButtonHover)
> b
margin-right 4px
font-size 1rem
font-weight bold
color var(--primary)
</style>

View file

@ -1,14 +0,0 @@
<template>
<a :href="url" :class="service" target="_blank">
<fa :icon="icon" size="lg" fixed-width />
<div>{{ text }}</div>
</a>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['url', 'text', 'icon', 'service']
});
</script>

View file

@ -1,63 +0,0 @@
<template>
<div class="usertwitxxxgithxxdiscxxxintegrat" :v-if="user.twitter || user.github || user.discord">
<x-integration v-if="user.twitter" service="twitter" :url="`https://twitter.com/${user.twitter.screenName}`" :text="user.twitter.screenName" :icon="['fab', 'twitter']"/>
<x-integration v-if="user.github" service="github" :url="`https://github.com/${user.github.login}`" :text="user.github.login" :icon="['fab', 'github']"/>
<x-integration v-if="user.discord" service="discord" :url="`https://discordapp.com/users/${user.discord.id}`" :text="`${user.discord.username}#${user.discord.discriminator}`" :icon="['fab', 'discord']"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XIntegration from './user.integrations.integration.vue';
export default Vue.extend({
components: {
XIntegration
},
props: ['user']
});
</script>
<style lang="stylus" scoped>
.usertwitxxxgithxxdiscxxxintegrat
> a
display flex
align-items center
padding 32px 38px
box-shadow var(--shadow)
border-radius var(--round)
&:not(:last-child)
margin-bottom 16px
&:hover
text-decoration none
> div
padding-left .2em
line-height 1.3em
flex 1 0
word-wrap anywhere
&.twitter
color #fff
background #1da1f3
&:hover
background #0c87cf
&.github
color #fff
background #171515
&:hover
background #000
&.discord
color #fff
background #7289da
&:hover
background #4968ce
</style>

View file

@ -1,106 +0,0 @@
<template>
<div class="dzsuvbsrrrwobdxifudxuefculdfiaxd">
<p class="title"><fa icon="camera"/>{{ $t('title') }}</p>
<p class="initializing" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('loading') }}<mk-ellipsis/></p>
<div class="stream" v-if="!fetching && images.length > 0">
<div v-for="(image, i) in images" :key="i" class="img"
:style="`background-image: url(${thumbnail(image)})`"
></div>
</div>
<p class="empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { getStaticImageUrl } from '../../../../common/scripts/get-static-image-url';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.photos.vue'),
props: ['user'],
data() {
return {
images: [],
fetching: true
};
},
mounted() {
const image = [
'image/jpeg',
'image/png',
'image/gif'
];
this.$root.api('users/notes', {
userId: this.user.id,
fileType: image,
excludeNsfw: !this.$store.state.device.alwaysShowNsfw,
limit: 9,
untilDate: new Date().getTime() + 1000 * 86400 * 365
}).then(notes => {
for (const note of notes) {
for (const file of note.files) {
if (this.images.length < 9) this.images.push(file);
}
}
this.fetching = false;
});
},
methods: {
thumbnail(image: any): string {
return this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(image.thumbnailUrl)
: image.thumbnailUrl;
},
},
});
</script>
<style lang="stylus" scoped>
.dzsuvbsrrrwobdxifudxuefculdfiaxd
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
overflow hidden
> .title
z-index 1
margin 0
padding 0 16px
line-height 42px
font-size 0.9em
font-weight bold
background var(--faceHeader)
color var(--faceHeaderText)
box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
> .stream
display flex
justify-content center
flex-wrap wrap
padding 8px
> .img
flex 1 1 33%
width 33%
height 80px
background-position center center
background-size cover
background-clip content-box
border solid 2px transparent
> .initializing
> .empty
margin 0
padding 16px
text-align center
color var(--text)
> i
margin-right 4px
</style>

View file

@ -1,66 +0,0 @@
<template>
<div class="profile" v-if="$store.getters.isSignedIn">
<div class="friend-form" v-if="$store.state.i.id != user.id">
<mk-follow-button :user="user" block/>
<p class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</p>
</div>
<div class="action-form">
<ui-button @click="menu" ref="menu">{{ $t('menu') }}</ui-button>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XUserMenu from '../../../../common/views/components/user-menu.vue';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.profile.vue'),
props: ['user'],
methods: {
menu() {
this.$root.new(XUserMenu, {
source: this.$refs.menu.$el,
user: this.user
});
},
}
});
</script>
<style lang="stylus" scoped>
.profile
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
> *:first-child
border-top none !important
> .friend-form
padding 16px
text-align center
border-bottom solid 1px var(--faceDivider)
> .followed
margin 12px 0 0 0
padding 0
text-align center
line-height 24px
font-size 0.8em
color var(--text)
border-radius 4px
> .action-form
padding 16px
text-align center
> *
width 100%
&:not(:last-child)
margin-bottom 12px
</style>

View file

@ -1,175 +0,0 @@
<template>
<div class="oh5y2r7l5lx8j6jj791ykeiwgihheguk">
<header>
<span :data-active="mode == 'default'" @click="mode = 'default'"><fa :icon="['far', 'comment-alt']"/> {{ $t('default') }}</span>
<span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'"><fa icon="comments"/> {{ $t('with-replies') }}</span>
<span :data-active="mode == 'with-media'" @click="mode = 'with-media'"><fa :icon="['far', 'images']"/> {{ $t('with-media') }}</span>
<span :data-active="mode == 'my-posts'" @click="mode = 'my-posts'"><fa icon="user"/> {{ $t('my-posts') }}</span>
</header>
<mk-notes ref="timeline" :more="existMore ? more : null">
<p class="empty" slot="empty"><fa :icon="['far', 'comments']"/>{{ $t('empty') }}</p>
</mk-notes>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
const fetchLimit = 10;
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.timeline.vue'),
props: ['user'],
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
mode: 'default',
unreadCount: 0,
date: null
};
},
watch: {
mode() {
this.fetch();
}
},
mounted() {
document.addEventListener('keydown', this.onDocumentKeydown);
this.fetch(() => this.$emit('loaded'));
},
beforeDestroy() {
document.removeEventListener('keydown', this.onDocumentKeydown);
},
methods: {
onDocumentKeydown(e) {
if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
if (e.which == 84) { // [t]
(this.$refs.timeline as any).focus();
}
}
},
fetch(cb?) {
this.fetching = true;
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api('users/notes', {
userId: this.user.id,
limit: fetchLimit + 1,
untilDate: this.date ? this.date.getTime() : new Date().getTime() + 1000 * 86400 * 365,
includeReplies: this.mode == 'with-replies',
includeMyRenotes: this.mode != 'my-posts',
withFiles: this.mode == 'with-media'
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
if (cb) cb();
}, rej);
}));
},
more() {
this.moreFetching = true;
const promise = this.$root.api('users/notes', {
userId: this.user.id,
limit: fetchLimit + 1,
includeReplies: this.mode == 'with-replies',
includeMyRenotes: this.mode != 'my-posts',
withFiles: this.mode == 'with-media',
untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime()
});
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
},
warp(date) {
this.date = date;
this.fetch();
}
}
});
</script>
<style lang="stylus" scoped>
.oh5y2r7l5lx8j6jj791ykeiwgihheguk
background var(--face)
border-radius var(--round)
overflow hidden
> header
padding 0 8px
z-index 10
background var(--faceHeader)
box-shadow 0 1px var(--desktopTimelineHeaderShadow)
> span
display inline-block
padding 0 10px
line-height 42px
font-size 12px
user-select none
&[data-active]
color var(--primary)
cursor default
font-weight bold
&:before
content ""
display block
position absolute
bottom 0
left -8px
width calc(100% + 16px)
height 2px
background var(--primary)
&:not([data-active])
color var(--desktopTimelineSrc)
cursor pointer
&:hover
color var(--desktopTimelineSrcHover)
> .mk-notes
> .empty
display block
margin 0 auto
padding 32px
max-width 400px
text-align center
color var(--text)
> [data-icon]
display block
margin-bottom 16px
font-size 3em
color var(--faceHeaderText);
</style>

View file

@ -1,155 +0,0 @@
<template>
<mk-ui>
<div class="xygkxeaeontfaokvqmiblezmhvhostak" v-if="!fetching">
<div class="is-suspended" v-if="user.isSuspended"><fa icon="exclamation-triangle"/> {{ $t('@.user-suspended') }}</div>
<div class="is-remote" v-if="user.host != null"><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}<a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a></div>
<main>
<div class="main">
<x-header :user="user"/>
<mk-note-detail v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/>
<x-timeline class="timeline" ref="tl" :user="user"/>
</div>
<div class="side">
<div class="instance" v-if="!$store.getters.isSignedIn"><mk-instance/></div>
<x-profile :user="user"/>
<x-integrations :user="user"/>
<mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/>
<mk-activity :user="user"/>
<x-photos :user="user"/>
<x-friends :user="user"/>
<x-followers-you-know v-if="$store.getters.isSignedIn && $store.state.i.id != user.id" :user="user"/>
<div class="nav"><mk-nav/></div>
</div>
</main>
</div>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import parseAcct from '../../../../../../misc/acct/parse';
import Progress from '../../../../common/scripts/loading';
import XHeader from './user.header.vue';
import XTimeline from './user.timeline.vue';
import XProfile from './user.profile.vue';
import XPhotos from './user.photos.vue';
import XFollowersYouKnow from './user.followers-you-know.vue';
import XFriends from './user.friends.vue';
import XIntegrations from './user.integrations.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XHeader,
XTimeline,
XProfile,
XPhotos,
XFollowersYouKnow,
XFriends,
XIntegrations
},
data() {
return {
fetching: true,
user: null
};
},
watch: {
$route: 'fetch'
},
created() {
this.fetch();
},
methods: {
fetch() {
this.fetching = true;
Progress.start();
this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
this.user = user;
this.fetching = false;
Progress.done();
});
},
warp(date) {
(this.$refs.tl as any).warp(date);
}
}
});
</script>
<style lang="stylus" scoped>
.xygkxeaeontfaokvqmiblezmhvhostak
max-width 980px
min-width 720px
padding 16px
margin 0 auto
> .is-suspended
> .is-remote
margin-bottom 16px
padding 14px 16px
font-size 14px
box-shadow var(--shadow)
border-radius var(--round)
&.is-suspended
color var(--suspendedInfoFg)
background var(--suspendedInfoBg)
&.is-remote
color var(--remoteInfoFg)
background var(--remoteInfoBg)
> a
font-weight bold
> main
display flex
justify-content center
> .main
> .side
> *:not(:last-child)
margin-bottom 16px
> .main
flex 1
min-width 0 // SEE: http://kudakurage.hatenadiary.com/entry/2016/04/01/232722
margin-right 16px
> .timeline
box-shadow var(--shadow)
> .side
width 275px
flex-shrink 0
> p
display block
margin 0
padding 0 12px
text-align center
font-size 0.8em
color var(--text)
> .instance
box-shadow var(--shadow)
border-radius var(--round)
> .nav
padding 16px
font-size 12px
color var(--text)
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
a
color var(--text)99
i
color var(--text)
</style>