feat: restore old deck ui

This commit is contained in:
sim1222 2022-07-17 06:49:25 +09:00
parent 8d1f90038b
commit 38a53b95ec
23 changed files with 2625 additions and 1 deletions

View File

@ -891,6 +891,7 @@ activeEmailValidationDescription: "ユーザーのメールアドレスのバリ
navbar: "ナビゲーションバー" navbar: "ナビゲーションバー"
shuffle: "シャッフル" shuffle: "シャッフル"
account: "アカウント" account: "アカウント"
deckOld: "旧デッキ"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"

View File

@ -835,6 +835,8 @@ emojiStretch: "自動で伸縮しない"
emojiGenerate: "生成" emojiGenerate: "生成"
emojiColor: "カラーコード" emojiColor: "カラーコード"
emojiApproval: "絵文字を登録" emojiApproval: "絵文字を登録"
deckOld: "旧デッキ"
_emailUnavailable: _emailUnavailable:
used: "既に使用されているにゃ" used: "既に使用されているにゃ"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "12.116.0-simkey-v1", "version": "12.116.0-simkey-v2",
"codename": "indigo", "codename": "indigo",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -172,6 +172,7 @@ import { getAccountFromId } from '@/scripts/get-account-from-id';
window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) : window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
ui === 'deckold' ? defineAsyncComponent(() => import('@/ui/deckold.vue')) :
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
defineAsyncComponent(() => import('@/ui/universal.vue')), defineAsyncComponent(() => import('@/ui/universal.vue')),
); );

View File

@ -115,6 +115,13 @@ export const navbarItemDef = reactive({
localStorage.setItem('ui', 'deck'); localStorage.setItem('ui', 'deck');
unisonReload(); unisonReload();
}, },
}, {
text: i18n.ts.deckOld,
active: ui === 'deckold',
action: () => {
localStorage.setItem('ui', 'deckold');
unisonReload();
},
}, { }, {
text: i18n.ts.classic, text: i18n.ts.classic,
active: ui === 'classic', active: ui === 'classic',

View File

@ -0,0 +1,123 @@
<template>
<component :is="popup.component"
v-for="popup in popups"
:key="popup.id"
v-bind="popup.props"
v-on="popup.events"
/>
<XUpload v-if="uploads.length > 0"/>
<XStreamIndicator/>
<div v-if="pendingApiRequestsCount > 0" id="wait"></div>
<div v-if="dev" id="devTicker"><span>DEV BUILD</span></div>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from 'vue';
import { popup, popups, pendingApiRequestsCount } from '@/os';
import { uploads } from '@/scripts/upload';
import * as sound from '@/scripts/sound';
import { $i } from '@/account';
import { swInject } from './sw-inject';
import { stream } from '@/stream';
export default defineComponent({
components: {
XStreamIndicator: defineAsyncComponent(() => import('./stream-indicator.vue')),
XUpload: defineAsyncComponent(() => import('./upload.vue')),
},
setup() {
const onNotification = notification => {
if ($i.mutingNotificationTypes.includes(notification.type)) return;
if (document.visibilityState === 'visible') {
stream.send('readNotification', {
id: notification.id
});
popup(defineAsyncComponent(() => import('@/components/notification-toast.vue')), {
notification
}, {}, 'closed');
}
sound.play('notification');
};
if ($i) {
const connection = stream.useChannel('main', null, 'UI');
connection.on('notification', onNotification);
//#region Listen message from SW
if ('serviceWorker' in navigator) {
swInject();
}
}
return {
uploads,
popups,
pendingApiRequestsCount,
dev: _DEV_,
};
},
});
</script>
<style lang="scss">
@keyframes dev-ticker-blink {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes progress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#wait {
display: block;
position: fixed;
z-index: 4000000;
top: 15px;
right: 15px;
&:before {
content: "";
display: block;
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: var(--accent);
border-left-color: var(--accent);
border-radius: 50%;
animation: progress-spinner 400ms linear infinite;
}
}
#devTicker {
position: fixed;
top: 0;
left: 0;
z-index: 2147483647;
color: #ff0;
background: rgba(0, 0, 0, 0.5);
padding: 4px 5px;
font-size: 14px;
pointer-events: none;
user-select: none;
> span {
animation: dev-ticker-blink 2s infinite;
}
}
</style>

View File

@ -0,0 +1,225 @@
<template>
<div class="kmwsukvl">
<div>
<button v-click-anime class="item _button account" @click="openAccountMenu">
<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
</button>
<MkA v-click-anime class="item index" active-class="active" to="/" exact>
<i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span>
</MkA>
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
<component :is="menuDef[item].to ? 'MkA' : 'button'" v-else-if="menuDef[item] && (menuDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" :to="menuDef[item].to" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}">
<i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span>
<span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span>
</component>
</template>
<div class="divider"></div>
<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin">
<i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span>
</MkA>
<button v-click-anime class="item _button" @click="more">
<i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span>
<span v-if="otherMenuItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
</button>
<MkA v-click-anime class="item" active-class="active" to="/settings">
<i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span>
</MkA>
<button class="item _button post" data-cy-open-post-form @click="post">
<i class="fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue';
import { host } from '@/config';
import { search } from '@/scripts/search';
import * as os from '@/os';
import { navbarItemDef } from '@/navbar';
import { openAccountMenu as openAccountMenu_ } from '@/account';
import { defaultStore } from '@/store';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
const menu = toRef(defaultStore.state, 'menu');
const otherMenuItemIndicated = computed(() => {
for (const def in navbarItemDef) {
if (menu.value.includes(def)) continue;
if (navbarItemDef[def].indicated) return true;
}
return false;
});
function openAccountMenu(ev: MouseEvent) {
openAccountMenu_({
withExtraOperation: true,
}, ev);
}
function openInstanceMenu(ev: MouseEvent) {
os.popupMenu([{
text: instance.name ?? host,
type: 'label',
}, {
type: 'link',
text: i18n.ts.instanceInfo,
icon: 'fas fa-info-circle',
to: '/about',
}, {
type: 'link',
text: i18n.ts.customEmojis,
icon: 'fas fa-laugh',
to: '/about#emojis',
}, {
type: 'link',
text: i18n.ts.federation,
icon: 'fas fa-globe',
to: '/about#federation',
}], ev.currentTarget ?? ev.target, {
align: 'left',
});
}
function more() {
os.popup(defineAsyncComponent(() => import('@/components/launch-pad.vue')), {}, {
}, 'closed');
}
</script>
<style lang="scss" scoped>
.kmwsukvl {
$ui-font-size: 1em; // TODO:
$avatar-size: 32px;
$avatar-margin: 8px;
backdrop-filter: var(--blur, blur(8px));
-webkit-backdrop-filter: var(--blur, blur(8px));
> div {
> .divider {
margin: 16px 16px;
border-top: solid 0.5px var(--divider);
}
> .item {
position: relative;
display: block;
padding-left: 24px;
font-size: $ui-font-size;
line-height: 2.85rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
text-align: left;
box-sizing: border-box;
color: var(--navFg);
> i {
position: relative;
width: 32px;
}
> i,
> .avatar {
margin-right: $avatar-margin;
}
> .avatar {
width: $avatar-size;
height: $avatar-size;
vertical-align: middle;
}
> .indicator {
position: absolute;
top: 0;
left: 20px;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
}
> .text {
position: relative;
font-size: 0.9em;
}
&:hover {
text-decoration: none;
color: var(--navHoverFg);
}
&.active {
color: var(--navActive);
}
&:hover, &.active {
&:before {
content: "";
display: block;
width: calc(100% - 24px);
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: var(--accentedBg);
}
}
&:first-child, &:last-child {
position: sticky;
z-index: 1;
padding-top: 8px;
padding-bottom: 8px;
background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
}
&:first-child {
top: 0;
&:hover, &.active {
&:before {
content: none;
}
}
}
&:last-child {
bottom: 0;
color: var(--fgOnAccent);
&:before {
content: "";
display: block;
width: calc(100% - 20px);
height: calc(100% - 20px);
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
&:hover, &.active {
&:before {
background: var(--accentLighten);
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<div class="mvcprjjd" :class="{ iconOnly }">
<div class="body">
<div class="top">
<div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div>
<button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
</button>
</div>
<div class="middle">
<MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.timeline" class="item index" active-class="active" to="/" exact>
<i class="icon fas fa-home fa-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
</MkA>
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
<component
:is="navbarItemDef[item].to ? 'MkA' : 'button'"
v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)"
v-click-anime
v-tooltip.noDelay.right="i18n.ts[navbarItemDef[item].title]"
class="item _button"
:class="[item, { active: navbarItemDef[item].active }]"
active-class="active"
:to="navbarItemDef[item].to"
v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"
>
<i class="icon fa-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon fas fa-circle"></i></span>
</component>
</template>
<div class="divider"></div>
<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip.noDelay.right="i18n.ts.controlPanel" class="item" active-class="active" to="/admin">
<i class="icon fas fa-door-open fa-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
</MkA>
<button v-click-anime class="item _button" @click="more">
<i class="icon fa fa-ellipsis-h fa-fw"></i><span class="text">{{ i18n.ts.more }}</span>
<span v-if="otherMenuItemIndicated" class="indicator"><i class="icon fas fa-circle"></i></span>
</button>
<MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.settings" class="item" active-class="active" to="/settings">
<i class="icon fas fa-cog fa-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
</MkA>
</div>
<div class="bottom">
<button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post">
<i class="icon fas fa-pencil-alt fa-fw"></i><span class="text">{{ i18n.ts.note }}</span>
</button>
<button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu">
<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import * as os from '@/os';
import { navbarItemDef } from '@/navbar';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { host } from '@/config';
const iconOnly = ref(false);
const menu = computed(() => defaultStore.state.menu);
const otherMenuItemIndicated = computed(() => {
for (const def in navbarItemDef) {
if (menu.value.includes(def)) continue;
if (navbarItemDef[def].indicated) return true;
}
return false;
});
const calcViewState = () => {
iconOnly.value = (window.innerWidth <= 1279) || (defaultStore.state.menuDisplay === 'sideIcon');
};
calcViewState();
window.addEventListener('resize', calcViewState);
watch(defaultStore.reactiveState.menuDisplay, () => {
calcViewState();
});
function openAccountMenu(ev: MouseEvent) {
openAccountMenu_({
withExtraOperation: true,
}, ev);
}
function openInstanceMenu(ev: MouseEvent) {
os.popupMenu([{
text: instance.name ?? host,
type: 'label',
}, {
type: 'link',
text: i18n.ts.instanceInfo,
icon: 'fas fa-info-circle',
to: '/about',
}, {
type: 'link',
text: i18n.ts.customEmojis,
icon: 'fas fa-laugh',
to: '/about#emojis',
}, {
type: 'link',
text: i18n.ts.federation,
icon: 'fas fa-globe',
to: '/about#federation',
}], ev.currentTarget ?? ev.target, {
align: 'left',
});
}
function more(ev: MouseEvent) {
os.popup(defineAsyncComponent(() => import('@/components/launch-pad.vue')), {
src: ev.currentTarget ?? ev.target,
}, {
}, 'closed');
}
</script>
<style lang="scss" scoped>
.mvcprjjd {
$ui-font-size: 1em; // TODO:
$nav-width: 250px;
$nav-icon-only-width: 86px;
$avatar-size: 32px;
$avatar-margin: 8px;
flex: 0 0 $nav-width;
width: $nav-width;
box-sizing: border-box;
> .body {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: $nav-icon-only-width;
// 100vh ... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
height: calc(var(--vh, 1vh) * 100);
box-sizing: border-box;
overflow: auto;
overflow-x: clip;
background: var(--navBg);
contain: strict;
display: flex;
flex-direction: column;
backdrop-filter: var(--blur, blur(8px));
-webkit-backdrop-filter: var(--blur, blur(8px));
}
&:not(.iconOnly) {
> .body {
width: $nav-width;
> .top {
position: sticky;
top: 0;
z-index: 1;
padding: 20px 0;
background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
> .banner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center center;
-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
}
> .instance {
position: relative;
display: block;
text-align: center;
width: 100%;
> .icon {
display: inline-block;
width: 38px;
aspect-ratio: 1;
}
}
}
> .bottom {
position: sticky;
bottom: 0;
padding: 20px 0;
background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
> .post {
position: relative;
display: block;
width: 100%;
height: 40px;
color: var(--fgOnAccent);
font-weight: bold;
text-align: left;
&:before {
content: "";
display: block;
width: calc(100% - 38px);
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
&:hover, &.active {
&:before {
background: var(--accentLighten);
}
}
> .icon {
position: relative;
margin-left: 30px;
margin-right: 8px;
width: 32px;
}
> .text {
position: relative;
}
}
> .account {
position: relative;
display: flex;
align-items: center;
padding-left: 30px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
text-align: left;
box-sizing: border-box;
margin-top: 16px;
> .avatar {
position: relative;
width: 32px;
aspect-ratio: 1;
margin-right: 8px;
}
}
}
> .middle {
flex: 1;
> .divider {
margin: 16px 16px;
border-top: solid 0.5px var(--divider);
}
> .item {
position: relative;
display: block;
padding-left: 30px;
line-height: 2.85rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
text-align: left;
box-sizing: border-box;
color: var(--navFg);
> .icon {
position: relative;
width: 32px;
margin-right: 8px;
}
> .indicator {
position: absolute;
top: 0;
left: 20px;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
}
> .text {
position: relative;
font-size: 0.9em;
}
&:hover {
text-decoration: none;
color: var(--navHoverFg);
}
&.active {
color: var(--navActive);
}
&:hover, &.active {
color: var(--accent);
&:before {
content: "";
display: block;
width: calc(100% - 34px);
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: var(--accentedBg);
}
}
}
}
}
}
&.iconOnly {
flex: 0 0 $nav-icon-only-width;
width: $nav-icon-only-width;
> .body {
width: $nav-icon-only-width;
> .top {
position: sticky;
top: 0;
z-index: 1;
padding: 20px 0;
background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
> .instance {
display: block;
text-align: center;
width: 100%;
> .icon {
display: inline-block;
width: 30px;
aspect-ratio: 1;
}
}
}
> .bottom {
position: sticky;
bottom: 0;
padding: 20px 0;
background: var(--X14);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
> .post {
display: block;
position: relative;
width: 100%;
height: 52px;
margin-bottom: 16px;
text-align: center;
&:before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: 52px;
aspect-ratio: 1/1;
border-radius: 100%;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
}
&:hover, &.active {
&:before {
background: var(--accentLighten);
}
}
> .icon {
position: relative;
color: var(--fgOnAccent);
}
> .text {
display: none;
}
}
> .account {
display: block;
text-align: center;
width: 100%;
> .avatar {
display: inline-block;
width: 38px;
aspect-ratio: 1;
}
> .text {
display: none;
}
}
}
> .middle {
flex: 1;
> .divider {
margin: 8px auto;
width: calc(100% - 32px);
border-top: solid 0.5px var(--divider);
}
> .item {
display: block;
position: relative;
padding: 18px 0;
width: 100%;
text-align: center;
> .icon {
display: block;
margin: 0 auto;
opacity: 0.7;
}
> .text {
display: none;
}
> .indicator {
position: absolute;
top: 6px;
left: 24px;
color: var(--navIndicator);
font-size: 8px;
animation: blink 1s infinite;
}
&:hover, &.active {
text-decoration: none;
color: var(--accent);
&:before {
content: "";
display: block;
height: 100%;
aspect-ratio: 1;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: var(--accentedBg);
}
> .icon, > .text {
opacity: 1;
}
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<div v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" class="nsbbhtug" @click="resetDisconnected">
<div>{{ $ts.disconnectedFromServer }}</div>
<div class="command">
<button class="_textButton" @click="reload">{{ $ts.reload }}</button>
<button class="_textButton">{{ $ts.doNothing }}</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { onUnmounted } from 'vue';
import { stream } from '@/stream';
let hasDisconnected = $ref(false);
function onDisconnected() {
hasDisconnected = true;
}
function resetDisconnected() {
hasDisconnected = false;
}
function reload() {
location.reload();
}
stream.on('_disconnected_', onDisconnected);
onUnmounted(() => {
stream.off('_disconnected_', onDisconnected);
});
</script>
<style lang="scss" scoped>
.nsbbhtug {
position: fixed;
z-index: 16385;
bottom: 8px;
right: 8px;
margin: 0;
padding: 6px 12px;
font-size: 0.9em;
color: #fff;
background: #000;
opacity: 0.8;
border-radius: 4px;
max-width: 320px;
> .command {
display: flex;
justify-content: space-around;
> button {
padding: 0.7em;
}
}
}
</style>

View File

@ -0,0 +1,35 @@
import { inject } from 'vue';
import { post } from '@/os';
import { $i, login } from '@/account';
import { defaultStore } from '@/store';
import { getAccountFromId } from '@/scripts/get-account-from-id';
import { mainRouter } from '@/router';
export function swInject() {
navigator.serviceWorker.addEventListener('message', ev => {
if (_DEV_) {
console.log('sw msg', ev.data);
}
if (ev.data.type !== 'order') return;
if (ev.data.loginId !== $i?.id) {
return getAccountFromId(ev.data.loginId).then(account => {
if (!account) return;
return login(account.token, ev.data.url);
});
}
switch (ev.data.order) {
case 'post':
return post(ev.data.options);
case 'push':
if (mainRouter.currentRoute.value.path === ev.data.url) {
return window.scroll({ top: 0, behavior: 'smooth' });
}
return mainRouter.push(ev.data.url);
default:
return;
}
});
}

View File

@ -0,0 +1,128 @@
<template>
<div class="mk-uploader _acrylic" :style="{ zIndex }">
<ol v-if="uploads.length > 0">
<li v-for="ctx in uploads" :key="ctx.id">
<div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div>
<div class="top">
<p class="name"><i class="fas fa-spinner fa-pulse"></i>{{ ctx.name }}</p>
<p class="status">
<span v-if="ctx.progressValue === undefined" class="initing">{{ $ts.waiting }}<MkEllipsis/></span>
<span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
<span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span>
</p>
</div>
<progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress>
</li>
</ol>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as os from '@/os';
import { uploads } from '@/scripts/upload';
const zIndex = os.claimZIndex('high');
</script>
<style lang="scss" scoped>
.mk-uploader {
position: fixed;
right: 16px;
width: 260px;
top: 32px;
padding: 16px 20px;
pointer-events: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
border-radius: 8px;
}
.mk-uploader:empty {
display: none;
}
.mk-uploader > ol {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
.mk-uploader > ol > li {
display: grid;
margin: 8px 0 0 0;
padding: 0;
height: 36px;
width: 100%;
border-top: solid 8px transparent;
grid-template-columns: 36px calc(100% - 44px);
grid-template-rows: 1fr 8px;
column-gap: 8px;
box-sizing: content-box;
}
.mk-uploader > ol > li:first-child {
margin: 0;
box-shadow: none;
border-top: none;
}
.mk-uploader > ol > li > .img {
display: block;
background-size: cover;
background-position: center center;
grid-column: 1/2;
grid-row: 1/3;
}
.mk-uploader > ol > li > .top {
display: flex;
grid-column: 2/3;
grid-row: 1/2;
}
.mk-uploader > ol > li > .top > .name {
display: block;
padding: 0 8px 0 0;
margin: 0;
font-size: 0.8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-shrink: 1;
}
.mk-uploader > ol > li > .top > .name > i {
margin-right: 4px;
}
.mk-uploader > ol > li > .top > .status {
display: block;
margin: 0 0 0 auto;
padding: 0;
font-size: 0.8em;
flex-shrink: 0;
}
.mk-uploader > ol > li > .top > .status > .initing {
}
.mk-uploader > ol > li > .top > .status > .kb {
}
.mk-uploader > ol > li > .top > .status > .percentage {
display: inline-block;
width: 48px;
text-align: right;
}
.mk-uploader > ol > li > .top > .status > .percentage:after {
content: '%';
}
.mk-uploader > ol > li > progress {
display: block;
background: transparent;
border: none;
border-radius: 4px;
overflow: hidden;
grid-column: 2/3;
grid-row: 2/3;
z-index: 2;
width: 100%;
height: 8px;
}
.mk-uploader > ol > li > progress::-webkit-progress-value {
background: var(--accent);
}
.mk-uploader > ol > li > progress::-webkit-progress-bar {
//background: var(--accentAlpha01);
background: transparent;
}
</style>

View File

@ -0,0 +1,305 @@
<template>
<div class="mk-deck" :class="[{ isMobile }, `${deckStore.reactiveState.columnAlign.value}`]" :style="{ '--deckMargin': '12' + 'px' }"
@contextmenu.self.prevent="onContextmenu"
>
<XSidebar v-if="!isMobile"/>
<template v-for="ids in layout">
<!-- sectionを利用しているのはdeck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
<section
v-if="ids.length > 1"
class="folder column"
:style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
>
<DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
</section>
<DeckColumnCore
v-else
:ref="ids[0]"
:key="ids[0]"
class="column"
:column="columns.find(c => c.id === ids[0])"
:is-stacked="false"
:style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }"
@parent-focus="moveFocus(ids[0], $event)"
/>
</template>
<div v-if="isMobile" class="buttons">
<button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
<button class="button home _button" @click="mainRouter.push('/')"><i class="fas fa-home"></i></button>
<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
<button class="button post _button" @click="os.post()"><i class="fas fa-pencil-alt"></i></button>
</div>
<transition :name="$store.state.animation ? 'menu-back' : ''">
<div
v-if="drawerMenuShowing"
class="menu-back _modalBg"
@click="drawerMenuShowing = false"
@touchstart.passive="drawerMenuShowing = false"
></div>
</transition>
<transition :name="$store.state.animation ? 'menu' : ''">
<XDrawerMenu v-if="drawerMenuShowing" class="menu"/>
</transition>
<XCommon/>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, provide, ref, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import XCommon from './_common_old/common.vue';
import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deckold/deck-store';
import DeckColumnCore from '@/ui/deckold/column-core.vue';
import XSidebar from '@/ui/_common_old/sidebar.vue';
import XDrawerMenu from '@/ui/_common_old/sidebar-for-mobile.vue';
import MkButton from '@/components/ui/button.vue';
import { getScrollContainer } from '@/scripts/scroll';
import * as os from '@/os';
import { navbarItemDef } from '@/navbar';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { mainRouter } from '@/router';
import { unisonReload } from '@/scripts/unison-reload';
mainRouter.navHook = (path): boolean => {
const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main');
if (deckStore.state.navWindow || noMainColumn) {
os.pageWindow(path);
return true;
}
return false;
};
const isMobile = ref(window.innerWidth <= 500);
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 500;
});
const drawerMenuShowing = ref(false);
const route = 'TODO';
watch(route, () => {
drawerMenuShowing.value = false;
});
const columns = deckStore.reactiveState.columns;
const layout = deckStore.reactiveState.layout;
const menuIndicated = computed(() => {
if ($i == null) return false;
for (const def in navbarItemDef) {
if (navbarItemDef[def].indicated) return true;
}
return false;
});
const addColumn = async (ev) => {
const columns = [
'main',
'widgets',
'notifications',
'tl',
'antenna',
'list',
'mentions',
'direct',
];
const { canceled, result: column } = await os.select({
title: i18n.ts._deck.addColumn,
items: columns.map(column => ({
value: column, text: i18n.t('_deck._columns.' + column)
}))
});
if (canceled) return;
addColumnToStore({
type: column,
id: uuid(),
name: i18n.t('_deck._columns.' + column),
width: 330,
});
};
const onContextmenu = (ev) => {
os.contextMenu([{
text: i18n.ts._deck.addColumn,
action: addColumn,
}], ev);
};
provide('shouldSpacerMin', true);
if (deckStore.state.navWindow) {
provide('navHook', (url) => {
os.pageWindow(url);
});
}
document.documentElement.style.overflowY = 'hidden';
document.documentElement.style.scrollBehavior = 'auto';
window.addEventListener('wheel', (ev) => {
if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) {
document.documentElement.scrollLeft += ev.deltaY;
}
});
loadDeck();
function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') {
// TODO??
}
</script>
<style lang="scss" scoped>
.menu-enter-active,
.menu-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);
}
.menu-enter-from,
.menu-leave-active {
opacity: 0;
transform: translateX(-240px);
}
.menu-back-enter-active,
.menu-back-leave-active {
opacity: 1;
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.menu-back-enter-from,
.menu-back-leave-active {
opacity: 0;
}
.mk-deck {
$nav-hide-threshold: 650px; // TODO:
// TODO:
--margin: var(--marginHalf);
display: flex;
// 100vh ... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
height: calc(var(--vh, 1vh) * 100);
box-sizing: border-box;
flex: 1;
padding: var(--deckMargin);
&.center {
> .column:first-of-type {
margin-left: auto;
}
> .column:last-of-type {
margin-right: auto;
}
}
&.isMobile {
padding-bottom: 100px;
}
> .column {
flex-shrink: 0;
margin-right: var(--deckMargin);
&.folder {
display: flex;
flex-direction: column;
> *:not(:last-child) {
margin-bottom: var(--deckMargin);
}
}
}
> .buttons {
position: fixed;
z-index: 1000;
bottom: 0;
left: 0;
padding: 16px;
display: flex;
width: 100%;
box-sizing: border-box;
> .button {
position: relative;
flex: 1;
padding: 0;
margin: auto;
height: 64px;
border-radius: 8px;
background: var(--panel);
color: var(--fg);
&:not(:last-child) {
margin-right: 12px;
}
@media (max-width: 400px) {
height: 60px;
&:not(:last-child) {
margin-right: 8px;
}
}
&:hover {
background: var(--X2);
}
> .indicator {
position: absolute;
top: 0;
left: 0;
color: var(--indicator);
font-size: 16px;
animation: blink 1s infinite;
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
> * {
font-size: 20px;
}
&:disabled {
cursor: default;
> * {
opacity: 0.5;
}
}
}
}
> .menu-back {
z-index: 1001;
}
> .menu {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
// 100vh ... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
height: calc(var(--vh, 1vh) * 100);
width: 240px;
box-sizing: border-box;
overflow: auto;
overscroll-behavior: contain;
background: var(--bg);
}
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<XColumn :func="{ handler: setAntenna, title: $ts.selectAntenna }" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
<template #header>
<i class="fas fa-satellite"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
<XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => emit('loaded')"/>
</XColumn>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import XColumn from './column.vue';
import XTimeline from '@/components/timeline.vue';
import * as os from '@/os';
import { updateColumn, Column } from './deck-store';
import { i18n } from '@/i18n';
const props = defineProps<{
column: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'loaded'): void;
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
let timeline = $ref<InstanceType<typeof XTimeline>>();
onMounted(() => {
if (props.column.antennaId == null) {
setAntenna();
}
});
async function setAntenna() {
const antennas = await os.api('antennas/list');
const { canceled, result: antenna } = await os.select({
title: i18n.ts.selectAntenna,
items: antennas.map(x => ({
value: x, text: x.name
})),
default: props.column.antennaId
});
if (canceled) return;
updateColumn(props.column.id, {
antennaId: antenna.id
});
}
/*
function focus() {
timeline.focus();
}
defineExpose({
focus,
});
*/
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,44 @@
<template>
<!-- TODO: リファクタの余地がありそう -->
<div v-if="!column">たぶん見えちゃいけないやつ</div>
<XMainColumn v-else-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
</template>
<script lang="ts" setup>
import { } from 'vue';
import XMainColumn from './main-column.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';
import { Column } from './deck-store';
defineProps<{
column?: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
/*
export default defineComponent({
methods: {
focus() {
this.$children[0].focus();
}
}
});
*/
</script>

View File

@ -0,0 +1,393 @@
<template>
<!-- sectionを利用しているのはdeck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
<section v-hotkey="keymap" class="dnpfarvg _panel _narrow_"
:class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }"
:style="{ '--deckColumnHeaderHeight': deckStore.reactiveState.columnHeaderHeight.value + 'px' }"
@dragover.prevent.stop="onDragover"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
>
<header :class="{ indicated }"
draggable="true"
@click="goTop"
@dragstart="onDragstart"
@dragend="onDragend"
@contextmenu.prevent.stop="onContextmenu"
>
<button v-if="isStacked && !isMainColumn" class="toggleActive _button" @click="toggleActive">
<template v-if="active"><i class="fas fa-angle-up"></i></template>
<template v-else><i class="fas fa-angle-down"></i></template>
</button>
<div class="action">
<slot name="action"></slot>
</div>
<span class="header"><slot name="header"></slot></span>
<button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="fas fa-ellipsis"></i></button>
</header>
<div v-show="active" ref="body">
<slot></slot>
</div>
</section>
</template>
<script lang="ts">
export type DeckFunc = {
title: string;
handler: (payload: MouseEvent) => void;
icon?: string;
};
</script>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, provide, watch } from 'vue';
import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column , deckStore } from './deck-store';
import * as os from '@/os';
import { i18n } from '@/i18n';
provide('shouldHeaderThin', true);
provide('shouldOmitHeaderTitle', true);
const props = withDefaults(defineProps<{
column: Column;
isStacked?: boolean;
func?: DeckFunc | null;
naked?: boolean;
indicated?: boolean;
}>(), {
isStacked: false,
func: null,
naked: false,
indicated: false,
});
const emit = defineEmits<{
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
(ev: 'change-active-state', v: boolean): void;
}>();
let body = $ref<HTMLDivElement>();
let dragging = $ref(false);
watch($$(dragging), v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'));
let draghover = $ref(false);
let dropready = $ref(false);
const isMainColumn = $computed(() => props.column.type === 'main');
const active = $computed(() => props.column.active !== false);
watch($$(active), v => emit('change-active-state', v));
const keymap = $computed(() => ({
'shift+up': () => emit('parent-focus', 'up'),
'shift+down': () => emit('parent-focus', 'down'),
'shift+left': () => emit('parent-focus', 'left'),
'shift+right': () => emit('parent-focus', 'right'),
}));
onMounted(() => {
os.deckGlobalEvents.on('column.dragStart', onOtherDragStart);
os.deckGlobalEvents.on('column.dragEnd', onOtherDragEnd);
});
onBeforeUnmount(() => {
os.deckGlobalEvents.off('column.dragStart', onOtherDragStart);
os.deckGlobalEvents.off('column.dragEnd', onOtherDragEnd);
});
function onOtherDragStart() {
dropready = true;
}
function onOtherDragEnd() {
dropready = false;
}
function toggleActive() {
if (!props.isStacked) return;
updateColumn(props.column.id, {
active: !props.column.active,
});
}
function getMenu() {
const items = [{
icon: 'fas fa-pencil-alt',
text: i18n.ts.edit,
action: async () => {
const { canceled, result } = await os.form(props.column.name, {
name: {
type: 'string',
label: i18n.ts.name,
default: props.column.name,
},
width: {
type: 'number',
label: i18n.ts.width,
default: props.column.width,
},
flexible: {
type: 'boolean',
label: i18n.ts.flexible,
default: props.column.flexible,
},
});
if (canceled) return;
updateColumn(props.column.id, result);
},
}, null, {
icon: 'fas fa-arrow-left',
text: i18n.ts._deck.swapLeft,
action: () => {
swapLeftColumn(props.column.id);
},
}, {
icon: 'fas fa-arrow-right',
text: i18n.ts._deck.swapRight,
action: () => {
swapRightColumn(props.column.id);
},
}, props.isStacked ? {
icon: 'fas fa-arrow-up',
text: i18n.ts._deck.swapUp,
action: () => {
swapUpColumn(props.column.id);
},
} : undefined, props.isStacked ? {
icon: 'fas fa-arrow-down',
text: i18n.ts._deck.swapDown,
action: () => {
swapDownColumn(props.column.id);
},
} : undefined, null, {
icon: 'fas fa-window-restore',
text: i18n.ts._deck.stackLeft,
action: () => {
stackLeftColumn(props.column.id);
},
}, props.isStacked ? {
icon: 'fas fa-window-maximize',
text: i18n.ts._deck.popRight,
action: () => {
popRightColumn(props.column.id);
},
} : undefined, null, {
icon: 'fas fa-trash-alt',
text: i18n.ts.remove,
danger: true,
action: () => {
removeColumn(props.column.id);
},
}];
if (props.func) {
items.unshift(null);
items.unshift({
icon: props.func.icon,
text: props.func.title,
action: props.func.handler,
});
}
return items;
}
function showSettingsMenu(ev: MouseEvent) {
os.popupMenu(getMenu(), ev.currentTarget ?? ev.target);
}
function onContextmenu(ev: MouseEvent) {
os.contextMenu(getMenu(), ev);
}
function goTop() {
body.scrollTo({
top: 0,
behavior: 'smooth',
});
}
function onDragstart(ev) {
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id);
// ChromeDragstartDOM(=)Drag
// SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately
window.setTimeout(() => {
dragging = true;
}, 10);
}
function onDragend(ev) {
dragging = false;
}
function onDragover(ev) {
//
if (dragging) {
//
ev.dataTransfer.dropEffect = 'none';
} else {
const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_;
ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
if (isDeckColumn) draghover = true;
}
}
function onDragleave() {
draghover = false;
}
function onDrop(ev) {
draghover = false;
os.deckGlobalEvents.emit('column.dragEnd');
const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_);
if (id != null && id !== '') {
swapColumn(props.column.id, id);
}
}
</script>
<style lang="scss" scoped>
.dnpfarvg {
--root-margin: 10px;
--deckColumnHeaderHeight: 42px;
height: 100%;
overflow: hidden;
contain: content;
box-shadow: 0 0 8px 0 var(--shadow);
&.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: var(--deckColumnHeaderHeight);
min-height: var(--deckColumnHeaderHeight);
> header.indicated {
box-shadow: 4px 0px var(--accent) inset;
}
}
&.naked {
background: var(--acrylicBg) !important;
-webkit-backdrop-filter: var(--blur, blur(10px));
backdrop-filter: var(--blur, blur(10px));
> header {
background: transparent;
box-shadow: none;
> button {
color: var(--fg);
}
}
}
&.paged {
background: var(--bg) !important;
}
> header {
position: relative;
display: flex;
z-index: 2;
line-height: var(--deckColumnHeaderHeight);
height: var(--deckColumnHeaderHeight);
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 > ::v-deep(*),
> .menu {
z-index: 1;
width: var(--deckColumnHeaderHeight);
line-height: var(--deckColumnHeaderHeight);
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 {
margin-left: auto;
margin-right: -16px;
}
}
> div {
height: calc(100% - var(--deckColumnHeaderHeight));
overflow-y: auto;
overflow-x: hidden; // Safari does not supports clip
overflow-x: clip;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
}
</style>

View File

@ -0,0 +1,317 @@
import { throttle } from 'throttle-debounce';
import { markRaw } from 'vue';
import { notificationTypes } from 'misskey-js';
import { Storage } from '../../pizzax';
import { i18n } from '@/i18n';
import { api } from '@/os';
type ColumnWidget = {
name: string;
id: string;
data: Record<string, any>;
};
export type Column = {
id: string;
type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct';
name: string | null;
width: number;
widgets?: ColumnWidget[];
active?: boolean;
flexible?: boolean;
antennaId?: string;
listId?: string;
includingTypes?: typeof notificationTypes[number][];
tl?: 'home' | 'local' | 'social' | 'global';
};
function copy<T>(x: T): T {
return JSON.parse(JSON.stringify(x));
}
export const deckStore = markRaw(new Storage('deck', {
profile: {
where: 'deviceAccount',
default: 'default',
},
columns: {
where: 'deviceAccount',
default: [] as Column[],
},
layout: {
where: 'deviceAccount',
default: [] as Column['id'][][],
},
columnAlign: {
where: 'deviceAccount',
default: 'left' as 'left' | 'right' | 'center',
},
alwaysShowMainColumn: {
where: 'deviceAccount',
default: true,
},
navWindow: {
where: 'deviceAccount',
default: true,
},
columnMargin: {
where: 'deviceAccount',
default: 12,
},
columnHeaderHeight: {
where: 'deviceAccount',
default: 42,
},
}));
export const loadDeck = async () => {
let deck;
try {
deck = await api('i/registry/get', {
scope: ['client', 'deck', 'profiles'],
key: deckStore.state.profile,
});
} catch (err) {
if (err.code === 'NO_SUCH_KEY') {
// 後方互換性のため
if (deckStore.state.profile === 'default') {
saveDeck();
return;
}
deckStore.set('columns', [{
id: 'a',
type: 'main',
name: i18n.ts._deck._columns.main,
width: 350,
}, {
id: 'b',
type: 'notifications',
name: i18n.ts._deck._columns.notifications,
width: 330,
}]);
deckStore.set('layout', [['a'], ['b']]);
return;
}
throw err;
}
deckStore.set('columns', deck.columns);
deckStore.set('layout', deck.layout);
};
// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
export const saveDeck = throttle(1000, () => {
api('i/registry/set', {
scope: ['client', 'deck', 'profiles'],
key: deckStore.state.profile,
value: {
columns: deckStore.reactiveState.columns.value,
layout: deckStore.reactiveState.layout.value,
},
});
});
export async function getProfiles(): Promise<string[]> {
return await api('i/registry/keys', {
scope: ['client', 'deck', 'profiles'],
});
}
export async function deleteProfile(key: string): Promise<void> {
return await api('i/registry/remove', {
scope: ['client', 'deck', 'profiles'],
key: key,
});
}
export function addColumn(column: Column) {
if (column.name === undefined) column.name = null;
deckStore.push('columns', column);
deckStore.push('layout', [column.id]);
saveDeck();
}
export function removeColumn(id: Column['id']) {
deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id));
deckStore.set('layout', deckStore.state.layout
.map(ids => ids.filter(_id => _id !== id))
.filter(ids => ids.length > 0));
saveDeck();
}
export function swapColumn(a: Column['id'], b: Column['id']) {
const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1);
const aY = deckStore.state.layout[aX].findIndex(id => id === a);
const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1);
const bY = deckStore.state.layout[bX].findIndex(id => id === b);
const layout = copy(deckStore.state.layout);
layout[aX][aY] = b;
layout[bX][bY] = a;
deckStore.set('layout', layout);
saveDeck();
}
export function swapLeftColumn(id: Column['id']) {
const layout = copy(deckStore.state.layout);
deckStore.state.layout.some((ids, i) => {
if (ids.includes(id)) {
const left = deckStore.state.layout[i - 1];
if (left) {
layout[i - 1] = deckStore.state.layout[i];
layout[i] = left;
deckStore.set('layout', layout);
}
return true;
}
});
saveDeck();
}
export function swapRightColumn(id: Column['id']) {
const layout = copy(deckStore.state.layout);
deckStore.state.layout.some((ids, i) => {
if (ids.includes(id)) {
const right = deckStore.state.layout[i + 1];
if (right) {
layout[i + 1] = deckStore.state.layout[i];
layout[i] = right;
deckStore.set('layout', layout);
}
return true;
}
});
saveDeck();
}
export function swapUpColumn(id: Column['id']) {
const layout = copy(deckStore.state.layout);
const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
const ids = copy(deckStore.state.layout[idsIndex]);
ids.some((x, i) => {
if (x === id) {
const up = ids[i - 1];
if (up) {
ids[i - 1] = id;
ids[i] = up;
layout[idsIndex] = ids;
deckStore.set('layout', layout);
}
return true;
}
});
saveDeck();
}
export function swapDownColumn(id: Column['id']) {
const layout = copy(deckStore.state.layout);
const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
const ids = copy(deckStore.state.layout[idsIndex]);
ids.some((x, i) => {
if (x === id) {
const down = ids[i + 1];
if (down) {
ids[i + 1] = id;
ids[i] = down;
layout[idsIndex] = ids;
deckStore.set('layout', layout);
}
return true;
}
});
saveDeck();
}
export function stackLeftColumn(id: Column['id']) {
let layout = copy(deckStore.state.layout);
const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
layout = layout.map(ids => ids.filter(_id => _id !== id));
layout[i - 1].push(id);
layout = layout.filter(ids => ids.length > 0);
deckStore.set('layout', layout);
saveDeck();
}
export function popRightColumn(id: Column['id']) {
let layout = copy(deckStore.state.layout);
const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
const affected = layout[i];
layout = layout.map(ids => ids.filter(_id => _id !== id));
layout.splice(i + 1, 0, [id]);
layout = layout.filter(ids => ids.length > 0);
deckStore.set('layout', layout);
const columns = copy(deckStore.state.columns);
for (const column of columns) {
if (affected.includes(column.id)) {
column.active = true;
}
}
deckStore.set('columns', columns);
saveDeck();
}
export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
const columns = copy(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const column = copy(deckStore.state.columns[columnIndex]);
if (column == null) return;
if (column.widgets == null) column.widgets = [];
column.widgets.unshift(widget);
columns[columnIndex] = column;
deckStore.set('columns', columns);
saveDeck();
}
export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
const columns = copy(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const column = copy(deckStore.state.columns[columnIndex]);
if (column == null) return;
column.widgets = column.widgets.filter(w => w.id !== widget.id);
columns[columnIndex] = column;
deckStore.set('columns', columns);
saveDeck();
}
export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
const columns = copy(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const column = copy(deckStore.state.columns[columnIndex]);
if (column == null) return;
column.widgets = widgets;
columns[columnIndex] = column;
deckStore.set('columns', columns);
saveDeck();
}
export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) {
const columns = copy(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const column = copy(deckStore.state.columns[columnIndex]);
if (column == null) return;
column.widgets = column.widgets.map(w => w.id === widgetId ? {
...w,
data: widgetData,
} : w);
columns[columnIndex] = column;
deckStore.set('columns', columns);
saveDeck();
}
export function updateColumn(id: Column['id'], column: Partial<Column>) {
const columns = copy(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const currentColumn = copy(deckStore.state.columns[columnIndex]);
if (currentColumn == null) return;
for (const [k, v] of Object.entries(column)) {
currentColumn[k] = v;
}
columns[columnIndex] = currentColumn;
deckStore.set('columns', columns);
saveDeck();
}

View File

@ -0,0 +1,31 @@
<template>
<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
<template #header><i class="fas fa-envelope" style="margin-right: 8px;"></i>{{ column.name }}</template>
<XNotes :pagination="pagination"/>
</XColumn>
</template>
<script lang="ts" setup>
import { } from 'vue';
import XColumn from './column.vue';
import XNotes from '@/components/notes.vue';
import { Column } from './deck-store';
defineProps<{
column: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
const pagination = {
endpoint: 'notes/mentions' as const,
limit: 10,
params: {
visibility: 'specified'
},
};
</script>

View File

@ -0,0 +1,66 @@
<template>
<XColumn :func="{ handler: setList, title: $ts.selectList }" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
<template #header>
<i class="fas fa-list-ul"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
<XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => emit('loaded')"/>
</XColumn>
</template>
<script lang="ts" setup>
import { } from 'vue';
import XColumn from './column.vue';
import XTimeline from '@/components/timeline.vue';
import * as os from '@/os';
import { updateColumn, Column } from './deck-store';
import { i18n } from '@/i18n';
const props = defineProps<{
column: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'loaded'): void;
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
let timeline = $ref<InstanceType<typeof XTimeline>>();
if (props.column.listId == null) {
setList();
}
async function setList() {
const lists = await os.api('users/lists/list');
const { canceled, result: list } = await os.select({
title: i18n.ts.selectList,
items: lists.map(x => ({
value: x, text: x.name
})),
default: props.column.listId
});
if (canceled) return;
updateColumn(props.column.id, {
listId: list.id
});
}
/*
function focus() {
timeline.focus();
}
export default defineComponent({
watch: {
mediaOnly() {
(this.$refs.timeline as any).reload();
}
}
});
*/
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,68 @@
<template>
<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
<template #header>
<template v-if="pageMetadata?.value">
<i :class="pageMetadata?.value.icon"></i>
{{ pageMetadata?.value.title }}
</template>
</template>
<RouterView @contextmenu.stop="onContextmenu"/>
</XColumn>
</template>
<script lang="ts" setup>
import { ComputedRef, provide } from 'vue';
import XColumn from './column.vue';
import { deckStore, Column } from '@/ui/deck/deck-store';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { mainRouter } from '@/router';
import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
defineProps<{
column: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
provide('router', mainRouter);
provideMetadataReceiver((info) => {
pageMetadata = info;
});
/*
function back() {
history.back();
}
*/
function onContextmenu(ev: MouseEvent) {
if (!ev.target) return;
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(ev.target as HTMLElement)) return;
if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return;
if (window.getSelection()?.toString() !== '') return;
const path = mainRouter.currentRoute.value.path;
os.contextMenu([{
type: 'label',
text: path,
}, {
icon: 'fas fa-window-maximize',
text: i18n.ts.openInWindow,
action: () => {
os.pageWindow(path);
},
}], ev);
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
<template #header><i class="fas fa-at" style="margin-right: 8px;"></i>{{ column.name }}</template>
<XNotes :pagination="pagination"/>
</XColumn>
</template>
<script lang="ts" setup>
import { } from 'vue';
import XColumn from './column.vue';
import XNotes from '@/components/notes.vue';
import { Column } from './deck-store';
defineProps<{
column: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
const pagination = {
endpoint: 'notes/mentions' as const,
limit: 10,
};
</script>

View File

@ -0,0 +1,38 @@
<template>
<XColumn :column="column" :is-stacked="isStacked" :func="{ handler: func, title: $ts.notificationSetting }" @parent-focus="$event => emit('parent-focus', $event)">
<template #header><i class="fas fa-bell" style="margin-right: 8px;"></i>{{ column.name }}</template>
<XNotifications :include-types="column.includingTypes"/>
</XColumn>
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
import XColumn from './column.vue';
import XNotifications from '@/components/notifications.vue';
import * as os from '@/os';
import { updateColumn } from './deck-store';
import { Column } from './deck-store';
const props = defineProps<{
column: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
function func() {
os.popup(defineAsyncComponent(() => import('@/components/notification-setting-window.vue')), {
includingTypes: props.column.includingTypes,
}, {
done: async (res) => {
const { includingTypes } = res;
updateColumn(props.column.id, {
includingTypes: includingTypes
});
},
}, 'closed');
}
</script>

View File

@ -0,0 +1,129 @@
<template>
<XColumn :func="{ handler: setType, title: $ts.timeline }" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState" @parent-focus="$event => emit('parent-focus', $event)">
<template #header>
<i v-if="column.tl === 'home'" class="fas fa-home"></i>
<i v-else-if="column.tl === 'local'" class="fas fa-comments"></i>
<i v-else-if="column.tl === 'social'" class="fas fa-share-alt"></i>
<i v-else-if="column.tl === 'global'" class="fas fa-globe"></i>
<span style="margin-left: 8px;">{{ column.name }}</span>
</template>
<div v-if="disabled" class="iwaalbte">
<p>
<i class="fas fa-minus-circle"></i>
{{ $t('disabled-timeline.title') }}
</p>
<p class="desc">{{ $t('disabled-timeline.description') }}</p>
</div>
<XTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')" @queue="queueUpdated" @note="onNote"/>
</XColumn>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import XColumn from './column.vue';
import XTimeline from '@/components/timeline.vue';
import * as os from '@/os';
import { removeColumn, updateColumn, Column } from './deck-store';
import { $i } from '@/account';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
const props = defineProps<{
column: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'loaded'): void;
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
let disabled = $ref(false);
let indicated = $ref(false);
let columnActive = $ref(true);
onMounted(() => {
if (props.column.tl == null) {
setType();
} else if ($i) {
disabled = !$i.isModerator && !$i.isAdmin && (
instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) ||
instance.disableGlobalTimeline && ['global'].includes(props.column.tl));
}
});
async function setType() {
const { canceled, result: src } = await os.select({
title: i18n.ts.timeline,
items: [{
value: 'home' as const, text: i18n.ts._timelines.home
}, {
value: 'local' as const, text: i18n.ts._timelines.local
}, {
value: 'social' as const, text: i18n.ts._timelines.social
}, {
value: 'global' as const, text: i18n.ts._timelines.global
}],
});
if (canceled) {
if (props.column.tl == null) {
removeColumn(props.column.id);
}
return;
}
updateColumn(props.column.id, {
tl: src
});
}
function queueUpdated(q) {
if (columnActive) {
indicated = q !== 0;
}
}
function onNote() {
if (!columnActive) {
indicated = true;
}
}
function onChangeActiveState(state) {
columnActive = state;
if (columnActive) {
indicated = false;
}
}
/*
export default defineComponent({
watch: {
mediaOnly() {
(this.$refs.timeline as any).reload();
}
},
methods: {
focus() {
(this.$refs.timeline as any).focus();
}
}
});
*/
</script>
<style lang="scss" scoped>
.iwaalbte {
text-align: center;
> p {
margin: 16px;
&.desc {
font-size: 14px;
}
}
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<XColumn :func="{ handler: func, title: $ts.editWidgets }" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
<template #header><i class="fas fa-window-maximize" style="margin-right: 8px;"></i>{{ column.name }}</template>
<div class="wtdtxvec">
<div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div>
<XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
</div>
</XColumn>
</template>
<script lang="ts" setup>
import { } from 'vue';
import XColumn from './column.vue';
import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store';
import XWidgets from '@/components/widgets.vue';
import { i18n } from '@/i18n';
const props = defineProps<{
column: Column;
isStacked: boolean;
}>();
const emit = defineEmits<{
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
}>();
let edit = $ref(false);
function addWidget(widget) {
addColumnWidget(props.column.id, widget);
}
function removeWidget(widget) {
removeColumnWidget(props.column.id, widget);
}
function updateWidget({ id, data }) {
updateColumnWidget(props.column.id, id, data);
}
function updateWidgets(widgets) {
setColumnWidgets(props.column.id, widgets);
}
function func() {
edit = !edit;
}
</script>
<style lang="scss" scoped>
.wtdtxvec {
--margin: 8px;
--panelBorder: none;
padding: 0 var(--margin);
> .intro {
padding: 16px;
text-align: center;
}
}
</style>