summaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2020-07-11 10:13:11 +0900
committerGitHub <noreply@github.com>2020-07-11 10:13:11 +0900
commitcf3fc97202588e835ade5d6ab1a3c087e46958ad (patch)
treeb3fe472b455bf913a47df4d41b1363c7122bf1d9 /src/client
parentタイムライン上でTwitterウィジットを展開できるようにな... (diff)
downloadmisskey-cf3fc97202588e835ade5d6ab1a3c087e46958ad.tar.gz
misskey-cf3fc97202588e835ade5d6ab1a3c087e46958ad.tar.bz2
misskey-cf3fc97202588e835ade5d6ab1a3c087e46958ad.zip
Deck (#6504)
* 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
Diffstat (limited to 'src/client')
-rw-r--r--src/client/app.vue474
-rw-r--r--src/client/components/deck/antenna-column.vue80
-rw-r--r--src/client/components/deck/column-core.vue50
-rw-r--r--src/client/components/deck/column.vue426
-rw-r--r--src/client/components/deck/direct-column.vue39
-rw-r--r--src/client/components/deck/list-column.vue87
-rw-r--r--src/client/components/deck/mentions-column.vue39
-rw-r--r--src/client/components/deck/notifications-column.vue69
-rw-r--r--src/client/components/deck/tl-column.vue141
-rw-r--r--src/client/components/deck/widgets-column.vue151
-rw-r--r--src/client/components/error.vue2
-rw-r--r--src/client/components/form-window.vue71
-rw-r--r--src/client/components/modal.vue9
-rw-r--r--src/client/components/note-header.vue1
-rw-r--r--src/client/components/note.vue118
-rw-r--r--src/client/components/sidebar.vue488
-rw-r--r--src/client/components/timeline.vue12
-rw-r--r--src/client/components/ui/container.vue38
-rw-r--r--src/client/components/ui/input.vue15
-rw-r--r--src/client/components/ui/select.vue6
-rw-r--r--src/client/components/ui/switch.vue2
-rw-r--r--src/client/components/ui/textarea.vue4
-rw-r--r--src/client/components/window.vue7
-rw-r--r--src/client/config.ts1
-rw-r--r--src/client/deck.vue312
-rw-r--r--src/client/init.ts18
-rw-r--r--src/client/mios.ts3
-rw-r--r--src/client/pages/index.home.vue2
-rw-r--r--src/client/pages/note.vue7
-rw-r--r--src/client/pages/preferences/index.vue28
-rw-r--r--src/client/pages/user/index.timeline.vue12
-rw-r--r--src/client/pages/user/index.vue90
-rw-r--r--src/client/scripts/form.ts26
-rw-r--r--src/client/scripts/paging.ts2
-rw-r--r--src/client/scripts/scroll.ts4
-rw-r--r--src/client/store.ts154
-rw-r--r--src/client/style.scss46
-rw-r--r--src/client/themes/_dark.json55
-rw-r--r--src/client/themes/_light.json55
-rw-r--r--src/client/themes/black.json51
-rw-r--r--src/client/themes/lilac.json52
-rw-r--r--src/client/themes/rainy.json51
-rw-r--r--src/client/themes/white.json54
-rw-r--r--src/client/widgets/activity.vue47
-rw-r--r--src/client/widgets/calendar.vue15
-rw-r--r--src/client/widgets/clock.vue23
-rw-r--r--src/client/widgets/define.ts34
-rw-r--r--src/client/widgets/digital-clock.vue75
-rw-r--r--src/client/widgets/index.ts14
-rw-r--r--src/client/widgets/memo.vue27
-rw-r--r--src/client/widgets/notifications.vue70
-rw-r--r--src/client/widgets/photos.vue44
-rw-r--r--src/client/widgets/rss.vue49
-rw-r--r--src/client/widgets/timeline.vue98
-rw-r--r--src/client/widgets/trends.vue39
55 files changed, 2680 insertions, 907 deletions
diff --git a/src/client/app.vue b/src/client/app.vue
index f1a8340490..4f39183564 100644
--- a/src/client/app.vue
+++ b/src/client/app.vue
@@ -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;
diff --git a/src/client/components/deck/antenna-column.vue b/src/client/components/deck/antenna-column.vue
new file mode 100644
index 0000000000..83fe14f2cc
--- /dev/null
+++ b/src/client/components/deck/antenna-column.vue
@@ -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>
diff --git a/src/client/components/deck/column-core.vue b/src/client/components/deck/column-core.vue
new file mode 100644
index 0000000000..44f19e7eda
--- /dev/null
+++ b/src/client/components/deck/column-core.vue
@@ -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>
diff --git a/src/client/components/deck/column.vue b/src/client/components/deck/column.vue
new file mode 100644
index 0000000000..f7620e5749
--- /dev/null
+++ b/src/client/components/deck/column.vue
@@ -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>
diff --git a/src/client/components/deck/direct-column.vue b/src/client/components/deck/direct-column.vue
new file mode 100644
index 0000000000..f340048d6a
--- /dev/null
+++ b/src/client/components/deck/direct-column.vue
@@ -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>
diff --git a/src/client/components/deck/list-column.vue b/src/client/components/deck/list-column.vue
new file mode 100644
index 0000000000..a3576e8d67
--- /dev/null
+++ b/src/client/components/deck/list-column.vue
@@ -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>
diff --git a/src/client/components/deck/mentions-column.vue b/src/client/components/deck/mentions-column.vue
new file mode 100644
index 0000000000..19e49d2a89
--- /dev/null
+++ b/src/client/components/deck/mentions-column.vue
@@ -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>
diff --git a/src/client/components/deck/notifications-column.vue b/src/client/components/deck/notifications-column.vue
new file mode 100644
index 0000000000..58873aa130
--- /dev/null
+++ b/src/client/components/deck/notifications-column.vue
@@ -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>
diff --git a/src/client/components/deck/tl-column.vue b/src/client/components/deck/tl-column.vue
new file mode 100644
index 0000000000..c3ee67af3a
--- /dev/null
+++ b/src/client/components/deck/tl-column.vue
@@ -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>
diff --git a/src/client/components/deck/widgets-column.vue b/src/client/components/deck/widgets-column.vue
new file mode 100644
index 0000000000..37b17451ec
--- /dev/null
+++ b/src/client/components/deck/widgets-column.vue
@@ -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-shadowを利用した1px 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>
diff --git a/src/client/components/error.vue b/src/client/components/error.vue
index b1d91fb3ef..90efa700b2 100644
--- a/src/client/components/error.vue
+++ b/src/client/components/error.vue
@@ -40,7 +40,7 @@ export default Vue.extend({
> img {
vertical-align: bottom;
- height: 150px;
+ height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}
diff --git a/src/client/components/form-window.vue b/src/client/components/form-window.vue
new file mode 100644
index 0000000000..25eee91647
--- /dev/null
+++ b/src/client/components/form-window.vue
@@ -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>
diff --git a/src/client/components/modal.vue b/src/client/components/modal.vue
index 1a9d98a8cc..f941d4d503 100644
--- a/src/client/components/modal.vue
+++ b/src/client/components/modal.vue
@@ -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 {
diff --git a/src/client/components/note-header.vue b/src/client/components/note-header.vue
index 93cf2cdf39..039287818f 100644
--- a/src/client/components/note-header.vue
+++ b/src/client/components/note-header.vue
@@ -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;
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index 118fef661c..badb9f12f3 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -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>
diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue
new file mode 100644
index 0000000000..3ddef7d127
--- /dev/null
+++ b/src/client/components/sidebar.vue
@@ -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>
diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue
index bd1901a624..ce0fd95caf 100644
--- a/src/client/components/timeline.vue
+++ b/src/client/components/timeline.vue
@@ -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);
diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue
index 3fed1f65c7..6a718439aa 100644
--- a/src/client/components/ui/container.vue
+++ b/src/client/components/ui/container.vue
@@ -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>
diff --git a/src/client/components/ui/input.vue b/src/client/components/ui/input.vue
index c9f62e3cc0..d5317db7f9 100644
--- a/src/client/components/ui/input.vue
+++ b/src/client/components/ui/input.vue
@@ -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;
diff --git a/src/client/components/ui/select.vue b/src/client/components/ui/select.vue
index ce21949713..55f76553a7 100644
--- a/src/client/components/ui/select.vue
+++ b/src/client/components/ui/select.vue
@@ -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;
diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue
index 18a2ec33f1..9652a01024 100644
--- a/src/client/components/ui/switch.vue
+++ b/src/client/components/ui/switch.vue
@@ -5,7 +5,7 @@
role="switch"
:aria-checked="checked"
:aria-disabled="disabled"
- @click="toggle"
+ @click.prevent="toggle"
>
<input
type="checkbox"
diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue
index fab307a202..a42813ee64 100644
--- a/src/client/components/ui/textarea.vue
+++ b/src/client/components/ui/textarea.vue
@@ -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;
diff --git a/src/client/components/window.vue b/src/client/components/window.vue
index db13985181..a0bff869b9 100644
--- a/src/client/components/window.vue
+++ b/src/client/components/window.vue
@@ -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() {
diff --git a/src/client/config.ts b/src/client/config.ts
index b9a4766188..badb695245 100644
--- a/src/client/config.ts
+++ b/src/client/config.ts
@@ -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';
diff --git a/src/client/deck.vue b/src/client/deck.vue
new file mode 100644
index 0000000000..669719ba8e
--- /dev/null
+++ b/src/client/deck.vue
@@ -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>
diff --git a/src/client/init.ts b/src/client/init.ts
index 21f233cc91..d00b4f5cca 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -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');
diff --git a/src/client/mios.ts b/src/client/mios.ts
index c54b6fff87..efeb630d7e 100644
--- a/src/client/mios.ts
+++ b/src/client/mios.ts
@@ -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>;
/**
diff --git a/src/client/pages/index.home.vue b/src/client/pages/index.home.vue
index 17d07e6084..2059b34ac3 100644
--- a/src/client/pages/index.home.vue
+++ b/src/client/pages/index.home.vue
@@ -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>
diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue
index 48629a4ebe..5464875dfb 100644
--- a/src/client/pages/note.vue
+++ b/src/client/pages/note.vue
@@ -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>
diff --git a/src/client/pages/preferences/index.vue b/src/client/pages/preferences/index.vue
index 92d745a846..ffc8858764 100644
--- a/src/client/pages/preferences/index.vue
+++ b/src/client/pages/preferences/index.vue
@@ -52,6 +52,20 @@
</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">
<mk-switch v-model="autoReload">
@@ -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) }); }
diff --git a/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue
index 1878a9b1f3..f03c4adf8d 100644
--- a/src/client/pages/user/index.timeline.vue
+++ b/src/client/pages/user/index.timeline.vue
@@ -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>
diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue
index 75f61a0c0c..20eaca3687 100644
--- a/src/client/pages/user/index.vue
+++ b/src/client/pages/user/index.vue
@@ -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>
diff --git a/src/client/scripts/form.ts b/src/client/scripts/form.ts
new file mode 100644
index 0000000000..3cf062be2a
--- /dev/null
+++ b/src/client/scripts/form.ts
@@ -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>;
diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts
index 1f302753e1..8efff7aa41 100644
--- a/src/client/scripts/paging.ts
+++ b/src/client/scripts/paging.ts
@@ -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)
diff --git a/src/client/scripts/scroll.ts b/src/client/scripts/scroll.ts
index 76881bbde1..f32e50cdc7 100644
--- a/src/client/scripts/scroll.ts
+++ b/src/client/scripts/scroll.ts
@@ -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);
diff --git a/src/client/store.ts b/src/client/store.ts
index eee3f59618..5eff0567a8 100644
--- a/src/client/store.ts
+++ b/src/client/store.ts
@@ -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
}
},
diff --git a/src/client/style.scss b/src/client/style.scss
index 3faecee430..cc650ab123 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -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;
diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5
index 9b80128600..4e5225db36 100644
--- a/src/client/themes/_dark.json5
+++ b/src/client/themes/_dark.json5
@@ -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)',
diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5
index e0b6d3cd6f..2317ddef65 100644
--- a/src/client/themes/_light.json5
+++ b/src/client/themes/_light.json5
@@ -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)',
diff --git a/src/client/themes/black.json5 b/src/client/themes/black.json5
index 33a9050f66..3504f15932 100644
--- a/src/client/themes/black.json5
+++ b/src/client/themes/black.json5
@@ -13,5 +13,6 @@
panelHeaderDivider: '@divider',
panelBorder: '@divider',
messageBg: '#1d1d1d',
+ deckColumnBorder: '@divider',
},
}
diff --git a/src/client/themes/lilac.json5 b/src/client/themes/lilac.json5
index 44e2591512..084f3fc406 100644
--- a/src/client/themes/lilac.json5
+++ b/src/client/themes/lilac.json5
@@ -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',
},
}
diff --git a/src/client/themes/rainy.json5 b/src/client/themes/rainy.json5
index 0ad6338295..a7dc181643 100644
--- a/src/client/themes/rainy.json5
+++ b/src/client/themes/rainy.json5
@@ -11,5 +11,6 @@
bg: 'rgb(220, 229, 232)',
fg: 'rgb(139, 153, 161)',
renote: '@accent',
+ panelHeaderDivider: '@divider',
},
}
diff --git a/src/client/themes/white.json5 b/src/client/themes/white.json5
index 5e2e1d7300..4c3db53acd 100644
--- a/src/client/themes/white.json5
+++ b/src/client/themes/white.json5
@@ -8,7 +8,11 @@
base: 'light',
props: {
+ bg: '#f2f2f2',
+ header: ':alpha<0.7<@bg',
+ navBg: '@bg',
panelHeaderDivider: '@divider',
messageBg: '#dedede',
+ deckColumnBorder: '#cccccc',
},
}
diff --git a/src/client/widgets/activity.vue b/src/client/widgets/activity.vue
index 4fdd81ae52..58b1631367 100644
--- a/src/client/widgets/activity.vue
+++ b/src/client/widgets/activity.vue
@@ -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;
diff --git a/src/client/widgets/calendar.vue b/src/client/widgets/calendar.vue
index a29f73d3c6..8ef74ff744 100644
--- a/src/client/widgets/calendar.vue
+++ b/src/client/widgets/calendar.vue
@@ -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();
diff --git a/src/client/widgets/clock.vue b/src/client/widgets/clock.vue
index 8e61898033..6388324125 100644
--- a/src/client/widgets/clock.vue
+++ b/src/client/widgets/clock.vue
@@ -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>
diff --git a/src/client/widgets/define.ts b/src/client/widgets/define.ts
index 96b1b4ab56..107045bf4b 100644
--- a/src/client/widgets/define.ts
+++ b/src/client/widgets/define.ts
@@ -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);
}
diff --git a/src/client/widgets/digital-clock.vue b/src/client/widgets/digital-clock.vue
new file mode 100644
index 0000000000..0e68fe0ff4
--- /dev/null
+++ b/src/client/widgets/digital-clock.vue
@@ -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>
diff --git a/src/client/widgets/index.ts b/src/client/widgets/index.ts
index 878d42c0c3..2d27d27e58 100644
--- a/src/client/widgets/index.ts
+++ b/src/client/widgets/index.ts
@@ -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',
+];
diff --git a/src/client/widgets/memo.vue b/src/client/widgets/memo.vue
index cdc716b9fa..0d319b225e 100644
--- a/src/client/widgets/memo.vue
+++ b/src/client/widgets/memo.vue
@@ -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);
diff --git a/src/client/widgets/notifications.vue b/src/client/widgets/notifications.vue
index 39fc8a9361..24d7fe4200 100644
--- a/src/client/widgets/notifications.vue
+++ b/src/client/widgets/notifications.vue
@@ -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>
diff --git a/src/client/widgets/photos.vue b/src/client/widgets/photos.vue
index 6e4e43a565..2b8399df9b 100644
--- a/src/client/widgets/photos.vue
+++ b/src/client/widgets/photos.vue
@@ -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;
}
diff --git a/src/client/widgets/rss.vue b/src/client/widgets/rss.vue
index 4e57281e9f..3a76c8fb4f 100644
--- a/src/client/widgets/rss.vue
+++ b/src/client/widgets/rss.vue
@@ -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>
diff --git a/src/client/widgets/timeline.vue b/src/client/widgets/timeline.vue
index 6331311828..fb7486cb70 100644
--- a/src/client/widgets/timeline.vue
+++ b/src/client/widgets/timeline.vue
@@ -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>
diff --git a/src/client/widgets/trends.vue b/src/client/widgets/trends.vue
index 61f5bfbd32..d4a4b2d289 100644
--- a/src/client/widgets/trends.vue
+++ b/src/client/widgets/trends.vue
@@ -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;