* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
This commit is contained in:
syuilo 2020-07-11 10:13:11 +09:00 committed by GitHub
parent b90de4572f
commit 897463ed85
56 changed files with 2695 additions and 907 deletions

View File

@ -519,6 +519,8 @@ fixedWidgetsPosition: "ウィジェットの位置を固定する"
enablePlayer: "プレイヤーを開く"
disablePlayer: "プレイヤーを閉じる"
expandTweet: "ツイートを展開する"
deck: "デッキ"
undeck: "デッキ解除"
_theme:
explore: "テーマを探す"
@ -651,6 +653,7 @@ _widgets:
rss: "RSSリーダー"
activity: "アクティビティ"
photos: "フォト"
digitalClock: "デジタル時計"
_cw:
hide: "隠す"
@ -1129,3 +1132,15 @@ _notification:
yourFollowRequestAccepted: "フォローリクエストが承認されました"
youWereInvitedToGroup: "グループに招待されました"
_deck:
alwaysShowMainColumn: "常にメインカラムを表示"
columnAlign: "カラムの寄せ"
_columns:
widgets: "ウィジェット"
notifications: "通知"
tl: "タイムライン"
antenna: "アンテナ"
list: "リスト"
mentions: "あなた宛て"
direct: "ダイレクト"

View File

@ -29,47 +29,7 @@
</div>
</header>
<transition name="nav-back">
<div class="nav-back"
v-if="showNav"
@click="showNav = false"
@touchstart="showNav = false"
></div>
</transition>
<transition name="nav">
<nav class="nav" ref="nav" v-show="showNav">
<div>
<button class="item _button account" @click="openAccountMenu" v-if="$store.getters.isSignedIn">
<mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/>
</button>
<button class="item _button index active" @click="top()" v-if="$route.name === 'index'">
<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
</button>
<router-link class="item index" active-class="active" to="/" exact v-else>
<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
</router-link>
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
<component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" @click="() => { if (menuDef[item].action) menuDef[item].action() }" :to="menuDef[item].to">
<fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span>
<i v-if="menuDef[item].indicated"><fa :icon="faCircle"/></i>
</component>
</template>
<div class="divider"></div>
<button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)" @click="oepnInstanceMenu">
<fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span>
</button>
<button class="item _button" @click="more">
<fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span>
<i v-if="otherNavItemIndicated"><fa :icon="faCircle"/></i>
</button>
<router-link class="item" active-class="active" to="/preferences">
<fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span>
</router-link>
</div>
</nav>
</transition>
<x-sidebar ref="nav"/>
<div class="contents" ref="contents" :class="{ wallpaper }">
<main ref="main">
@ -103,20 +63,20 @@
<span class="handle"><fa :icon="faBars"/></span>{{ $t('_widgets.' + widget.name) }}<button class="remove _button" @click="removeWidget(widget)"><fa :icon="faTimes"/></button>
</header>
<div @click="widgetFunc(widget.id)">
<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/>
<component class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/>
</div>
</div>
</x-draggable>
</div>
<div class="container" v-else>
<component class="_widget" v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget"/>
<component v-for="widget in widgets[place]" class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget"/>
</div>
</div>
</template>
</div>
<div class="buttons">
<button class="button nav _button" @click="showNav = true" ref="navButton"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button>
<button class="button nav _button" @click="showNav" ref="navButton"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button>
<button v-if="$route.name === 'index'" class="button home _button" @click="top()"><fa :icon="faHome"/></button>
<button v-else class="button home _button" @click="$router.push('/')"><fa :icon="faHome"/></button>
<button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="$router.push('/my/notifications')"><fa :icon="faBell"/><i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i></button>
@ -135,14 +95,17 @@ import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt,
import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons';
import { ResizeObserver } from '@juggle/resize-observer';
import { v4 as uuid } from 'uuid';
import { host, instanceName } from './config';
import { host } from './config';
import { search } from './scripts/search';
import { StickySidebar } from './scripts/sticky-sidebar';
import { widgets } from './widgets';
import XSidebar from './components/sidebar.vue';
const DESKTOP_THRESHOLD = 1100;
export default Vue.extend({
components: {
XSidebar,
XClock: () => import('./components/header-clock.vue').then(m => m.default),
MkButton: () => import('./components/ui/button.vue').then(m => m.default),
XDraggable: () => import('vuedraggable'),
@ -152,19 +115,14 @@ export default Vue.extend({
return {
host: host,
pageKey: 0,
showNav: false,
searching: false,
accounts: [],
lists: [],
connection: null,
searchQuery: '',
searchWait: false,
widgetsEditMode: false,
menuDef: this.$store.getters.nav({
search: this.search
}),
isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
canBack: false,
menuDef: this.$store.getters.nav({}),
wallpaper: localStorage.getItem('wallpaper') != null,
faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
};
@ -210,30 +168,19 @@ export default Vue.extend({
return this.$store.state.deviceUser.menu;
},
otherNavItemIndicated(): boolean {
if (!this.$store.getters.isSignedIn) return false;
for (const def in this.menuDef) {
if (this.menu.includes(def)) continue;
if (this.menuDef[def].indicated) return true;
}
return false;
},
navIndicated(): boolean {
if (!this.$store.getters.isSignedIn) return false;
for (const def in this.menuDef) {
if (def === 'timeline') continue;
if (def === 'notifications') continue;
if (def === 'notifications') continue; //
if (this.menuDef[def].indicated) return true;
}
return false;
}
},
watch:{
watch: {
$route(to, from) {
this.pageKey++;
this.showNav = false;
this.canBack = (window.history.length > 0 && !['index'].includes(to.name));
},
@ -245,6 +192,8 @@ export default Vue.extend({
},
created() {
document.documentElement.style.overflowY = 'scroll';
if (this.$store.getters.isSignedIn) {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('notification', this.onNotification);
@ -266,7 +215,7 @@ export default Vue.extend({
mounted() {
const adjustTitlePosition = () => {
const left = this.$refs.main.getBoundingClientRect().left - this.$refs.nav.offsetWidth;
const left = this.$refs.main.getBoundingClientRect().left - this.$refs.nav.$el.offsetWidth;
if (left >= 0) {
this.$refs.title.style.left = left + 'px';
}
@ -293,6 +242,10 @@ export default Vue.extend({
},
methods: {
showNav() {
this.$refs.nav.show();
},
attachSticky() {
if (!this.isDesktop) return;
if (this.$store.state.device.fixedWidgetsPosition) return;
@ -351,180 +304,6 @@ export default Vue.extend({
}
},
async openAccountMenu(ev) {
const accounts = (await this.$root.api('users/show', { userIds: this.$store.state.device.accounts.map(x => x.id) })).filter(x => x.id !== this.$store.state.i.id);
const accountItems = accounts.map(account => ({
type: 'user',
user: account,
action: () => { this.switchAccount(account); }
}));
this.$root.menu({
items: [...[{
type: 'link',
text: this.$t('profile'),
to: `/@${ this.$store.state.i.username }`,
avatar: this.$store.state.i,
}, {
type: 'link',
text: this.$t('accountSettings'),
to: '/my/settings',
icon: faCog,
}, null, ...accountItems, {
icon: faPlus,
text: this.$t('addAcount'),
action: () => {
this.$root.menu({
items: [{
text: this.$t('existingAcount'),
action: () => { this.addAcount(); },
}, {
text: this.$t('createAccount'),
action: () => { this.createAccount(); },
}],
align: 'left',
fixed: true,
width: 240,
source: ev.currentTarget || ev.target,
});
},
}]],
align: 'left',
fixed: true,
width: 240,
source: ev.currentTarget || ev.target,
});
},
oepnInstanceMenu(ev) {
this.$root.menu({
items: [{
type: 'link',
text: this.$t('dashboard'),
to: '/instance',
icon: faTachometerAlt,
}, null, {
type: 'link',
text: this.$t('settings'),
to: '/instance/settings',
icon: faCog,
}, {
type: 'link',
text: this.$t('customEmojis'),
to: '/instance/emojis',
icon: faLaugh,
}, {
type: 'link',
text: this.$t('users'),
to: '/instance/users',
icon: faUsers,
}, {
type: 'link',
text: this.$t('files'),
to: '/instance/files',
icon: faCloud,
}, {
type: 'link',
text: this.$t('jobQueue'),
to: '/instance/queue',
icon: faExchangeAlt,
}, {
type: 'link',
text: this.$t('federation'),
to: '/instance/federation',
icon: faGlobe,
}, {
type: 'link',
text: this.$t('relays'),
to: '/instance/relays',
icon: faProjectDiagram,
}, {
type: 'link',
text: this.$t('announcements'),
to: '/instance/announcements',
icon: faBroadcastTower,
}],
align: 'left',
fixed: true,
width: 200,
source: ev.currentTarget || ev.target,
});
},
more(ev) {
const items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
type: def.to ? 'link' : 'button',
text: this.$t(def.title),
icon: def.icon,
to: def.to,
action: def.action,
indicate: def.indicated,
}));
this.$root.menu({
items: [...items, null, {
type: 'link',
text: this.$t('help'),
to: '/docs',
icon: faQuestionCircle,
}, {
type: 'link',
text: this.$t('aboutX', { x: instanceName || host }),
to: '/about',
icon: faInfoCircle,
}, {
type: 'link',
text: this.$t('aboutMisskey'),
to: '/about-misskey',
icon: faInfoCircle,
}],
align: 'left',
fixed: true,
width: 200,
source: ev.currentTarget || ev.target,
});
},
async addAcount() {
this.$root.new(await import('./components/signin-dialog.vue').then(m => m.default)).$once('login', res => {
this.$store.dispatch('addAcount', res);
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
});
},
async createAccount() {
this.$root.new(await import('./components/signup-dialog.vue').then(m => m.default)).$once('signup', res => {
this.$store.dispatch('addAcount', res);
this.switchAccountWithToken(res.i);
});
},
async switchAccount(account: any) {
const token = this.$store.state.device.accounts.find((x: any) => x.id === account.id).token;
this.switchAccountWithToken(token);
},
switchAccountWithToken(token: string) {
this.$root.dialog({
type: 'waiting',
iconOnly: true
});
this.$root.api('i', {}, token).then((i: any) => {
this.$store.dispatch('switchAccount', {
...i,
token: token
}).then(() => {
this.$nextTick(() => {
location.reload();
});
});
});
},
async onNotification(notification) {
if (document.visibilityState === 'visible') {
this.$root.stream.send('readNotification', {
@ -540,8 +319,7 @@ export default Vue.extend({
},
widgetFunc(id) {
const w = this.$refs[id][0];
if (w.func) w.func();
this.$refs[id][0].setting();
},
onWidgetSort() {
@ -549,18 +327,6 @@ export default Vue.extend({
},
async addWidget(place) {
const widgets = [
'memo',
'notifications',
'timeline',
'calendar',
'rss',
'trends',
'clock',
'activity',
'photos',
];
const { canceled, result: widget } = await this.$root.dialog({
type: null,
title: this.$t('chooseWidget'),
@ -594,36 +360,14 @@ export default Vue.extend({
</script>
<style lang="scss" scoped>
.nav-enter-active,
.nav-leave-active {
opacity: 1;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.nav-enter,
.nav-leave-active {
opacity: 0;
transform: translateX(-240px);
}
.nav-back-enter-active,
.nav-back-leave-active {
opacity: 1;
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.nav-back-enter,
.nav-back-leave-active {
opacity: 0;
}
.mk-app {
$header-height: 60px;
$nav-width: 250px;
$nav-icon-only-width: 80px;
$nav-width: 250px; // TODO:
$nav-icon-only-width: 80px; // TODO:
$main-width: 670px;
$ui-font-size: 1em;
$nav-icon-only-threshold: 1279px;
$nav-hide-threshold: 650px;
$ui-font-size: 1em; // TODO:
$nav-icon-only-threshold: 1279px; // TODO:
$nav-hide-threshold: 650px; // TODO:
$header-sub-hide-threshold: 1090px;
$left-widgets-hide-threshold: 1600px;
$right-widgets-hide-threshold: 1090px;
@ -780,176 +524,6 @@ export default Vue.extend({
}
}
> .nav-back {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100%;
background: var(--modalBg);
}
> .nav {
$avatar-size: 32px;
$avatar-margin: ($header-height - $avatar-size) / 2;
flex: 0 0 $nav-width;
width: $nav-width;
box-sizing: border-box;
@media (max-width: $nav-icon-only-threshold) {
flex: 0 0 $nav-icon-only-width;
width: $nav-icon-only-width;
}
@media (max-width: $nav-hide-threshold) {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
}
@media (min-width: $nav-hide-threshold + 1px) {
display: block !important;
}
> div {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: $nav-width;
height: 100vh;
box-sizing: border-box;
overflow: auto;
background: var(--navBg);
border-right: solid 1px var(--divider);
> .divider {
margin: 16px 0;
border-top: solid 1px var(--divider);
}
@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
width: $nav-icon-only-width;
> .divider {
margin: 8px auto;
width: calc(100% - 32px);
}
> .item {
&:first-child {
margin-bottom: 8px;
}
&:last-child {
margin-top: 8px;
}
}
}
> .item {
position: relative;
display: block;
padding-left: 32px;
font-size: $ui-font-size;
line-height: 3.2rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
text-align: left;
box-sizing: border-box;
color: var(--navFg);
> [data-icon] {
width: ($header-height - ($avatar-margin * 2));
}
> [data-icon],
> .avatar {
margin-right: $avatar-margin;
}
> .avatar {
width: $avatar-size;
height: $avatar-size;
vertical-align: middle;
}
> i {
position: absolute;
top: 0;
left: 20px;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
}
&:hover {
text-decoration: none;
color: var(--navHoverFg);
}
&.active {
color: var(--navActive);
}
&:first-child, &:last-child {
position: sticky;
z-index: 1;
padding-top: 8px;
padding-bottom: 8px;
background: var(--X14);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
&:first-child {
top: 0;
margin-bottom: 16px;
border-bottom: solid 1px var(--divider);
}
&:last-child {
bottom: 0;
margin-top: 16px;
border-top: solid 1px var(--divider);
}
@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
padding-left: 0;
width: 100%;
text-align: center;
font-size: $ui-font-size * 1.2;
line-height: 3.7rem;
> [data-icon],
> .avatar {
margin-right: 0;
}
> i {
left: 10px;
}
> .text {
display: none;
}
}
}
@media (max-width: $nav-hide-threshold) {
> .index,
> .notifications {
display: none;
}
}
}
}
> .contents {
display: flex;
margin: 0 auto;

View File

@ -0,0 +1,80 @@
<template>
<x-column :menu="menu" :column="column" :is-stacked="isStacked">
<template #header>
<fa :icon="faSatellite"/><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
<x-timeline ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSatellite, faCog } from '@fortawesome/free-solid-svg-icons';
import XColumn from './column.vue';
import XTimeline from '../timeline.vue';
export default Vue.extend({
components: {
XColumn,
XTimeline,
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
menu: null,
faSatellite
};
},
watch: {
mediaOnly() {
(this.$refs.timeline as any).reload();
}
},
created() {
this.menu = [{
icon: faCog,
text: this.$t('antenna'),
action: async () => {
const antennas = await this.$root.api('antennas/list');
this.$root.dialog({
title: this.$t('antenna'),
type: null,
select: {
items: antennas.map(x => ({
value: x, text: x.name
}))
},
showCancelButton: true
}).then(({ canceled, result: antenna }) => {
if (canceled) return;
this.column.antennaId = antenna.id;
this.$store.commit('deviceUser/updateDeckColumn', this.column);
});
}
}];
},
methods: {
focus() {
(this.$refs.timeline as any).focus();
}
}
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,50 @@
<template>
<!-- TODO: リファクタの余地がありそう -->
<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 === 'tl'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-list-column v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-antenna-column v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<!-- TODO: <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 './tl-column.vue';
import XAntennaColumn from './antenna-column.vue';
import XListColumn from './list-column.vue';
import XNotificationsColumn from './notifications-column.vue';
import XWidgetsColumn from './widgets-column.vue';
import XMentionsColumn from './mentions-column.vue';
import XDirectColumn from './direct-column.vue';
export default Vue.extend({
components: {
XTlColumn,
XAntennaColumn,
XListColumn,
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

@ -0,0 +1,426 @@
<template>
<!-- sectionを利用しているのはdeck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
<section class="dnpfarvg _panel _narrow_" :class="{ naked, paged: isMainColumn, _close_: !isMainColumn, active, isStacked, draghover, dragging, dropready }"
@dragover.prevent.stop="onDragover"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
v-hotkey="keymap"
:style="{ width: `${width}px` }"
>
<header :class="{ indicated }"
draggable="true"
@click="goTop"
@dragstart="onDragstart"
@dragend="onDragend"
@contextmenu.prevent.stop="onContextmenu"
>
<button class="toggleActive _button" @click="toggleActive" v-if="isStacked">
<template v-if="active"><fa :icon="faAngleUp"/></template>
<template v-else><fa :icon="faAngleDown"/></template>
</button>
<div class="action">
<slot name="action"></slot>
</div>
<span class="header"><slot name="header"></slot></span>
<button v-if="!isMainColumn" class="menu _button" ref="menu" @click.stop="showMenu"><fa :icon="faCaretDown"/></button>
<button v-else-if="$route.name !== 'index'" class="close _button" @click.stop="close"><fa :icon="faTimes"/></button>
</header>
<div ref="body" v-show="active">
<slot></slot>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faTimes, faArrowRight, faArrowLeft, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { faWindowMaximize, faTrashAlt, faWindowRestore } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
props: {
column: {
type: Object,
required: false,
default: null
},
isStacked: {
type: Boolean,
required: false,
default: false
},
menu: {
type: Array,
required: false,
default: null
},
naked: {
type: Boolean,
required: false,
default: false
},
indicated: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
active: true,
dragging: false,
draghover: false,
dropready: false,
faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faTimes,
};
},
computed: {
isMainColumn(): boolean {
return this.column == null;
},
width(): number {
return this.isMainColumn ? 350 : this.column.width;
},
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'),
};
}
},
watch: {
active(v) {
this.$emit('change-active-state', v);
},
dragging(v) {
this.$root.$emit(v ? 'deck.column.dragStart' : 'deck.column.dragEnd');
}
},
mounted() {
if (!this.isMainColumn) {
this.$root.$on('deck.column.dragStart', this.onOtherDragStart);
this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd);
}
},
beforeDestroy() {
if (!this.isMainColumn) {
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;
this.active = !this.active;
},
getMenu() {
const items = [{
icon: faPencilAlt,
text: this.$t('rename'),
action: () => {
this.$root.dialog({
title: this.$t('rename'),
input: {
default: this.column.name,
allowEmpty: false
}
}).then(({ canceled, result: name }) => {
if (canceled) return;
this.$store.commit('deviceUser/renameDeckColumn', { id: this.column.id, name });
});
}
}, null, {
icon: faArrowLeft,
text: this.$t('swap-left'),
action: () => {
this.$store.commit('deviceUser/swapLeftDeckColumn', this.column.id);
}
}, {
icon: faArrowRight,
text: this.$t('swap-right'),
action: () => {
this.$store.commit('deviceUser/swapRightDeckColumn', this.column.id);
}
}, this.isStacked ? {
icon: faArrowUp,
text: this.$t('swap-up'),
action: () => {
this.$store.commit('deviceUser/swapUpDeckColumn', this.column.id);
}
} : undefined, this.isStacked ? {
icon: faArrowDown,
text: this.$t('swap-down'),
action: () => {
this.$store.commit('deviceUser/swapDownDeckColumn', this.column.id);
}
} : undefined, null, {
icon: faWindowRestore,
text: this.$t('stack-left'),
action: () => {
this.$store.commit('deviceUser/stackLeftDeckColumn', this.column.id);
}
}, this.isStacked ? {
icon: faWindowMaximize,
text: this.$t('pop-right'),
action: () => {
this.$store.commit('deviceUser/popRightDeckColumn', this.column.id);
}
} : undefined, null, {
icon: faTrashAlt,
text: this.$t('remove'),
action: () => {
this.$store.commit('deviceUser/removeDeckColumn', this.column.id);
}
}];
if (this.menu) {
for (const i of this.menu.reverse()) {
items.unshift(i);
}
}
return items;
},
onContextmenu(e) {
if (this.isMainColumn) return;
this.showMenu();
},
showMenu() {
this.$root.menu({
items: this.getMenu(),
source: this.$refs.menu,
});
},
close() {
this.$router.push('/');
},
goTop() {
this.$refs.body.scrollTo({
top: 0,
behavior: 'smooth'
});
},
onDragstart(e) {
//
if (this.isMainColumn) {
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.isMainColumn) {
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.commit('deviceUser/swapDeckColumn', {
a: this.column.id,
b: id
});
}
}
}
});
</script>
<style lang="scss" scoped>
.dnpfarvg {
$header-height: 42px;
height: 100%;
overflow: hidden;
box-shadow: 0 0 0 1px var(--deckColumnBorder);
&.draghover {
box-shadow: 0 0 0 2px var(--focus);
&:after {
content: "";
display: block;
position: absolute;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--focus);
}
}
&.dragging {
box-shadow: 0 0 0 2px var(--focus);
}
&.dropready {
* {
pointer-events: none;
}
}
&:not(.active) {
flex-basis: $header-height;
min-height: $header-height;
> header.indicated {
box-shadow: 4px 0px var(--accent) inset;
}
}
&.naked {
//background: var(--deckAcrylicColumnBg);
background: transparent !important;
> header {
background: transparent;
box-shadow: none;
> button {
color: var(--fg);
}
}
}
&.paged {
> div {
background: var(--bg);
padding: var(--margin);
}
}
> header {
position: relative;
display: flex;
z-index: 2;
line-height: $header-height;
padding: 0 16px;
font-size: 0.9em;
color: var(--panelHeaderFg);
background: var(--panelHeaderBg);
box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
cursor: pointer;
&, * {
user-select: none;
}
&.indicated {
box-shadow: 0 3px 0 0 var(--accent);
}
> .header {
display: inline-block;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
> span:only-of-type {
width: 100%;
}
> .toggleActive,
> .action > *,
> .menu,
> .close {
z-index: 1;
width: $header-height;
line-height: $header-height;
font-size: 16px;
color: var(--faceTextButton);
&:hover {
color: var(--faceTextButtonHover);
}
&:active {
color: var(--faceTextButtonActive);
}
}
> .toggleActive, > .action {
margin-left: -16px;
}
> .action {
z-index: 1;
}
> .action:empty {
display: none;
}
> .menu,
> .close {
margin-left: auto;
margin-right: -16px;
}
}
> div {
height: calc(100% - #{$header-height});
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<x-column :name="name" :column="column" :is-stacked="isStacked" :menu="menu">
<template #header><fa :icon="faEnvelope" style="margin-right: 8px;"/>{{ column.name }}</template>
<x-direct/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
import XColumn from './column.vue';
import XDirect from '../../pages/messages.vue';
export default Vue.extend({
components: {
XColumn,
XDirect
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
menu: null,
faEnvelope
}
},
});
</script>

View File

@ -0,0 +1,87 @@
<template>
<x-column :menu="menu" :column="column" :is-stacked="isStacked">
<template #header>
<fa :icon="faListUl"/><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
<x-timeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => $emit('loaded')"/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import { faListUl, faCog } from '@fortawesome/free-solid-svg-icons';
import XColumn from './column.vue';
import XTimeline from '../timeline.vue';
export default Vue.extend({
components: {
XColumn,
XTimeline,
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
faListUl
};
},
watch: {
mediaOnly() {
(this.$refs.timeline as any).reload();
}
},
created() {
this.menu = [{
icon: faCog,
text: this.$t('list'),
action: this.setList
}];
},
mounted() {
if (this.column.listId == null) {
this.setList();
}
},
methods: {
async setList() {
const lists = await this.$root.api('users/lists/list');
const { canceled, result: list } = await this.$root.dialog({
title: this.$t('list'),
type: null,
select: {
items: lists.map(x => ({
value: x, text: x.name
})),
default: this.column.listId
},
showCancelButton: true
});
if (canceled) return;
Vue.set(this.column, 'listId', list.id);
this.$store.commit('deviceUser/updateDeckColumn', this.column);
},
focus() {
(this.$refs.timeline as any).focus();
}
}
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,39 @@
<template>
<x-column :column="column" :is-stacked="isStacked" :menu="menu">
<template #header><fa :icon="faAt" style="margin-right: 8px;"/>{{ column.name }}</template>
<x-mentions/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import { faAt } from '@fortawesome/free-solid-svg-icons';
import XColumn from './column.vue';
import XMentions from '../../pages/mentions.vue';
export default Vue.extend({
components: {
XColumn,
XMentions
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
menu: null,
faAt
}
},
});
</script>

View File

@ -0,0 +1,69 @@
<template>
<x-column :column="column" :is-stacked="isStacked" :menu="menu">
<template #header><fa :icon="faBell" style="margin-right: 8px;"/>{{ column.name }}</template>
<x-notifications/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import { faCog } from '@fortawesome/free-solid-svg-icons';
import { faBell } from '@fortawesome/free-regular-svg-icons';
import XColumn from './column.vue';
import XNotifications from '../notifications.vue';
export default Vue.extend({
components: {
XColumn,
XNotifications
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
menu: null,
faBell
}
},
created() {
if (this.column.notificationType == null) {
this.column.notificationType = 'all';
this.$store.commit('deviceUser/updateDeckColumn', this.column);
}
this.menu = [{
icon: faCog,
text: this.$t('@.notification-type'),
action: () => {
this.$root.dialog({
title: this.$t('@.notification-type'),
type: null,
select: {
items: ['all', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'].map(x => ({
value: x, text: this.$t('@.notification-types.' + x)
}))
default: this.column.notificationType,
},
showCancelButton: true
}).then(({ canceled, result: type }) => {
if (canceled) return;
this.column.notificationType = type;
this.$store.commit('deviceUser/updateDeckColumn', this.column);
});
}
}];
},
});
</script>

View File

@ -0,0 +1,141 @@
<template>
<x-column :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState">
<template #header>
<fa v-if="column.tl === 'home'" :icon="faHome"/>
<fa v-else-if="column.tl === 'local'" :icon="faComments"/>
<fa v-else-if="column.tl === 'social'" :icon="faShareAlt"/>
<fa v-else-if="column.tl === 'global'" :icon="faGlobe"/>
<span style="margin-left: 8px;">{{ column.name }}</span>
</template>
<div class="iwaalbte" v-if="disabled">
<p>
<fa :icon="faMinusCircle"/>
{{ $t('disabled-timeline.title') }}
</p>
<p class="desc">{{ $t('disabled-timeline.description') }}</p>
</div>
<x-timeline v-else-if="column.tl" ref="timeline" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import { faMinusCircle, faHome, faComments, faShareAlt, faGlobe, faCog } from '@fortawesome/free-solid-svg-icons';
import XColumn from './column.vue';
import XTimeline from '../timeline.vue';
export default Vue.extend({
components: {
XColumn,
XTimeline,
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
menu: null,
disabled: false,
indicated: false,
columnActive: true,
faMinusCircle, faHome, faComments, faShareAlt, faGlobe,
};
},
watch: {
mediaOnly() {
(this.$refs.timeline as any).reload();
}
},
created() {
this.menu = [{
icon: faCog,
text: this.$t('timeline'),
action: this.setType
}];
},
mounted() {
if (this.column.tl == null) {
this.setType();
} else {
this.disabled = !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin && (
this.$store.state.instance.meta.disableLocalTimeline && ['local', 'social'].includes(this.column.tl) ||
this.$store.state.instance.meta.disableGlobalTimeline && ['global'].includes(this.column.tl));
}
},
methods: {
async setType() {
const { canceled, result: src } = await this.$root.dialog({
title: this.$t('timeline'),
type: null,
select: {
items: [{
value: 'home', text: this.$t('_timelines.home')
}, {
value: 'local', text: this.$t('_timelines.local')
}, {
value: 'social', text: this.$t('_timelines.social')
}, {
value: 'global', text: this.$t('_timelines.global')
}]
},
showCancelButton: true
});
if (canceled) return;
Vue.set(this.column, 'tl', src);
this.$store.commit('deviceUser/updateDeckColumn', this.column);
},
queueUpdated(q) {
if (this.columnActive) {
this.indicated = q !== 0;
}
},
onNote() {
if (!this.columnActive) {
this.indicated = true;
}
},
onChangeActiveState(state) {
this.columnActive = state;
if (this.columnActive) {
this.indicated = false;
}
},
focus() {
(this.$refs.timeline as any).focus();
}
}
});
</script>
<style lang="scss" scoped>
.iwaalbte {
text-align: center;
> p {
margin: 16px;
&.desc {
font-size: 14px;
}
}
}
</style>

View File

@ -0,0 +1,151 @@
<template>
<x-column :menu="menu" :naked="true" :column="column" :is-stacked="isStacked">
<template #header><fa :icon="faWindowMaximize" style="margin-right: 8px;"/>{{ column.name }}</template>
<div class="wtdtxvec">
<template v-if="edit">
<header>
<select v-model="widgetAdderSelected" @change="addWidget">
<option v-for="widget in widgets" :value="widget" :key="widget">{{ widget }}</option>
</select>
</header>
<x-draggable
:list="column.widgets"
animation="150"
@sort="onWidgetSort"
>
<div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @click="widgetFunc(widget.id)">
<button class="remove _button" @click="removeWidget(widget)"><fa :icon="faTimes"/></button>
<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :column="column"/>
</div>
</x-draggable>
</template>
<component v-else class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :column="column"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import * as XDraggable from 'vuedraggable';
import { v4 as uuid } from 'uuid';
import { faWindowMaximize, faTimes } from '@fortawesome/free-solid-svg-icons';
import XColumn from './column.vue';
import { widgets } from '../../widgets';
export default Vue.extend({
components: {
XColumn,
XDraggable,
},
props: {
column: {
type: Object,
required: true,
},
isStacked: {
type: Boolean,
required: true,
},
},
data() {
return {
edit: false,
menu: null,
widgetAdderSelected: null,
widgets,
faWindowMaximize, faTimes
};
},
created() {
this.menu = [{
icon: 'cog',
text: this.$t('edit'),
action: () => {
this.edit = !this.edit;
}
}];
},
methods: {
widgetFunc(id) {
this.$refs[id][0].setting();
},
onWidgetSort() {
this.saveWidgets();
},
addWidget() {
this.$store.commit('deviceUser/addDeckWidget', {
id: this.column.id,
widget: {
name: this.widgetAdderSelected,
id: uuid(),
data: {}
}
});
this.widgetAdderSelected = null;
},
removeWidget(widget) {
this.$store.commit('deviceUser/removeDeckWidget', {
id: this.column.id,
widget
});
},
saveWidgets() {
this.$store.commit('deviceUser/updateDeckColumn', this.column);
}
}
});
</script>
<style lang="scss" scoped>
.wtdtxvec {
padding-top: 1px; // box-shadow1px border
> header {
padding: 16px;
> * {
width: 100%;
padding: 4px;
}
}
> .widget, .customize-container {
margin: 8px;
&:first-of-type {
margin-top: 0;
}
}
.customize-container {
position: relative;
cursor: move;
> *:not(.remove) {
pointer-events: none;
}
> .remove {
position: absolute;
z-index: 2;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
color: #fff;
background: rgba(#000, 0.7);
border-radius: 4px;
}
}
}
</style>

View File

@ -40,7 +40,7 @@ export default Vue.extend({
> img {
vertical-align: bottom;
height: 150px;
height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}

View File

@ -0,0 +1,71 @@
<template>
<x-window ref="window" :width="400" :height="450" :no-padding="true" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="false" @ok="ok()" :can-close="false">
<template #header>
{{ title }}
</template>
<div class="xkpnjxcv">
<label v-for="item in Object.keys(form).filter(item => !form[item].hidden)" :key="item">
<mk-input v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"><span v-text="form[item].label || item"></span></mk-input>
<mk-input v-else-if="form[item].type === 'string' && !item.multiline" v-model="values[item]" type="text"><span v-text="form[item].label || item"></span></mk-input>
<mk-textarea v-else-if="form[item].type === 'string' && item.multiline" v-model="values[item]"><span v-text="form[item].label || item"></span></mk-textarea>
<mk-switch v-else-if="form[item].type === 'boolean'" v-model="values[item]"><span v-text="form[item].label || item"></span></mk-switch>
</label>
</div>
</x-window>
</template>
<script lang="ts">
import Vue from 'vue';
import XWindow from './window.vue';
import MkInput from './ui/input.vue';
import MkTextarea from './ui/textarea.vue';
import MkSwitch from './ui/switch.vue';
export default Vue.extend({
components: {
XWindow,
MkInput,
MkTextarea,
MkSwitch,
},
props: {
title: {
type: String,
required: true,
},
form: {
type: Object,
required: true,
},
},
data() {
return {
values: {}
};
},
created() {
for (const item in this.form) {
Vue.set(this.values, item, this.form[item].default || null);
}
},
methods: {
ok() {
this.$emit('ok', this.values);
this.$refs.window.close();
},
}
});
</script>
<style lang="scss" scoped>
.xkpnjxcv {
> label {
display: block;
padding: 16px 24px;
}
}
</style>

View File

@ -1,10 +1,10 @@
<template>
<div class="mk-modal" v-hotkey.global="keymap">
<transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear>
<div class="bg" ref="bg" v-if="show" @click="close()"></div>
<div class="bg" ref="bg" v-if="show" @click="canClose ? close() : () => {}"></div>
</transition>
<transition :name="$store.state.device.animation ? 'modal' : ''" appear @after-leave="() => { $emit('closed'); destroyDom(); }">
<div class="content" ref="content" v-if="show" @click.self="close()"><slot></slot></div>
<div class="content" ref="content" v-if="show" @click.self="canClose ? close() : () => {}"><slot></slot></div>
</transition>
</div>
</template>
@ -14,6 +14,11 @@ import Vue from 'vue';
export default Vue.extend({
props: {
canClose: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {

View File

@ -54,7 +54,6 @@ export default Vue.extend({
margin: 0 .5em 0 0;
padding: 0;
overflow: hidden;
color: var(--noteHeaderName);
font-size: 1em;
font-weight: bold;
text-decoration: none;

View File

@ -724,61 +724,6 @@ export default Vue.extend({
transition: box-shadow 0.1s ease;
overflow: hidden;
&.max-width_500px {
font-size: 0.9em;
}
&.max-width_450px {
> .renote {
padding: 8px 16px 0 16px;
}
> .article {
padding: 14px 16px 9px;
> .avatar {
margin: 0 10px 8px 0;
width: 50px;
height: 50px;
}
}
}
&.max-width_350px {
> .article {
> .main {
> .footer {
> .button {
&:not(:last-child) {
margin-right: 18px;
}
}
}
}
}
}
&.max-width_300px {
font-size: 0.825em;
> .article {
> .avatar {
width: 44px;
height: 44px;
}
> .main {
> .footer {
> .button {
&:not(:last-child) {
margin-right: 12px;
}
}
}
}
}
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px var(--focus);
@ -797,10 +742,6 @@ export default Vue.extend({
white-space: pre;
color: #d28a3f;
@media (max-width: 450px) {
padding: 8px 16px 0 16px;
}
> [data-icon] {
margin-right: 4px;
}
@ -985,5 +926,64 @@ export default Vue.extend({
> .reply {
border-top: solid 1px var(--divider);
}
&.max-width_500px {
font-size: 0.9em;
}
&.max-width_450px {
> .renote {
padding: 8px 16px 0 16px;
}
> .info {
padding: 8px 16px 0 16px;
}
> .article {
padding: 14px 16px 9px;
> .avatar {
margin: 0 10px 8px 0;
width: 50px;
height: 50px;
}
}
}
&.max-width_350px {
> .article {
> .main {
> .footer {
> .button {
&:not(:last-child) {
margin-right: 18px;
}
}
}
}
}
}
&.max-width_300px {
font-size: 0.825em;
> .article {
> .avatar {
width: 44px;
height: 44px;
}
> .main {
> .footer {
> .button {
&:not(:last-child) {
margin-right: 12px;
}
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,488 @@
<template>
<div class="mvcprjjd">
<transition name="nav-back">
<div class="nav-back"
v-if="showing"
@click="showing = false"
@touchstart="showing = false"
></div>
</transition>
<transition name="nav">
<nav class="nav" v-show="showing">
<div>
<button class="item _button account" @click="openAccountMenu" v-if="$store.getters.isSignedIn">
<mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/>
</button>
<button class="item _button index active" @click="top()" v-if="$route.name === 'index'">
<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
</button>
<router-link class="item index" active-class="active" to="/" exact v-else>
<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
</router-link>
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
<component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" @click="() => { if (menuDef[item].action) menuDef[item].action() }" :to="menuDef[item].to">
<fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span>
<i v-if="menuDef[item].indicated"><fa :icon="faCircle"/></i>
</component>
</template>
<div class="divider"></div>
<button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)" @click="oepnInstanceMenu">
<fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span>
</button>
<button class="item _button" @click="more">
<fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span>
<i v-if="otherNavItemIndicated"><fa :icon="faCircle"/></i>
</button>
<router-link class="item" active-class="active" to="/preferences">
<fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span>
</router-link>
</div>
</nav>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram } from '@fortawesome/free-solid-svg-icons';
import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons';
import { host, instanceName } from '../config';
import { search } from '../scripts/search';
export default Vue.extend({
data() {
return {
host: host,
showing: false,
searching: false,
accounts: [],
connection: null,
menuDef: this.$store.getters.nav({
search: this.search
}),
faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
};
},
computed: {
menu(): string[] {
return this.$store.state.deviceUser.menu;
},
otherNavItemIndicated(): boolean {
if (!this.$store.getters.isSignedIn) return false;
for (const def in this.menuDef) {
if (this.menu.includes(def)) continue;
if (this.menuDef[def].indicated) return true;
}
return false;
},
},
watch: {
$route(to, from) {
this.showing = false;
},
},
methods: {
show() {
this.showing = true;
},
search() {
if (this.searching) return;
this.$root.dialog({
title: this.$t('search'),
input: true
}).then(async ({ canceled, result: query }) => {
if (canceled || query == null || query === '') return;
this.searching = true;
search(this, query).finally(() => {
this.searching = false;
});
});
},
async openAccountMenu(ev) {
const accounts = (await this.$root.api('users/show', { userIds: this.$store.state.device.accounts.map(x => x.id) })).filter(x => x.id !== this.$store.state.i.id);
const accountItems = accounts.map(account => ({
type: 'user',
user: account,
action: () => { this.switchAccount(account); }
}));
this.$root.menu({
items: [...[{
type: 'link',
text: this.$t('profile'),
to: `/@${ this.$store.state.i.username }`,
avatar: this.$store.state.i,
}, {
type: 'link',
text: this.$t('accountSettings'),
to: '/my/settings',
icon: faCog,
}, null, ...accountItems, {
icon: faPlus,
text: this.$t('addAcount'),
action: () => {
this.$root.menu({
items: [{
text: this.$t('existingAcount'),
action: () => { this.addAcount(); },
}, {
text: this.$t('createAccount'),
action: () => { this.createAccount(); },
}],
align: 'left',
fixed: true,
width: 240,
source: ev.currentTarget || ev.target,
});
},
}]],
align: 'left',
fixed: true,
width: 240,
source: ev.currentTarget || ev.target,
});
},
oepnInstanceMenu(ev) {
this.$root.menu({
items: [{
type: 'link',
text: this.$t('dashboard'),
to: '/instance',
icon: faTachometerAlt,
}, null, {
type: 'link',
text: this.$t('settings'),
to: '/instance/settings',
icon: faCog,
}, {
type: 'link',
text: this.$t('customEmojis'),
to: '/instance/emojis',
icon: faLaugh,
}, {
type: 'link',
text: this.$t('users'),
to: '/instance/users',
icon: faUsers,
}, {
type: 'link',
text: this.$t('files'),
to: '/instance/files',
icon: faCloud,
}, {
type: 'link',
text: this.$t('jobQueue'),
to: '/instance/queue',
icon: faExchangeAlt,
}, {
type: 'link',
text: this.$t('federation'),
to: '/instance/federation',
icon: faGlobe,
}, {
type: 'link',
text: this.$t('relays'),
to: '/instance/relays',
icon: faProjectDiagram,
}, {
type: 'link',
text: this.$t('announcements'),
to: '/instance/announcements',
icon: faBroadcastTower,
}],
align: 'left',
fixed: true,
width: 200,
source: ev.currentTarget || ev.target,
});
},
more(ev) {
const items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
type: def.to ? 'link' : 'button',
text: this.$t(def.title),
icon: def.icon,
to: def.to,
action: def.action,
indicate: def.indicated,
}));
this.$root.menu({
items: [...items, null, {
type: 'link',
text: this.$t('help'),
to: '/docs',
icon: faQuestionCircle,
}, {
type: 'link',
text: this.$t('aboutX', { x: instanceName || host }),
to: '/about',
icon: faInfoCircle,
}, {
type: 'link',
text: this.$t('aboutMisskey'),
to: '/about-misskey',
icon: faInfoCircle,
}],
align: 'left',
fixed: true,
width: 200,
source: ev.currentTarget || ev.target,
});
},
async addAcount() {
this.$root.new(await import('./signin-dialog.vue').then(m => m.default)).$once('login', res => {
this.$store.dispatch('addAcount', res);
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
});
},
async createAccount() {
this.$root.new(await import('./signup-dialog.vue').then(m => m.default)).$once('signup', res => {
this.$store.dispatch('addAcount', res);
this.switchAccountWithToken(res.i);
});
},
async switchAccount(account: any) {
const token = this.$store.state.device.accounts.find((x: any) => x.id === account.id).token;
this.switchAccountWithToken(token);
},
switchAccountWithToken(token: string) {
this.$root.dialog({
type: 'waiting',
iconOnly: true
});
this.$root.api('i', {}, token).then((i: any) => {
this.$store.dispatch('switchAccount', {
...i,
token: token
}).then(() => {
this.$nextTick(() => {
location.reload();
});
});
});
},
}
});
</script>
<style lang="scss" scoped>
.nav-enter-active,
.nav-leave-active {
opacity: 1;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.nav-enter,
.nav-leave-active {
opacity: 0;
transform: translateX(-240px);
}
.nav-back-enter-active,
.nav-back-leave-active {
opacity: 1;
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.nav-back-enter,
.nav-back-leave-active {
opacity: 0;
}
.mvcprjjd {
$ui-font-size: 1em; // TODO:
$nav-width: 250px; // TODO:
$nav-icon-only-width: 80px; // TODO:
$nav-icon-only-threshold: 1279px; // TODO:
$nav-hide-threshold: 650px; // TODO:
> .nav-back {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100%;
background: var(--modalBg);
}
> .nav {
$avatar-size: 32px;
$avatar-margin: 8px;
flex: 0 0 $nav-width;
width: $nav-width;
box-sizing: border-box;
@media (max-width: $nav-icon-only-threshold) {
flex: 0 0 $nav-icon-only-width;
width: $nav-icon-only-width;
}
@media (max-width: $nav-hide-threshold) {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
}
@media (min-width: $nav-hide-threshold + 1px) {
display: block !important;
}
> div {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: $nav-width;
height: 100vh;
box-sizing: border-box;
overflow: auto;
background: var(--navBg);
border-right: solid 1px var(--divider);
> .divider {
margin: 16px 0;
border-top: solid 1px var(--divider);
}
@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
width: $nav-icon-only-width;
> .divider {
margin: 8px auto;
width: calc(100% - 32px);
}
> .item {
&:first-child {
margin-bottom: 8px;
}
&:last-child {
margin-top: 8px;
}
}
}
> .item {
position: relative;
display: block;
padding-left: 32px;
font-size: $ui-font-size;
line-height: 3.2rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
text-align: left;
box-sizing: border-box;
color: var(--navFg);
> [data-icon] {
width: 32px;
}
> [data-icon],
> .avatar {
margin-right: $avatar-margin;
}
> .avatar {
width: $avatar-size;
height: $avatar-size;
vertical-align: middle;
}
> i {
position: absolute;
top: 0;
left: 20px;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
}
&:hover {
text-decoration: none;
color: var(--navHoverFg);
}
&.active {
color: var(--navActive);
}
&:first-child, &:last-child {
position: sticky;
z-index: 1;
padding-top: 8px;
padding-bottom: 8px;
background: var(--X14);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
&:first-child {
top: 0;
margin-bottom: 16px;
border-bottom: solid 1px var(--divider);
}
&:last-child {
bottom: 0;
margin-top: 16px;
border-top: solid 1px var(--divider);
}
@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
padding-left: 0;
width: 100%;
text-align: center;
font-size: $ui-font-size * 1.2;
line-height: 3.7rem;
> [data-icon],
> .avatar {
margin-right: 0;
}
> i {
left: 10px;
}
> .text {
display: none;
}
}
}
@media (max-width: $nav-hide-threshold) {
> .index,
> .notifications {
display: none;
}
}
}
}
}
</style>

View File

@ -17,9 +17,11 @@ export default Vue.extend({
required: true
},
list: {
type: String,
required: false
},
antenna: {
type: String,
required: false
},
sound: {
@ -53,6 +55,8 @@ export default Vue.extend({
const _note = JSON.parse(JSON.stringify(note)); // deepcopy
(this.$refs.tl as any).prepend(_note);
this.$emit('note');
if (this.sound) {
this.$root.sound(note.userId === this.$store.state.i.id ? 'noteMy' : 'note');
}
@ -77,10 +81,10 @@ export default Vue.extend({
if (this.src == 'antenna') {
endpoint = 'antennas/notes';
this.query = {
antennaId: this.antenna.id
antennaId: this.antenna
};
this.connection = this.$root.stream.connectToChannel('antenna', {
antennaId: this.antenna.id
antennaId: this.antenna
});
this.connection.on('note', prepend);
} else if (this.src == 'home') {
@ -106,10 +110,10 @@ export default Vue.extend({
} else if (this.src == 'list') {
endpoint = 'notes/user-list-timeline';
this.query = {
listId: this.list.id
listId: this.list
};
this.connection = this.$root.stream.connectToChannel('userList', {
listId: this.list.id
listId: this.list
});
this.connection.on('note', prepend);
this.connection.on('userAdded', onUserAdded);

View File

@ -1,5 +1,5 @@
<template>
<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader }">
<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader, scrollable }" v-size="[{ max: 500 }]">
<header v-if="showHeader">
<div class="title"><slot name="header"></slot></div>
<slot name="func"></slot>
@ -47,6 +47,11 @@ export default Vue.extend({
required: false,
default: true
},
scrollable: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
@ -107,10 +112,19 @@ export default Vue.extend({
box-shadow: none !important;
}
&.scrollable {
display: flex;
flex-direction: column;
> div {
overflow: auto;
}
}
> header {
position: relative;
box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
z-index: 1;
z-index: 2;
background: var(--panelHeaderBg);
color: var(--panelHeaderFg);
@ -118,10 +132,6 @@ export default Vue.extend({
margin: 0;
padding: 12px 16px;
@media (max-width: 500px) {
padding: 8px 10px;
}
> [data-icon] {
margin-right: 6px;
}
@ -141,5 +151,21 @@ export default Vue.extend({
height: 100%;
}
}
&.max-width_500px {
> header {
> .title {
padding: 8px 10px;
}
}
}
}
._forceContainerFull_ .ukygtjoj {
> header {
> .title {
padding: 12px 16px !important;
}
}
}
</style>

View File

@ -20,6 +20,7 @@
:pattern="pattern"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:step="step"
@focus="focused = true"
@blur="focused = false"
@keydown="$emit('keydown', $event)"
@ -36,6 +37,7 @@
:pattern="pattern"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:step="step"
@focus="focused = true"
@blur="focused = false"
@keydown="$emit('keydown', $event)"
@ -114,6 +116,9 @@ export default Vue.extend({
spellcheck: {
required: false
},
step: {
required: false
},
debounce: {
required: false
},
@ -164,7 +169,7 @@ export default Vue.extend({
},
v(v) {
if (this.type === 'number') {
this.$emit('input', parseInt(v, 10));
this.$emit('input', parseFloat(v));
} else {
this.$emit('input', v);
}
@ -297,7 +302,7 @@ export default Vue.extend({
pointer-events: none;
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
transition-duration: 0.3s;
font-size: 16px;
font-size: 1em;
line-height: 32px;
color: var(--inputLabel);
pointer-events: none;
@ -312,7 +317,7 @@ export default Vue.extend({
top: -17px;
left: 0 !important;
pointer-events: none;
font-size: 16px;
font-size: 1em;
line-height: 32px;
color: var(--inputLabel);
pointer-events: none;
@ -343,7 +348,7 @@ export default Vue.extend({
padding: 0;
font: inherit;
font-weight: normal;
font-size: 16px;
font-size: 1em;
line-height: $height;
color: var(--inputText);
background: transparent;
@ -364,7 +369,7 @@ export default Vue.extend({
position: absolute;
z-index: 1;
top: 0;
font-size: 16px;
font-size: 1em;
line-height: 32px;
color: var(--inputLabel);
pointer-events: none;

View File

@ -135,7 +135,7 @@ export default Vue.extend({
pointer-events: none;
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
transition-duration: 0.3s;
font-size: 16px;
font-size: 1em;
line-height: 32px;
pointer-events: none;
//will-change transform
@ -150,7 +150,7 @@ export default Vue.extend({
padding: 0;
font: inherit;
font-weight: normal;
font-size: 16px;
font-size: 1em;
height: 32px;
background: none;
border: none;
@ -170,7 +170,7 @@ export default Vue.extend({
display: block;
align-self: center;
justify-self: center;
font-size: 16px;
font-size: 1em;
line-height: 32px;
color: rgba(#000, 0.54);
pointer-events: none;

View File

@ -5,7 +5,7 @@
role="switch"
:aria-checked="checked"
:aria-disabled="disabled"
@click="toggle"
@click.prevent="toggle"
>
<input
type="checkbox"

View File

@ -133,7 +133,7 @@ export default Vue.extend({
pointer-events: none;
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
transition-duration: 0.3s;
font-size: 16px;
font-size: 1em;
line-height: 32px;
pointer-events: none;
//will-change transform
@ -151,7 +151,7 @@ export default Vue.extend({
box-sizing: border-box;
font: inherit;
font-weight: normal;
font-size: 16px;
font-size: 1em;
background: transparent;
border: none;
border-radius: 0;

View File

@ -1,5 +1,5 @@
<template>
<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }">
<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }" :can-close="canClose">
<div class="ebkgoccj" :class="{ noPadding }" @keydown="onKeydown" :style="{ width: `${width}px`, height: `${height}px` }">
<div class="header">
<button class="_button" v-if="withOkButton" @click="close()"><fa :icon="faTimes"/></button>
@ -57,6 +57,11 @@ export default Vue.extend({
required: false,
default: 400
},
canClose: {
type: Boolean,
required: false,
default: true,
},
},
data() {

View File

@ -18,3 +18,4 @@ export const getLocale = async () => Object.fromEntries((await entries(clientDb.
export const version = _VERSION_;
export const env = _ENV_;
export const instanceName = siteName === 'Misskey' ? null : siteName;
export const deckmode = localStorage.getItem('deckmode') === 'true';

312
src/client/deck.vue Normal file
View File

@ -0,0 +1,312 @@
<template>
<div class="mk-deck" :class="`${$store.state.device.deckColumnAlign}`" v-hotkey.global="keymap">
<x-sidebar ref="nav"/>
<!-- TODO: deckMainColumnPlace を見て位置変える -->
<deck-column class="column" v-if="$store.state.device.deckAlwaysShowMainColumn || $route.name !== 'index'">
<template #action>
<button class="_button back" v-if="canBack" @click="back()"><fa :icon="faChevronLeft"/></button>
</template>
<template #header>
<div class="iwnjqeul">
<div class="default">
<portal-target name="avatar" slim/>
<span class="title"><portal-target name="icon" slim/><portal-target name="title" slim/></span>
</div>
<div class="custom">
<portal-target name="header" slim/>
</div>
</div>
</template>
<router-view></router-view>
</deck-column>
<template v-for="ids in layout">
<div v-if="ids.length > 1" class="folder column">
<deck-column-core v-for="id, i in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
</div>
<deck-column-core v-else class="column" :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id === ids[0])" @parent-focus="moveFocus(ids[0], $event)"/>
</template>
<button @click="addColumn" class="_button add"><fa :icon="faPlus"/></button>
<button v-if="$store.getters.isSignedIn" class="nav _button" @click="showNav()"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button>
<button v-if="$store.getters.isSignedIn" class="post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button>
<stream-indicator v-if="$store.getters.isSignedIn"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPlus, faPencilAlt, faChevronLeft, faBars, faCircle } from '@fortawesome/free-solid-svg-icons';
import { } from '@fortawesome/free-regular-svg-icons';
import { v4 as uuid } from 'uuid';
import { host } from './config';
import { search } from './scripts/search';
import DeckColumnCore from './components/deck/column-core.vue';
import DeckColumn from './components/deck/column.vue';
import XSidebar from './components/sidebar.vue';
export default Vue.extend({
components: {
XSidebar,
DeckColumn,
DeckColumnCore,
},
data() {
return {
host: host,
pageKey: 0,
searching: false,
connection: null,
searchQuery: '',
searchWait: false,
canBack: false,
menuDef: this.$store.getters.nav({}),
wallpaper: localStorage.getItem('wallpaper') != null,
faPlus, faPencilAlt, faChevronLeft, faBars, faCircle
};
},
computed: {
deck() {
return this.$store.state.deviceUser.deck;
},
columns(): any[] {
return this.deck.columns;
},
layout(): any[] {
return this.deck.layout;
},
navIndicated(): boolean {
if (!this.$store.getters.isSignedIn) return false;
for (const def in this.menuDef) {
if (this.menuDef[def].indicated) return true;
}
return false;
},
keymap(): any {
return {
'p': this.post,
'n': this.post,
's': this.search,
'h|/': this.help
};
},
},
watch: {
$route(to, from) {
this.pageKey++;
this.canBack = (window.history.length > 0 && !['index'].includes(to.name));
},
},
created() {
document.documentElement.style.overflowY = 'hidden';
if (this.$store.getters.isSignedIn) {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('notification', this.onNotification);
}
},
mounted() {
},
methods: {
showNav() {
this.$refs.nav.show();
},
help() {
this.$router.push('/docs/keyboard-shortcut');
},
back() {
if (this.canBack) window.history.back();
},
post() {
this.$root.post();
},
search() {
if (this.searching) return;
this.$root.dialog({
title: this.$t('search'),
input: true
}).then(async ({ canceled, result: query }) => {
if (canceled || query == null || query === '') return;
this.searching = true;
search(this, query).finally(() => {
this.searching = false;
});
});
},
async onNotification(notification) {
if (document.visibilityState === 'visible') {
this.$root.stream.send('readNotification', {
id: notification.id
});
this.$root.new(await import('./components/toast.vue').then(m => m.default), {
notification
});
}
this.$root.sound('notification');
},
async addColumn(ev) {
const columns = [
'widgets',
'notifications',
'tl',
'antenna',
'list',
'mentions',
'direct',
];
const { canceled, result: column } = await this.$root.dialog({
title: this.$t('_deck.addColumn'),
type: null,
select: {
items: columns.map(column => ({
value: column, text: this.$t('_deck._columns.' + column)
}))
},
showCancelButton: true
});
if (canceled) return;
this.$store.commit('deviceUser/addDeckColumn', {
type: column,
id: uuid(),
name: this.$t('_deck._columns.' + column),
width: 330,
});
},
}
});
</script>
<style lang="scss" scoped>
.mk-deck {
$nav-hide-threshold: 650px; // TODO:
// TODO:
$columnMargin: 12px;
$deckMargin: 12px;
--margin: var(--marginHalf);
display: flex;
height: 100vh;
box-sizing: border-box;
flex: 1;
padding: $deckMargin 0 $deckMargin $deckMargin;
&.center {
> .column:first-of-type {
margin-left: auto;
}
> .add {
margin-right: auto;
}
}
> .column {
flex-shrink: 0;
margin-right: $columnMargin;
&.folder {
display: flex;
flex-direction: column;
> *:not(:last-child) {
margin-bottom: $columnMargin;
}
}
}
> .post,
> .nav {
position: fixed;
z-index: 1000;
bottom: 32px;
width: 64px;
height: 64px;
border-radius: 100%;
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12);
font-size: 22px;
}
> .post {
right: 32px;
}
> .nav {
left: 32px;
background: var(--panel);
color: var(--fg);
@media (min-width: ($nav-hide-threshold + 1px)) {
display: none;
}
&:hover {
background: var(--X2);
}
> i {
position: absolute;
top: 0;
left: 0;
color: var(--indicator);
font-size: 16px;
animation: blink 1s infinite;
}
}
}
.iwnjqeul {
$header-height: 42px; // TODO: column.vue()
> .default {
> .avatar {
$size: 28px;
display: inline-block;
width: $size;
height: $size;
vertical-align: bottom;
margin: (($header-height - $size) / 2) 8px (($header-height - $size) / 2) 0;
}
> .title {
display: inline-block;
margin: 0;
line-height: $header-height;
> [data-icon] {
margin-right: 8px;
}
}
}
> .custom {
position: absolute;
top: 0;
}
}
</style>

View File

@ -1,5 +1,5 @@
/**
* App entry point
* Client entry point
*/
import Vue from 'vue';
@ -12,11 +12,13 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import VueHotkey from './scripts/hotkey';
import App from './app.vue';
import Deck from './deck.vue';
import MiOS from './mios';
import { version, langs, instanceName, getLocale } from './config';
import { version, langs, instanceName, getLocale, deckmode } from './config';
import PostFormDialog from './components/post-form-dialog.vue';
import Dialog from './components/dialog.vue';
import Menu from './components/menu.vue';
import Form from './components/form-window.vue';
import { router } from './router';
import { applyTheme, lightTheme } from './scripts/theme';
import { isDeviceDarkmode } from './scripts/is-device-darkmode';
@ -165,6 +167,7 @@ os.init(async () => {
i18n // TODO: 消せないか考える SEE: https://github.com/syuilo/misskey/pull/6396#discussion_r429511030
};
},
// TODO: ここらへんのメソッド全部Vuexに移したい
methods: {
api: (endpoint: string, data: { [x: string]: any } = {}, token?) => store.dispatch('api', { endpoint, data, token }),
signout: os.signout,
@ -194,6 +197,13 @@ os.init(async () => {
});
return p;
},
form(title, form) {
const vm = this.new(Form, { title, form });
return new Promise((res) => {
vm.$once('ok', result => res({ canceled: false, result }));
vm.$once('cancel', () => res({ canceled: true }));
});
},
post(opts, cb) {
if (!this.$store.getters.isSignedIn) return;
const vm = this.new(PostFormDialog, opts);
@ -210,11 +220,9 @@ os.init(async () => {
}
},
router: router,
render: createEl => createEl(App)
render: createEl => createEl(deckmode ? Deck : App)
});
os.app = app;
// マウント
app.$mount('#app');

View File

@ -1,7 +1,6 @@
// TODO: このファイル消したい
import autobind from 'autobind-decorator';
import Vue from 'vue';
import { EventEmitter } from 'eventemitter3';
import { apiUrl, version } from './config';
@ -14,8 +13,6 @@ import store from './store';
* Misskey Operating System
*/
export default class MiOS extends EventEmitter {
public app: Vue;
public store: ReturnType<typeof store>;
/**

View File

@ -19,7 +19,7 @@
<x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/>
<x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/>
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list" :antenna="antenna" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
</div>
</template>

View File

@ -15,14 +15,15 @@
<mk-remote-caution v-if="note.user.host != null" :href="note.url || note.uri" style="margin-bottom: var(--margin)"/>
<x-note :note="note" :key="note.id" :detail="true"/>
<div v-if="error">
<mk-error @retry="fetch()"/>
</div>
<button class="_panel _button" v-if="hasPrev && !showPrev" @click="showPrev = true" style="margin: var(--margin) auto 0 auto;"><fa :icon="faChevronDown"/></button>
<hr v-if="showPrev"/>
<x-notes v-if="showPrev" ref="prev" :pagination="prev" style="margin-top: var(--margin);"/>
</div>
<div v-if="error">
<mk-error @retry="fetch()"/>
</div>
</div>
</template>

View File

@ -51,6 +51,20 @@
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faColumns"/> {{ $t('deck') }}</div>
<div class="_content">
<mk-switch v-model="deckAlwaysShowMainColumn">
{{ $t('_deck.alwaysShowMainColumn') }}
</mk-switch>
</div>
<div class="_content">
<div>{{ $t('_deck.columnAlign') }}</div>
<mk-radio v-model="deckColumnAlign" value="left">{{ $t('left') }}</mk-radio>
<mk-radio v-model="deckColumnAlign" value="center">{{ $t('center') }}</mk-radio>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faCog"/> {{ $t('accessibility') }}</div>
<div class="_content">
@ -93,7 +107,7 @@
<script lang="ts">
import Vue from 'vue';
import { faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons';
import { faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute, faColumns } from '@fortawesome/free-solid-svg-icons';
import MkButton from '../../components/ui/button.vue';
import MkSwitch from '../../components/ui/switch.vue';
import MkSelect from '../../components/ui/select.vue';
@ -145,7 +159,7 @@ export default Vue.extend({
lang: localStorage.getItem('lang'),
fontSize: localStorage.getItem('fontSize'),
sounds,
faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute
faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute, faColumns
}
},
@ -195,6 +209,16 @@ export default Vue.extend({
set(value) { this.$store.commit('device/set', { key: 'fixedWidgetsPosition', value }); }
},
deckAlwaysShowMainColumn: {
get() { return this.$store.state.device.deckAlwaysShowMainColumn; },
set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); }
},
deckColumnAlign: {
get() { return this.$store.state.device.deckColumnAlign; },
set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); }
},
sfxVolume: {
get() { return this.$store.state.device.sfxVolume; },
set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); }

View File

@ -1,5 +1,5 @@
<template>
<div class="kjeftjfm">
<div class="kjeftjfm" v-size="[{ max: 500 }]">
<div class="with">
<button class="_button" @click="with_ = null" :class="{ active: with_ === null }">{{ $t('notes') }}</button>
<button class="_button" @click="with_ = 'replies'" :class="{ active: with_ === 'replies' }">{{ $t('notesAndReplies') }}</button>
@ -60,10 +60,6 @@ export default Vue.extend({
display: flex;
margin-bottom: var(--margin);
@media (max-width: 500px) {
font-size: 80%;
}
> button {
flex: 1;
padding: 11px 8px 8px 8px;
@ -75,5 +71,11 @@ export default Vue.extend({
}
}
}
&.max-width_500px {
> .with {
font-size: 80%;
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="mk-user-page" v-if="user">
<div class="mk-user-page" v-if="user" v-size="[{ max: 500 }]">
<portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
<portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
@ -118,6 +118,7 @@ import MkContainer from '../../components/ui/container.vue';
import MkRemoteCaution from '../../components/remote-caution.vue';
import Progress from '../../scripts/loading';
import parseAcct from '../../../misc/acct/parse';
import { getScrollPosition } from '../../scripts/scroll';
export default Vue.extend({
components: {
@ -168,12 +169,8 @@ export default Vue.extend({
mounted() {
window.requestAnimationFrame(this.parallaxLoop);
window.addEventListener('scroll', this.parallax, { passive: true });
document.addEventListener('touchmove', this.parallax, { passive: true });
this.$once('hook:beforeDestroy', () => {
window.cancelAnimationFrame(this.parallaxAnimationId);
window.removeEventListener('scroll', this.parallax);
document.removeEventListener('touchmove', this.parallax);
});
},
@ -205,7 +202,7 @@ export default Vue.extend({
const banner = this.$refs.banner as any;
if (banner == null) return;
const top = window.scrollY;
const top = getScrollPosition(this.$el);
if (top < 0) return;
@ -219,7 +216,6 @@ export default Vue.extend({
<style lang="scss" scoped>
.mk-user-page {
> .punished {
font-size: 0.8em;
padding: 16px;
@ -237,10 +233,6 @@ export default Vue.extend({
background-size: cover;
background-position: center;
@media (max-width: 500px) {
height: 140px;
}
> .banner {
height: 100%;
background-color: #4c5e6d;
@ -257,10 +249,6 @@ export default Vue.extend({
width: 100%;
height: 78px;
background: linear-gradient(transparent, rgba(#000, 0.7));
@media (max-width: 500px) {
display: none;
}
}
> .followed {
@ -308,10 +296,6 @@ export default Vue.extend({
box-sizing: border-box;
color: #fff;
@media (max-width: 500px) {
display: none;
}
> .name {
display: block;
margin: 0;
@ -343,10 +327,6 @@ export default Vue.extend({
font-weight: bold;
border-bottom: solid 1px var(--divider);
@media (max-width: 500px) {
display: block;
}
> .bottom {
> * {
display: inline-block;
@ -365,26 +345,12 @@ export default Vue.extend({
width: 120px;
height: 120px;
box-shadow: 1px 1px 3px rgba(#000, 0.2);
@media (max-width: 500px) {
top: 90px;
left: 0;
right: 0;
width: 92px;
height: 92px;
margin: auto;
}
}
> .description {
padding: 24px 24px 24px 154px;
font-size: 0.95em;
@media (max-width: 500px) {
padding: 16px;
text-align: center;
}
> .empty {
margin: 0;
opacity: 0.5;
@ -396,10 +362,6 @@ export default Vue.extend({
font-size: 0.9em;
border-top: solid 1px var(--divider);
@media (max-width: 500px) {
padding: 16px;
}
> .field {
display: flex;
padding: 0;
@ -436,10 +398,6 @@ export default Vue.extend({
padding: 24px;
border-top: solid 1px var(--divider);
@media (max-width: 500px) {
padding: 16px;
}
> a {
flex: 1;
text-align: center;
@ -473,5 +431,47 @@ export default Vue.extend({
> .content {
margin-bottom: var(--margin);
}
&.max-width_500px {
> .profile {
> .banner-container {
height: 140px;
> .fade {
display: none;
}
> .title {
display: none;
}
}
> .title {
display: block;
}
> .avatar {
top: 90px;
left: 0;
right: 0;
width: 92px;
height: 92px;
margin: auto;
}
> .description {
padding: 16px;
text-align: center;
}
> .fields {
padding: 16px;
}
> .status {
padding: 16px;
}
}
}
}
</style>

View File

@ -0,0 +1,26 @@
export type FormItem = {
label?: string;
type: 'string';
default: string | null;
hidden?: boolean;
multiline?: boolean;
} | {
label?: string;
type: 'number';
default: number | null;
hidden?: boolean;
step?: number;
} | {
label?: string;
type: 'boolean';
default: boolean | null;
hidden?: boolean;
} | {
label?: string;
type: 'enum';
default: string | null;
hidden?: boolean;
enum: string[];
};
export type Form = Record<string, FormItem>;

View File

@ -13,7 +13,7 @@ export default (opts) => ({
moreFetching: false,
inited: false,
more: false,
backed: false,
backed: false, // 遡り中か否か
isBackTop: false,
ilObserver: new IntersectionObserver(
(entries) => entries.some((entry) => entry.isIntersecting)

View File

@ -1,7 +1,7 @@
export function getScrollContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null;
const style = window.getComputedStyle(el);
if (style.getPropertyValue('overflow') === 'auto') {
const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる
return el;
} else {
return getScrollContainer(el.parentElement);

View File

@ -1,9 +1,10 @@
import Vuex from 'vuex';
import createPersistedState from 'vuex-persistedstate';
import * as nestedProperty from 'nested-property';
import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed } from '@fortawesome/free-solid-svg-icons';
import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
import { apiUrl } from './config';
import { apiUrl, deckmode } from './config';
import { erase } from '../prelude/array';
export const defaultSettings = {
tutorial: 0,
@ -35,7 +36,13 @@ export const defaultDeviceUserSettings = {
'explore',
'announcements',
'search',
'-',
'deck',
],
deck: {
columns: [],
layout: [],
},
};
export const defaultDeviceSettings = {
@ -50,6 +57,7 @@ export const defaultDeviceSettings = {
darkTheme: '8c539dc1-0fab-4d47-9194-39c508e9bfe1',
lightTheme: '4eea646f-7afa-4645-83e9-83af0333cd37',
darkMode: false,
deckMode: false,
syncDeviceDarkMode: true,
animation: true,
animatedMfm: true,
@ -60,6 +68,9 @@ export const defaultDeviceSettings = {
fixedWidgetsPosition: false,
roomGraphicsQuality: 'medium',
roomUseOrthographicCamera: true,
deckColumnAlign: 'left',
deckAlwaysShowMainColumn: true,
deckMainColumnPlace: 'left',
sfxVolume: 0.3,
sfxNote: 'syuilo/down',
sfxNoteMy: 'syuilo/up',
@ -197,6 +208,14 @@ export default () => new Vuex.Store({
get show() { return getters.isSignedIn; },
get to() { return `/@${state.i.username}/room`; },
},
deck: {
title: deckmode ? 'undeck' : 'deck',
icon: faColumns,
action: () => {
localStorage.setItem('deckmode', (!deckmode).toString());
location.reload();
},
},
}),
},
@ -399,6 +418,137 @@ export default () => new Vuex.Store({
w.data = x.data;
}
},
//#region Deck
addDeckColumn(state, column) {
if (column.name == undefined) column.name = null;
state.deck.columns.push(column);
state.deck.layout.push([column.id]);
},
removeDeckColumn(state, id) {
state.deck.columns = state.deck.columns.filter(c => c.id != id);
state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
},
swapDeckColumn(state, x) {
const a = x.a;
const b = x.b;
const aX = state.deck.layout.findIndex(ids => ids.indexOf(a) != -1);
const aY = state.deck.layout[aX].findIndex(id => id == a);
const bX = state.deck.layout.findIndex(ids => ids.indexOf(b) != -1);
const bY = state.deck.layout[bX].findIndex(id => id == b);
state.deck.layout[aX][aY] = b;
state.deck.layout[bX][bY] = a;
},
swapLeftDeckColumn(state, id) {
state.deck.layout.some((ids, i) => {
if (ids.indexOf(id) != -1) {
const left = state.deck.layout[i - 1];
if (left) {
// https://vuejs.org/v2/guide/list.html#Caveats
//state.deck.layout[i - 1] = state.deck.layout[i];
//state.deck.layout[i] = left;
state.deck.layout.splice(i - 1, 1, state.deck.layout[i]);
state.deck.layout.splice(i, 1, left);
}
return true;
}
});
},
swapRightDeckColumn(state, id) {
state.deck.layout.some((ids, i) => {
if (ids.indexOf(id) != -1) {
const right = state.deck.layout[i + 1];
if (right) {
// https://vuejs.org/v2/guide/list.html#Caveats
//state.deck.layout[i + 1] = state.deck.layout[i];
//state.deck.layout[i] = right;
state.deck.layout.splice(i + 1, 1, state.deck.layout[i]);
state.deck.layout.splice(i, 1, right);
}
return true;
}
});
},
swapUpDeckColumn(state, id) {
const ids = state.deck.layout.find(ids => ids.indexOf(id) != -1);
ids.some((x, i) => {
if (x == id) {
const up = ids[i - 1];
if (up) {
// https://vuejs.org/v2/guide/list.html#Caveats
//ids[i - 1] = id;
//ids[i] = up;
ids.splice(i - 1, 1, id);
ids.splice(i, 1, up);
}
return true;
}
});
},
swapDownDeckColumn(state, id) {
const ids = state.deck.layout.find(ids => ids.indexOf(id) != -1);
ids.some((x, i) => {
if (x == id) {
const down = ids[i + 1];
if (down) {
// https://vuejs.org/v2/guide/list.html#Caveats
//ids[i + 1] = id;
//ids[i] = down;
ids.splice(i + 1, 1, id);
ids.splice(i, 1, down);
}
return true;
}
});
},
stackLeftDeckColumn(state, id) {
const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
const left = state.deck.layout[i - 1];
if (left) state.deck.layout[i - 1].push(id);
state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
},
popRightDeckColumn(state, id) {
const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
state.deck.layout.splice(i + 1, 0, [id]);
state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
},
addDeckWidget(state, x) {
const column = state.deck.columns.find(c => c.id == x.id);
if (column == null) return;
if (column.widgets == null) column.widgets = [];
column.widgets.unshift(x.widget);
},
removeDeckWidget(state, x) {
const column = state.deck.columns.find(c => c.id == x.id);
if (column == null) return;
column.widgets = column.widgets.filter(w => w.id != x.widget.id);
},
renameDeckColumn(state, x) {
const column = state.deck.columns.find(c => c.id == x.id);
if (column == null) return;
column.name = x.name;
},
updateDeckColumn(state, x) {
let column = state.deck.columns.find(c => c.id == x.id);
if (column == null) return;
column = x;
},
//#endregion
}
},

View File

@ -3,7 +3,7 @@
:root {
--radius: 8px;
--marginFull: 16px;
--marginHalf: 8px;
--marginHalf: 10px;
--margin: var(--marginFull);
@ -25,7 +25,6 @@ html {
background-position: center;
color: var(--fg);
overflow: auto;
overflow-y: scroll;
&, * {
scrollbar-color: var(--scrollbarHandle) var(--panel);
@ -278,13 +277,14 @@ hr {
._panel {
position: relative;
z-index: 1;
background: var(--panel);
border-radius: var(--radius);
box-shadow: 0 0 0 1px var(--panelBorder);
overflow: hidden;
}
._widget ._list_ ._panel {
._close_ ._list_ > * {
box-shadow: 0 1px 0 0 var(--divider), 0 -1px 0 0 var(--divider);
border-radius: 0;
margin: 0 !important;
@ -348,31 +348,6 @@ hr {
& + ._content {
border-top: solid 1px var(--divider);
}
&._list {
padding: 16px;
@media (max-width: 500px) {
padding: 8px;
}
._listItem {
padding: 8px 16px;
border-radius: var(--radius);
@media (max-width: 500px) {
padding: 8px;
}
&:hover {
background: var(--listItemHoverBg);
}
> * {
pointer-events: none;
}
}
}
}
> ._footer {
@ -385,6 +360,21 @@ hr {
}
}
._narrow_ ._card {
> ._title {
padding: 16px;
font-size: 1em;
}
> ._content {
padding: 16px;
}
> ._footer {
padding: 16px;
}
}
._fullinfo {
padding: 64px 32px;
text-align: center;

View File

@ -26,8 +26,8 @@
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
panelBorder: 'rgba(0, 0, 0, 0)',
shadow: 'rgba(0, 0, 0, 0.1)',
header: 'rgba(20, 20, 20, 0.75)',
navBg: '@panel',
header: ':alpha<0.7<@bg',
navBg: '@bg',
navFg: '@fg',
navHoverFg: ':lighten<17<@fg',
navActive: '@accent',
@ -58,6 +58,7 @@
wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
badge: '#31b1ce',
messageBg: ':lighten<5<@bg',
deckColumnBorder: ':lighten<10<@panel',
X1: ':alpha<0<@bg',
X2: ':darken<2<@panel',
X3: 'rgba(255, 255, 255, 0.05)',

View File

@ -26,8 +26,8 @@
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
panelBorder: 'rgba(0, 0, 0, 0)',
shadow: 'rgba(0, 0, 0, 0.1)',
header: 'rgba(255, 255, 255, 0.75)',
navBg: '@panel',
header: ':alpha<0.7<@bg',
navBg: '@bg',
navFg: '@fg',
navHoverFg: ':darken<17<@fg',
navActive: '@accent',
@ -58,6 +58,7 @@
wallpaperOverlay: 'rgba(255, 255, 255, 0.5)',
badge: '#31b1ce',
messageBg: '@panel',
deckColumnBorder: ':darken<20<@panel',
X1: ':alpha<0<@bg',
X2: ':darken<2<@panel',
X3: 'rgba(0, 0, 0, 0.05)',

View File

@ -13,5 +13,6 @@
panelHeaderDivider: '@divider',
panelBorder: '@divider',
messageBg: '#1d1d1d',
deckColumnBorder: '@divider',
},
}

View File

@ -10,9 +10,11 @@
accent: 'rgb(206, 147, 191)',
bg: 'rgb(253, 242, 243)',
fg: 'rgb(161, 139, 146)',
divider: '#ece7e7',
renote: '@accent',
link: '@accent',
mention: '@accent',
hashtag: '@accent',
panelHeaderDivider: '@divider',
},
}

View File

@ -11,5 +11,6 @@
bg: 'rgb(220, 229, 232)',
fg: 'rgb(139, 153, 161)',
renote: '@accent',
panelHeaderDivider: '@divider',
},
}

View File

@ -8,7 +8,11 @@
base: 'light',
props: {
bg: '#f2f2f2',
header: ':alpha<0.7<@bg',
navBg: '@bg',
panelHeaderDivider: '@divider',
messageBg: '#dedede',
deckColumnBorder: '#cccccc',
},
}

View File

@ -1,18 +1,16 @@
<template>
<div>
<mk-container :show-header="props.design === 0" :naked="props.design === 2">
<template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template>
<template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template>
<mk-container :show-header="props.showHeader" :naked="props.transparent">
<template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template>
<template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template>
<div>
<mk-loading v-if="fetching"/>
<template v-else>
<x-calendar v-show="props.view === 0" :data="[].concat(activity)"/>
<x-chart v-show="props.view === 1" :data="[].concat(activity)"/>
</template>
</div>
</mk-container>
</div>
<div>
<mk-loading v-if="fetching"/>
<template v-else>
<x-calendar v-show="props.view === 0" :data="[].concat(activity)"/>
<x-chart v-show="props.view === 1" :data="[].concat(activity)"/>
</template>
</div>
</mk-container>
</template>
<script lang="ts">
@ -25,8 +23,19 @@ import XChart from './activity.chart.vue';
export default define({
name: 'activity',
props: () => ({
design: 0,
view: 0
showHeader: {
type: 'boolean',
default: true,
},
transparent: {
type: 'boolean',
default: false,
},
view: {
type: 'number',
default: 0,
hidden: true,
},
})
}).extend({
components: {
@ -57,14 +66,6 @@ export default define({
});
},
methods: {
func() {
if (this.props.design === 2) {
this.props.design = 0;
} else {
this.props.design++;
}
this.save();
},
toggleView() {
if (this.props.view === 1) {
this.props.view = 0;

View File

@ -1,5 +1,5 @@
<template>
<div class="mkw-calendar" :class="{ _panel: props.design === 0 }">
<div class="mkw-calendar" :class="{ _panel: !props.transparent }">
<div class="calendar" :data-is-holiday="isHoliday">
<p class="month-and-year">
<span class="year">{{ $t('yearX', { year }) }}</span>
@ -37,7 +37,10 @@ import define from './define';
export default define({
name: 'calendar',
props: () => ({
design: 0
transparent: {
type: 'boolean',
default: false,
},
})
}).extend({
data() {
@ -62,14 +65,6 @@ export default define({
clearInterval(this.clock);
},
methods: {
func() {
if (this.props.design === 2) {
this.props.design = 0;
} else {
this.props.design++;
}
this.save();
},
tick() {
const now = new Date();
const nd = now.getDate();

View File

@ -1,11 +1,9 @@
<template>
<div>
<mk-container :naked="props.style % 2 === 0" :show-header="false">
<div class="vubelbmv">
<mk-analog-clock class="clock" :smooth="props.style < 2"/>
</div>
</mk-container>
</div>
<mk-container :naked="props.transparent" :show-header="false">
<div class="vubelbmv">
<mk-analog-clock class="clock"/>
</div>
</mk-container>
</template>
<script lang="ts">
@ -16,19 +14,16 @@ import MkAnalogClock from '../components/analog-clock.vue';
export default define({
name: 'clock',
props: () => ({
style: 0
transparent: {
type: 'boolean',
default: false,
},
})
}).extend({
components: {
MkContainer,
MkAnalogClock
},
methods: {
func() {
this.props.style = (this.props.style + 1) % 4;
this.save();
}
}
});
</script>

View File

@ -1,6 +1,7 @@
import Vue from 'vue';
import { Form } from '../scripts/form';
export default function <T extends object>(data: {
export default function <T extends Form>(data: {
name: string;
props?: () => T;
}) {
@ -15,22 +16,22 @@ export default function <T extends object>(data: {
}
},
data() {
return {
bakedOldProps: null
};
},
computed: {
id(): string {
return this.widget.id;
},
props(): T {
props(): Record<string, any> {
return this.widget.data;
}
},
data() {
return {
bakedOldProps: null
};
},
created() {
this.mergeProps();
@ -45,11 +46,26 @@ export default function <T extends object>(data: {
const defaultProps = data.props();
for (const prop of Object.keys(defaultProps)) {
if (this.props.hasOwnProperty(prop)) continue;
Vue.set(this.props, prop, defaultProps[prop]);
Vue.set(this.props, prop, defaultProps[prop].default);
}
}
},
async setting() {
const form = data.props();
for (const item of Object.keys(form)) {
form[item].default = this.props[item];
}
const { canceled, result } = await this.$root.form(data.name, form);
if (canceled) return;
for (const key of Object.keys(result)) {
Vue.set(this.props, key, result[key]);
}
this.save();
},
save() {
this.$store.commit('deviceUser/updateWidget', this.widget);
}

View File

@ -0,0 +1,75 @@
<template>
<div class="mkw-digitalClock" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }">
<span>
<span v-text="hh"></span>
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
<span v-text="mm"></span>
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
<span v-text="ss"></span>
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }" v-if="props.showMs">:</span>
<span v-text="ms" v-if="props.showMs"></span>
</span>
</div>
</template>
<script lang="ts">
import define from './define';
export default define({
name: 'digitalClock',
props: () => ({
transparent: {
type: 'boolean',
default: false,
},
fontSize: {
type: 'number',
default: 1.5,
step: 0.1,
},
showMs: {
type: 'boolean',
default: true,
},
})
}).extend({
data() {
return {
clock: null,
hh: null,
mm: null,
ss: null,
ms: null,
showColon: true,
};
},
created() {
this.tick();
this.$watch('props.showMs', () => {
if (this.clock) clearInterval(this.clock);
this.clock = setInterval(this.tick, this.props.showMs ? 10 : 1000);
}, { immediate: true });
},
beforeDestroy() {
clearInterval(this.clock);
},
methods: {
tick() {
const now = new Date();
this.hh = now.getHours().toString().padStart(2, '0');
this.mm = now.getMinutes().toString().padStart(2, '0');
this.ss = now.getSeconds().toString().padStart(2, '0');
this.ms = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
this.showColon = now.getSeconds() % 2 === 0;
}
}
});
</script>
<style lang="scss" scoped>
.mkw-digitalClock {
padding: 16px 0;
font-family: Lucida Console, Courier, monospace;
text-align: center;
}
</style>

View File

@ -10,3 +10,17 @@ Vue.component('mkw-trends', () => import('./trends.vue').then(m => m.default));
Vue.component('mkw-clock', () => import('./clock.vue').then(m => m.default));
Vue.component('mkw-activity', () => import('./activity.vue').then(m => m.default));
Vue.component('mkw-photos', () => import('./photos.vue').then(m => m.default));
Vue.component('mkw-digitalClock', () => import('./digital-clock.vue').then(m => m.default));
export const widgets = [
'memo',
'notifications',
'timeline',
'calendar',
'rss',
'trends',
'clock',
'activity',
'photos',
'digitalClock',
];

View File

@ -1,14 +1,12 @@
<template>
<div>
<mk-container :show-header="!props.compact">
<template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template>
<mk-container :show-header="props.showHeader">
<template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template>
<div class="otgbylcu">
<textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea>
<button @click="saveMemo" :disabled="!changed" class="_buttonPrimary">{{ $t('save') }}</button>
</div>
</mk-container>
</div>
<div class="otgbylcu">
<textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea>
<button @click="saveMemo" :disabled="!changed" class="_buttonPrimary">{{ $t('save') }}</button>
</div>
</mk-container>
</template>
<script lang="ts">
@ -19,10 +17,12 @@ import define from './define';
export default define({
name: 'memo',
props: () => ({
compact: false
showHeader: {
type: 'boolean',
default: true,
},
})
}).extend({
components: {
MkContainer
},
@ -45,11 +45,6 @@ export default define({
},
methods: {
func() {
this.props.compact = !this.props.compact;
this.save();
},
onChange() {
this.changed = true;
clearTimeout(this.timeoutId);

View File

@ -1,13 +1,11 @@
<template>
<div class="mkw-notifications" :style="`flex-basis: calc(${basis}% - var(--margin)); height: ${previewHeight}px;`">
<mk-container :show-header="!props.compact" class="container">
<template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template>
<mk-container :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true">
<template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template>
<div>
<x-notifications/>
</div>
</mk-container>
</div>
<div>
<x-notifications/>
</div>
</mk-container>
</template>
<script lang="ts">
@ -16,17 +14,19 @@ import MkContainer from '../components/ui/container.vue';
import XNotifications from '../components/notifications.vue';
import define from './define';
const basisSteps = [25, 50, 75, 100]
const previewHeights = [200, 300, 400, 500]
export default define({
name: 'notifications',
props: () => ({
compact: false,
basisStep: 0
showHeader: {
type: 'boolean',
default: true,
},
height: {
type: 'number',
default: 300,
},
})
}).extend({
components: {
MkContainer,
XNotifications,
@ -37,47 +37,5 @@ export default define({
faBell
};
},
computed: {
basis(): number {
return basisSteps[this.props.basisStep] || 25
},
previewHeight(): number {
return previewHeights[this.props.basisStep] || 200
}
},
methods: {
func() {
if (this.props.basisStep === basisSteps.length - 1) {
this.props.basisStep = 0
this.props.compact = !this.props.compact;
} else {
this.props.basisStep += 1
}
this.save();
}
}
});
</script>
<style lang="scss">
.mkw-notifications {
flex-grow: 1;
flex-shrink: 0;
min-height: 0; // https://www.gwtcenter.com/min-height-required-on-firefox-flexbox
.container {
display: flex;
flex-direction: column;
height: 100%;
> div {
overflow: auto;
flex-grow: 1;
}
}
}
</style>

View File

@ -1,19 +1,17 @@
<template>
<div>
<mk-container :show-header="props.design === 0" :naked="props.design === 2" :class="$style.root" :data-melt="props.design === 2">
<template #header><fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template>
<mk-container :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent">
<template #header><fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template>
<div class="">
<mk-loading v-if="fetching"/>
<div v-else :class="$style.stream">
<div v-for="(image, i) in images" :key="i"
:class="$style.img"
:style="`background-image: url(${thumbnail(image)})`"
></div>
</div>
<div class="">
<mk-loading v-if="fetching"/>
<div v-else :class="$style.stream">
<div v-for="(image, i) in images" :key="i"
:class="$style.img"
:style="`background-image: url(${thumbnail(image)})`"
></div>
</div>
</mk-container>
</div>
</div>
</mk-container>
</template>
<script lang="ts">
@ -25,7 +23,14 @@ import { getStaticImageUrl } from '../scripts/get-static-image-url';
export default define({
name: 'photos',
props: () => ({
design: 0,
showHeader: {
type: 'boolean',
default: true,
},
transparent: {
type: 'boolean',
default: false,
},
})
}).extend({
components: {
@ -63,15 +68,6 @@ export default define({
}
},
func() {
if (this.props.design === 2) {
this.props.design = 0;
} else {
this.props.design++;
}
this.save();
},
thumbnail(image: any): string {
return this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(image.thumbnailUrl)
@ -82,7 +78,7 @@ export default define({
</script>
<style lang="scss" module>
.root[data-melt] {
.root[data-transparent] {
.stream {
padding: 0;
}

View File

@ -1,17 +1,15 @@
<template>
<div>
<mk-container :show-header="!props.compact">
<template #header><fa :icon="faRssSquare"/>RSS</template>
<template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template>
<mk-container :show-header="props.showHeader">
<template #header><fa :icon="faRssSquare"/>RSS</template>
<template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template>
<div class="ekmkgxbj">
<mk-loading v-if="fetching"/>
<div class="feed" v-else>
<a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
</div>
<div class="ekmkgxbj">
<mk-loading v-if="fetching"/>
<div class="feed" v-else>
<a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
</div>
</mk-container>
</div>
</div>
</mk-container>
</template>
<script lang="ts">
@ -22,8 +20,14 @@ import define from './define';
export default define({
name: 'rss',
props: () => ({
compact: false,
url: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'
showHeader: {
type: 'boolean',
default: true,
},
url: {
type: 'string',
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
},
})
}).extend({
components: {
@ -40,15 +44,12 @@ export default define({
mounted() {
this.fetch();
this.clock = setInterval(this.fetch, 60000);
this.$watch('props.url', this.fetch);
},
beforeDestroy() {
clearInterval(this.clock);
},
methods: {
func() {
this.props.compact = !this.props.compact;
this.save();
},
fetch() {
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, {
}).then(res => {
@ -58,20 +59,6 @@ export default define({
});
});
},
setting() {
this.$root.dialog({
title: 'URL',
input: {
type: 'url',
default: this.props.url
}
}).then(({ canceled, result: url }) => {
if (canceled) return;
this.props.url = url;
this.save();
this.fetch();
});
}
}
});
</script>

View File

@ -1,24 +1,22 @@
<template>
<div class="mkw-timeline" :style="`flex-basis: calc(${basis}% - var(--margin)); height: ${previewHeight}px;`">
<mk-container :show-header="!props.compact" class="container">
<template #header>
<button @click="choose" class="_button">
<fa v-if="props.src === 'home'" :icon="faHome"/>
<fa v-if="props.src === 'local'" :icon="faComments"/>
<fa v-if="props.src === 'social'" :icon="faShareAlt"/>
<fa v-if="props.src === 'global'" :icon="faGlobe"/>
<fa v-if="props.src === 'list'" :icon="faListUl"/>
<fa v-if="props.src === 'antenna'" :icon="faSatellite"/>
<span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span>
<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
</button>
</template>
<mk-container :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true">
<template #header>
<button @click="choose" class="_button">
<fa v-if="props.src === 'home'" :icon="faHome"/>
<fa v-if="props.src === 'local'" :icon="faComments"/>
<fa v-if="props.src === 'social'" :icon="faShareAlt"/>
<fa v-if="props.src === 'global'" :icon="faGlobe"/>
<fa v-if="props.src === 'list'" :icon="faListUl"/>
<fa v-if="props.src === 'antenna'" :icon="faSatellite"/>
<span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span>
<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
</button>
</template>
<div>
<x-timeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list" :antenna="props.antenna"/>
</div>
</mk-container>
</div>
<div>
<x-timeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/>
</div>
</mk-container>
</template>
<script lang="ts">
@ -28,19 +26,25 @@ import MkContainer from '../components/ui/container.vue';
import XTimeline from '../components/timeline.vue';
import define from './define';
const basisSteps = [25, 50, 75, 100]
const previewHeights = [200, 300, 400, 500]
export default define({
name: 'timeline',
props: () => ({
src: 'home',
list: null,
compact: false,
basisStep: 0
showHeader: {
type: 'boolean',
default: true,
},
src: {
type: 'string',
default: 'home',
hidden: true,
},
list: {
type: 'object',
default: null,
hidden: true,
},
})
}).extend({
components: {
MkContainer,
XTimeline,
@ -53,28 +57,7 @@ export default define({
};
},
computed: {
basis(): number {
return basisSteps[this.props.basisStep] || 25
},
previewHeight(): number {
return previewHeights[this.props.basisStep] || 200
}
},
methods: {
func() {
if (this.props.basisStep === basisSteps.length - 1) {
this.props.basisStep = 0
this.props.compact = !this.props.compact;
} else {
this.props.basisStep += 1
}
this.save();
},
async choose(ev) {
this.menuOpened = true;
const [antennas, lists] = await Promise.all([
@ -129,22 +112,3 @@ export default define({
}
});
</script>
<style lang="scss">
.mkw-timeline {
flex-grow: 1;
flex-shrink: 0;
min-height: 0; // https://www.gwtcenter.com/min-height-required-on-firefox-flexbox
.container {
display: flex;
flex-direction: column;
height: 100%;
> div {
overflow: auto;
flex-grow: 1;
}
}
}
</style>

View File

@ -1,22 +1,20 @@
<template>
<div>
<mk-container :show-header="!props.compact">
<template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template>
<mk-container :show-header="props.showHeader">
<template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template>
<div class="wbrkwala">
<mk-loading v-if="fetching"/>
<transition-group tag="div" name="chart" class="tags" v-else>
<div v-for="stat in stats" :key="stat.tag">
<div class="tag">
<router-link class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link>
<p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
</div>
<x-chart class="chart" :src="stat.chart"/>
<div class="wbrkwala">
<mk-loading v-if="fetching"/>
<transition-group tag="div" name="chart" class="tags" v-else>
<div v-for="stat in stats" :key="stat.tag">
<div class="tag">
<router-link class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link>
<p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
</div>
</transition-group>
</div>
</mk-container>
</div>
<x-chart class="chart" :src="stat.chart"/>
</div>
</transition-group>
</div>
</mk-container>
</template>
<script lang="ts">
@ -28,7 +26,10 @@ import XChart from './trends.chart.vue';
export default define({
name: 'hashtags',
props: () => ({
compact: false
showHeader: {
type: 'boolean',
default: true,
},
})
}).extend({
components: {
@ -49,10 +50,6 @@ export default define({
clearInterval(this.clock);
},
methods: {
func() {
this.props.compact = !this.props.compact;
this.save();
},
fetch() {
this.$root.api('hashtags/trend').then(stats => {
this.stats = stats;