parent
							
								
									037837b551
								
							
						
					
					
						commit
						0e4a111f81
					
				
					 1714 changed files with 20803 additions and 11751 deletions
				
			
		
							
								
								
									
										89
									
								
								packages/client/src/ui/_common_/common.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								packages/client/src/ui/_common_/common.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
<template>
 | 
			
		||||
<component v-for="popup in popups"
 | 
			
		||||
	:key="popup.id"
 | 
			
		||||
	:is="popup.component"
 | 
			
		||||
	v-bind="popup.props"
 | 
			
		||||
	v-on="popup.events"
 | 
			
		||||
/>
 | 
			
		||||
 | 
			
		||||
<XUpload v-if="uploads.length > 0"/>
 | 
			
		||||
 | 
			
		||||
<XStreamIndicator/>
 | 
			
		||||
 | 
			
		||||
<div id="wait" v-if="pendingApiRequestsCount > 0"></div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineAsyncComponent, defineComponent } from 'vue';
 | 
			
		||||
import { stream, popup, popups, uploads, pendingApiRequestsCount } from '@/os';
 | 
			
		||||
import * as sound from '@/scripts/sound';
 | 
			
		||||
import { $i } from '@/account';
 | 
			
		||||
 | 
			
		||||
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(import('@/components/toast.vue'), {
 | 
			
		||||
					notification
 | 
			
		||||
				}, {}, 'closed');
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			sound.play('notification');
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		if ($i) {
 | 
			
		||||
			const connection = stream.useChannel('main', null, 'UI');
 | 
			
		||||
			connection.on('notification', onNotification);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return {
 | 
			
		||||
			uploads,
 | 
			
		||||
			popups,
 | 
			
		||||
			pendingApiRequestsCount,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
#wait {
 | 
			
		||||
	display: block;
 | 
			
		||||
	position: fixed;
 | 
			
		||||
	z-index: 10000;
 | 
			
		||||
	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;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes progress-spinner {
 | 
			
		||||
	0% {
 | 
			
		||||
		transform: rotate(0deg);
 | 
			
		||||
	}
 | 
			
		||||
	100% {
 | 
			
		||||
		transform: rotate(360deg);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										388
									
								
								packages/client/src/ui/_common_/sidebar.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										388
									
								
								packages/client/src/ui/_common_/sidebar.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,388 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mvcprjjd">
 | 
			
		||||
	<transition name="nav-back">
 | 
			
		||||
		<div class="nav-back _modalBg"
 | 
			
		||||
			v-if="showing"
 | 
			
		||||
			@click="showing = false"
 | 
			
		||||
			@touchstart.passive="showing = false"
 | 
			
		||||
		></div>
 | 
			
		||||
	</transition>
 | 
			
		||||
 | 
			
		||||
	<transition name="nav">
 | 
			
		||||
		<nav class="nav" :class="{ iconOnly, hidden }" v-show="showing">
 | 
			
		||||
			<div>
 | 
			
		||||
				<button class="item _button account" @click="openAccountMenu" v-click-anime>
 | 
			
		||||
					<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
 | 
			
		||||
				</button>
 | 
			
		||||
				<MkA class="item index" active-class="active" to="/" exact v-click-anime>
 | 
			
		||||
					<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 v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'MkA' : 'button'" class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime>
 | 
			
		||||
						<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" class="item" active-class="active" to="/admin" v-click-anime>
 | 
			
		||||
					<i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span>
 | 
			
		||||
				</MkA>
 | 
			
		||||
				<button class="item _button" @click="more" v-click-anime>
 | 
			
		||||
					<i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span>
 | 
			
		||||
					<span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
 | 
			
		||||
				</button>
 | 
			
		||||
				<MkA class="item" active-class="active" to="/settings" v-click-anime>
 | 
			
		||||
					<i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span>
 | 
			
		||||
				</MkA>
 | 
			
		||||
				<button class="item _button post" @click="post" data-cy-open-post-form>
 | 
			
		||||
					<i class="fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</nav>
 | 
			
		||||
	</transition>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { host } from '@/config';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import { openAccountMenu } from '@/account';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		defaultHidden: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			host: host,
 | 
			
		||||
			showing: false,
 | 
			
		||||
			accounts: [],
 | 
			
		||||
			connection: null,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			iconOnly: false,
 | 
			
		||||
			hidden: this.defaultHidden,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		menu(): string[] {
 | 
			
		||||
			return this.$store.state.menu;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		otherNavItemIndicated(): boolean {
 | 
			
		||||
			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;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		'$store.reactiveState.menuDisplay.value'() {
 | 
			
		||||
			this.calcViewState();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		iconOnly() {
 | 
			
		||||
			this.$nextTick(() => {
 | 
			
		||||
				this.$emit('change-view-mode');
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		hidden() {
 | 
			
		||||
			this.$nextTick(() => {
 | 
			
		||||
				this.$emit('change-view-mode');
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		window.addEventListener('resize', this.calcViewState);
 | 
			
		||||
		this.calcViewState();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		calcViewState() {
 | 
			
		||||
			this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.menuDisplay === 'sideIcon');
 | 
			
		||||
			if (!this.defaultHidden) {
 | 
			
		||||
				this.hidden = (window.innerWidth <= 650);
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		show() {
 | 
			
		||||
			this.showing = true;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		post() {
 | 
			
		||||
			os.post();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		search() {
 | 
			
		||||
			search();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		more(ev) {
 | 
			
		||||
			os.popup(import('@/components/launch-pad.vue'), {}, {
 | 
			
		||||
			}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		openAccountMenu,
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</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-from,
 | 
			
		||||
.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-from,
 | 
			
		||||
.nav-back-leave-active {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mvcprjjd {
 | 
			
		||||
	$ui-font-size: 1em; // TODO: どこかに集約したい
 | 
			
		||||
	$nav-width: 250px;
 | 
			
		||||
	$nav-icon-only-width: 86px;
 | 
			
		||||
 | 
			
		||||
	> .nav-back {
 | 
			
		||||
		z-index: 1001;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .nav {
 | 
			
		||||
		$avatar-size: 32px;
 | 
			
		||||
		$avatar-margin: 8px;
 | 
			
		||||
 | 
			
		||||
		flex: 0 0 $nav-width;
 | 
			
		||||
		width: $nav-width;
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
		&.iconOnly {
 | 
			
		||||
			flex: 0 0 $nav-icon-only-width;
 | 
			
		||||
			width: $nav-icon-only-width;
 | 
			
		||||
 | 
			
		||||
			&:not(.hidden) {
 | 
			
		||||
				> div {
 | 
			
		||||
					width: $nav-icon-only-width;
 | 
			
		||||
 | 
			
		||||
					> .divider {
 | 
			
		||||
						margin: 8px auto;
 | 
			
		||||
						width: calc(100% - 32px);
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .item {
 | 
			
		||||
						padding-left: 0;
 | 
			
		||||
						padding: 18px 0;
 | 
			
		||||
						width: 100%;
 | 
			
		||||
						text-align: center;
 | 
			
		||||
						font-size: $ui-font-size * 1.1;
 | 
			
		||||
						line-height: initial;
 | 
			
		||||
 | 
			
		||||
						> i,
 | 
			
		||||
						> .avatar {
 | 
			
		||||
							display: block;
 | 
			
		||||
							margin: 0 auto;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						> i {
 | 
			
		||||
							opacity: 0.7;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						> .text {
 | 
			
		||||
							display: none;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						&:hover, &.active {
 | 
			
		||||
							> i, > .text {
 | 
			
		||||
								opacity: 1;
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						&:first-child {
 | 
			
		||||
							margin-bottom: 8px;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						&:last-child {
 | 
			
		||||
							margin-top: 8px;
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&.hidden {
 | 
			
		||||
			position: fixed;
 | 
			
		||||
			top: 0;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			z-index: 1001;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&:not(.hidden) {
 | 
			
		||||
			display: block !important;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> div {
 | 
			
		||||
			position: fixed;
 | 
			
		||||
			top: 0;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			z-index: 1001;
 | 
			
		||||
			width: $nav-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);
 | 
			
		||||
 | 
			
		||||
			> .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>
 | 
			
		||||
							
								
								
									
										70
									
								
								packages/client/src/ui/_common_/stream-indicator.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								packages/client/src/ui/_common_/stream-indicator.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,70 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="nsbbhtug" v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" @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">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			hasDisconnected: false,
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	computed: {
 | 
			
		||||
		stream() {
 | 
			
		||||
			return os.stream;
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		os.stream.on('_disconnected_', this.onDisconnected);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		os.stream.off('_disconnected_', this.onDisconnected);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		onDisconnected() {
 | 
			
		||||
			this.hasDisconnected = true;
 | 
			
		||||
		},
 | 
			
		||||
		resetDisconnected() {
 | 
			
		||||
			this.hasDisconnected = false;
 | 
			
		||||
		},
 | 
			
		||||
		reload() {
 | 
			
		||||
			location.reload();
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</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>
 | 
			
		||||
							
								
								
									
										134
									
								
								packages/client/src/ui/_common_/upload.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								packages/client/src/ui/_common_/upload.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,134 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-uploader _acrylic">
 | 
			
		||||
	<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 class="initing" v-if="ctx.progressValue === undefined">{{ $ts.waiting }}<MkEllipsis/></span>
 | 
			
		||||
					<span class="kb" v-if="ctx.progressValue !== undefined">{{ 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 class="percentage" v-if="ctx.progressValue !== undefined">{{ 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">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			uploads: os.uploads,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.mk-uploader {
 | 
			
		||||
	position: fixed;
 | 
			
		||||
	z-index: 10000;
 | 
			
		||||
	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>
 | 
			
		||||
							
								
								
									
										163
									
								
								packages/client/src/ui/chat/date-separated-list.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								packages/client/src/ui/chat/date-separated-list.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,163 @@
 | 
			
		|||
<script lang="ts">
 | 
			
		||||
import { defineComponent, h, PropType, TransitionGroup } from 'vue';
 | 
			
		||||
import MkAd from '@/components/global/ad.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		items: {
 | 
			
		||||
			type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
		reversed: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		ad: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$slots.default[0].elm.focus();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		const getDateText = (time: string) => {
 | 
			
		||||
			const date = new Date(time).getDate();
 | 
			
		||||
			const month = new Date(time).getMonth() + 1;
 | 
			
		||||
			return this.$t('monthAndDay', {
 | 
			
		||||
				month: month.toString(),
 | 
			
		||||
				day: date.toString()
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return h(this.reversed ? 'div' : TransitionGroup, {
 | 
			
		||||
			class: 'hmjzthxl',
 | 
			
		||||
			name: this.reversed ? 'list-reversed' : 'list',
 | 
			
		||||
			tag: 'div',
 | 
			
		||||
		}, this.items.map((item, i) => {
 | 
			
		||||
			const el = this.$slots.default({
 | 
			
		||||
				item: item
 | 
			
		||||
			})[0];
 | 
			
		||||
			if (el.key == null && item.id) el.key = item.id;
 | 
			
		||||
 | 
			
		||||
			if (
 | 
			
		||||
				i != this.items.length - 1 &&
 | 
			
		||||
				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
 | 
			
		||||
			) {
 | 
			
		||||
				const separator = h('div', {
 | 
			
		||||
					class: 'separator',
 | 
			
		||||
					key: item.id + ':separator',
 | 
			
		||||
				}, h('p', {
 | 
			
		||||
					class: 'date'
 | 
			
		||||
				}, [
 | 
			
		||||
					h('span', [
 | 
			
		||||
						h('i', {
 | 
			
		||||
							class: 'fas fa-angle-up icon',
 | 
			
		||||
						}),
 | 
			
		||||
						getDateText(item.createdAt)
 | 
			
		||||
					]),
 | 
			
		||||
					h('span', [
 | 
			
		||||
						getDateText(this.items[i + 1].createdAt),
 | 
			
		||||
						h('i', {
 | 
			
		||||
							class: 'fas fa-angle-down icon',
 | 
			
		||||
						})
 | 
			
		||||
					])
 | 
			
		||||
				]));
 | 
			
		||||
 | 
			
		||||
				return [el, separator];
 | 
			
		||||
			} else {
 | 
			
		||||
				if (this.ad && item._shouldInsertAd_) {
 | 
			
		||||
					return [h(MkAd, {
 | 
			
		||||
						class: 'a', // advertiseの意(ブロッカー対策)
 | 
			
		||||
						key: item.id + ':ad',
 | 
			
		||||
						prefer: ['horizontal', 'horizontal-big'],
 | 
			
		||||
					}), el];
 | 
			
		||||
				} else {
 | 
			
		||||
					return el;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}));
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
.hmjzthxl {
 | 
			
		||||
	> .list-move {
 | 
			
		||||
		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
	}
 | 
			
		||||
	> .list-enter-active {
 | 
			
		||||
		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
	}
 | 
			
		||||
	> .list-enter-from {
 | 
			
		||||
		opacity: 0;
 | 
			
		||||
		transform: translateY(-64px);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .list-reversed-enter-active, > .list-reversed-leave-active {
 | 
			
		||||
		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
	}
 | 
			
		||||
	> .list-reversed-enter-from {
 | 
			
		||||
		opacity: 0;
 | 
			
		||||
		transform: translateY(64px);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
.hmjzthxl {
 | 
			
		||||
	> .separator {
 | 
			
		||||
		text-align: center;
 | 
			
		||||
		position: relative;
 | 
			
		||||
 | 
			
		||||
		&:before {
 | 
			
		||||
			content: "";
 | 
			
		||||
			display: block;
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			top: 50%;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			right: 0;
 | 
			
		||||
			margin: auto;
 | 
			
		||||
			width: calc(100% - 32px);
 | 
			
		||||
			height: 1px;
 | 
			
		||||
			background: var(--divider);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .date {
 | 
			
		||||
			display: inline-block;
 | 
			
		||||
			position: relative;
 | 
			
		||||
			margin: 0;
 | 
			
		||||
			padding: 0 16px;
 | 
			
		||||
			line-height: 32px;
 | 
			
		||||
			text-align: center;
 | 
			
		||||
			font-size: 12px;
 | 
			
		||||
			color: var(--dateLabelFg);
 | 
			
		||||
			background: var(--panel);
 | 
			
		||||
 | 
			
		||||
			> span {
 | 
			
		||||
				&:first-child {
 | 
			
		||||
					margin-right: 8px;
 | 
			
		||||
 | 
			
		||||
					> .icon {
 | 
			
		||||
						margin-right: 8px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				&:last-child {
 | 
			
		||||
					margin-left: 8px;
 | 
			
		||||
 | 
			
		||||
					> .icon {
 | 
			
		||||
						margin-left: 8px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										62
									
								
								packages/client/src/ui/chat/header-clock.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								packages/client/src/ui/chat/header-clock.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,62 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="acemodlh _monospace">
 | 
			
		||||
	<div>
 | 
			
		||||
		<span v-text="y"></span>/<span v-text="m"></span>/<span v-text="d"></span>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div>
 | 
			
		||||
		<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>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			clock: null,
 | 
			
		||||
			y: null,
 | 
			
		||||
			m: null,
 | 
			
		||||
			d: null,
 | 
			
		||||
			hh: null,
 | 
			
		||||
			mm: null,
 | 
			
		||||
			ss: null,
 | 
			
		||||
			showColon: true,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		this.tick();
 | 
			
		||||
		this.clock = setInterval(this.tick, 1000);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		tick() {
 | 
			
		||||
			const now = new Date();
 | 
			
		||||
			this.y = now.getFullYear().toString();
 | 
			
		||||
			this.m = (now.getMonth() + 1).toString().padStart(2, '0');
 | 
			
		||||
			this.d = now.getDate().toString().padStart(2, '0');
 | 
			
		||||
			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.showColon = now.getSeconds() % 2 === 0;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.acemodlh {
 | 
			
		||||
	opacity: 0.7;
 | 
			
		||||
	font-size: 0.85em;
 | 
			
		||||
	line-height: 1em;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										467
									
								
								packages/client/src/ui/chat/index.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										467
									
								
								packages/client/src/ui/chat/index.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,467 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-app" @contextmenu.self.prevent="onContextmenu">
 | 
			
		||||
	<XSidebar ref="menu" class="menu" :default-hidden="true"/>
 | 
			
		||||
 | 
			
		||||
	<div class="nav">
 | 
			
		||||
		<header class="header">
 | 
			
		||||
			<div class="left">
 | 
			
		||||
				<button class="_button account" @click="openAccountMenu">
 | 
			
		||||
					<MkAvatar :user="$i" class="avatar"/><!--<MkAcct class="text" :user="$i"/>-->
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="right">
 | 
			
		||||
				<MkA class="item" to="/my/messaging" v-tooltip="$ts.messaging"><i class="fas fa-comments icon"></i><span v-if="$i.hasUnreadMessagingMessage" class="indicator"><i class="fas fa-circle"></i></span></MkA>
 | 
			
		||||
				<MkA class="item" to="/my/messages" v-tooltip="$ts.directNotes"><i class="fas fa-envelope icon"></i><span v-if="$i.hasUnreadSpecifiedNotes" class="indicator"><i class="fas fa-circle"></i></span></MkA>
 | 
			
		||||
				<MkA class="item" to="/my/mentions" v-tooltip="$ts.mentions"><i class="fas fa-at icon"></i><span v-if="$i.hasUnreadMentions" class="indicator"><i class="fas fa-circle"></i></span></MkA>
 | 
			
		||||
				<MkA class="item" to="/my/notifications" v-tooltip="$ts.notifications"><i class="fas fa-bell icon"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></MkA>
 | 
			
		||||
			</div>
 | 
			
		||||
		</header>
 | 
			
		||||
		<div class="body">
 | 
			
		||||
			<div class="container">
 | 
			
		||||
				<div class="header">{{ $ts.timeline }}</div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><i class="fas fa-home icon"></i>{{ $ts._timelines.home }}</MkA>
 | 
			
		||||
					<MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><i class="fas fa-comments icon"></i>{{ $ts._timelines.local }}</MkA>
 | 
			
		||||
					<MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><i class="fas fa-share-alt icon"></i>{{ $ts._timelines.social }}</MkA>
 | 
			
		||||
					<MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><i class="fas fa-globe icon"></i>{{ $ts._timelines.global }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="container" v-if="followedChannels">
 | 
			
		||||
				<div class="header">{{ $ts.channel }} ({{ $ts.following }})<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="container" v-if="featuredChannels">
 | 
			
		||||
				<div class="header">{{ $ts.channel }}<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="container" v-if="lists">
 | 
			
		||||
				<div class="header">{{ $ts.lists }}<button class="_button add" @click="addList"><i class="fas fa-plus"></i></button></div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><i class="fas fa-list-ul icon"></i>{{ list.name }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="container" v-if="antennas">
 | 
			
		||||
				<div class="header">{{ $ts.antennas }}<button class="_button add" @click="addAntenna"><i class="fas fa-plus"></i></button></div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><i class="fas fa-satellite icon"></i>{{ antenna.name }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="container">
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA to="/my/favorites" class="item"><i class="fas fa-star icon"></i>{{ $ts.favorites }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<MkAd class="a" :prefer="['square']"/>
 | 
			
		||||
		</div>
 | 
			
		||||
		<footer class="footer">
 | 
			
		||||
			<div class="left">
 | 
			
		||||
				<button class="_button menu" @click="showMenu">
 | 
			
		||||
					<i class="fas fa-bars icon"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="right">
 | 
			
		||||
				<button class="_button item search" @click="search" v-tooltip="$ts.search">
 | 
			
		||||
					<i class="fas fa-search icon"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
				<MkA class="item" to="/settings" v-tooltip="$ts.settings"><i class="fas fa-cog icon"></i></MkA>
 | 
			
		||||
			</div>
 | 
			
		||||
		</footer>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<main class="main" @contextmenu.stop="onContextmenu">
 | 
			
		||||
		<header class="header">
 | 
			
		||||
			<MkHeader class="header" :info="pageInfo" :menu="menu" :center="false" @click="onHeaderClick"/>
 | 
			
		||||
		</header>
 | 
			
		||||
		<router-view v-slot="{ Component }">
 | 
			
		||||
			<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
 | 
			
		||||
				<keep-alive :include="['timeline']">
 | 
			
		||||
					<component :is="Component" :ref="changePage" class="body"/>
 | 
			
		||||
				</keep-alive>
 | 
			
		||||
			</transition>
 | 
			
		||||
		</router-view>
 | 
			
		||||
	</main>
 | 
			
		||||
 | 
			
		||||
	<XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/>
 | 
			
		||||
	<div class="side widgets" :class="{ sideViewOpening }">
 | 
			
		||||
		<XWidgets/>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<XCommon/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { instanceName, url } from '@/config';
 | 
			
		||||
import XSidebar from '@/ui/_common_/sidebar.vue';
 | 
			
		||||
import XWidgets from './widgets.vue';
 | 
			
		||||
import XCommon from '../_common_/common.vue';
 | 
			
		||||
import XSide from './side.vue';
 | 
			
		||||
import XHeaderClock from './header-clock.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { router } from '@/router';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
 | 
			
		||||
import { store } from './store';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
import { openAccountMenu } from '@/account';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCommon,
 | 
			
		||||
		XSidebar,
 | 
			
		||||
		XWidgets,
 | 
			
		||||
		XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる
 | 
			
		||||
		XHeaderClock,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			sideViewHook: (path) => {
 | 
			
		||||
				this.$refs.side.navigate(path);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			lists: null,
 | 
			
		||||
			antennas: null,
 | 
			
		||||
			followedChannels: null,
 | 
			
		||||
			featuredChannels: null,
 | 
			
		||||
			currentChannel: null,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			sideViewOpening: false,
 | 
			
		||||
			instanceName,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		menu() {
 | 
			
		||||
			return [{
 | 
			
		||||
				icon: 'fas fa-columns',
 | 
			
		||||
				text: this.$ts.openInSideView,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.$refs.side.navigate(this.$route.path);
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(this.$route.path);
 | 
			
		||||
				}
 | 
			
		||||
			}];
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		if (window.innerWidth < 1024) {
 | 
			
		||||
			localStorage.setItem('ui', 'default');
 | 
			
		||||
			location.reload();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		os.api('users/lists/list').then(lists => {
 | 
			
		||||
			this.lists = lists;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		os.api('antennas/list').then(antennas => {
 | 
			
		||||
			this.antennas = antennas;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		os.api('channels/followed', { limit: 20 }).then(channels => {
 | 
			
		||||
			this.followedChannels = channels;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// TODO: pagination
 | 
			
		||||
		os.api('channels/featured', { limit: 20 }).then(channels => {
 | 
			
		||||
			this.featuredChannels = channels;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			console.log(page);
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
				document.title = `${this.pageInfo.title} | ${instanceName}`;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onTransition() {
 | 
			
		||||
			if (window._scroll) window._scroll();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showMenu() {
 | 
			
		||||
			this.$refs.menu.show();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		post() {
 | 
			
		||||
			os.post();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		search() {
 | 
			
		||||
			search();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		back() {
 | 
			
		||||
			history.back();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		top() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onTransition() {
 | 
			
		||||
			if (window._scroll) window._scroll();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onHeaderClick() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			const isLink = (el: HTMLElement) => {
 | 
			
		||||
				if (el.tagName === 'A') return true;
 | 
			
		||||
				if (el.parentElement) {
 | 
			
		||||
					return isLink(el.parentElement);
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
			if (isLink(e.target)) return;
 | 
			
		||||
			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
 | 
			
		||||
			if (window.getSelection().toString() !== '') return;
 | 
			
		||||
			const path = this.$route.path;
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				type: 'label',
 | 
			
		||||
				text: path,
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-columns',
 | 
			
		||||
				text: this.$ts.openInSideView,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.$refs.side.navigate(path);
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(path);
 | 
			
		||||
				}
 | 
			
		||||
			}], e);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		openAccountMenu,
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.mk-app {
 | 
			
		||||
	$header-height: 54px; // TODO: どこかに集約したい
 | 
			
		||||
	$ui-font-size: 1em; // TODO: どこかに集約したい
 | 
			
		||||
 | 
			
		||||
	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 | 
			
		||||
	height: calc(var(--vh, 1vh) * 100);
 | 
			
		||||
	display: flex;
 | 
			
		||||
 | 
			
		||||
	> .nav {
 | 
			
		||||
		display: flex;
 | 
			
		||||
		flex-direction: column;
 | 
			
		||||
		width: 250px;
 | 
			
		||||
		height: 100vh;
 | 
			
		||||
		border-right: solid 4px var(--divider);
 | 
			
		||||
 | 
			
		||||
		> .header, > .footer {
 | 
			
		||||
			$padding: 8px;
 | 
			
		||||
			display: flex;
 | 
			
		||||
			align-items: center;
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
			height: $header-height;
 | 
			
		||||
			padding: $padding;
 | 
			
		||||
			box-sizing: border-box;
 | 
			
		||||
			user-select: none;
 | 
			
		||||
 | 
			
		||||
			&.header {
 | 
			
		||||
				border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			&.footer {
 | 
			
		||||
				border-top: solid 0.5px var(--divider);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .left, > .right {
 | 
			
		||||
				> .item, > .menu {
 | 
			
		||||
					display: inline-flex;
 | 
			
		||||
					vertical-align: middle;
 | 
			
		||||
					height: ($header-height - ($padding * 2));
 | 
			
		||||
					width: ($header-height - ($padding * 2));
 | 
			
		||||
					box-sizing: border-box;
 | 
			
		||||
					//opacity: 0.6;
 | 
			
		||||
					position: relative;
 | 
			
		||||
					border-radius: 5px;
 | 
			
		||||
 | 
			
		||||
					&:hover {
 | 
			
		||||
						background: rgba(0, 0, 0, 0.05);
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .icon {
 | 
			
		||||
						margin: auto;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .indicator {
 | 
			
		||||
						position: absolute;
 | 
			
		||||
						top: 8px;
 | 
			
		||||
						right: 8px;
 | 
			
		||||
						color: var(--indicator);
 | 
			
		||||
						font-size: 8px;
 | 
			
		||||
						line-height: 8px;
 | 
			
		||||
						animation: blink 1s infinite;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .left {
 | 
			
		||||
				flex: 1;
 | 
			
		||||
				min-width: 0;
 | 
			
		||||
 | 
			
		||||
				> .account {
 | 
			
		||||
					display: flex;
 | 
			
		||||
					align-items: center;
 | 
			
		||||
					padding: 0 8px;
 | 
			
		||||
 | 
			
		||||
					> .avatar {
 | 
			
		||||
						width: 26px;
 | 
			
		||||
						height: 26px;
 | 
			
		||||
						margin-right: 8px;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .text {
 | 
			
		||||
						white-space: nowrap;
 | 
			
		||||
						overflow: hidden;
 | 
			
		||||
						text-overflow: ellipsis;
 | 
			
		||||
						font-size: 0.9em;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .right {
 | 
			
		||||
				margin-left: auto;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .body {
 | 
			
		||||
			flex: 1;
 | 
			
		||||
			min-width: 0;
 | 
			
		||||
			overflow: auto;
 | 
			
		||||
 | 
			
		||||
			> .container {
 | 
			
		||||
				margin-top: 8px;
 | 
			
		||||
				margin-bottom: 8px;
 | 
			
		||||
 | 
			
		||||
				& + .container {
 | 
			
		||||
					margin-top: 16px;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .header {
 | 
			
		||||
					display: flex;
 | 
			
		||||
					font-size: 0.9em;
 | 
			
		||||
					padding: 8px 16px;
 | 
			
		||||
					position: sticky;
 | 
			
		||||
					top: 0;
 | 
			
		||||
					background: var(--X17);
 | 
			
		||||
					-webkit-backdrop-filter: var(--blur, blur(8px));
 | 
			
		||||
					backdrop-filter: var(--blur, blur(8px));
 | 
			
		||||
					z-index: 1;
 | 
			
		||||
					color: var(--fgTransparentWeak);
 | 
			
		||||
 | 
			
		||||
					> .add {
 | 
			
		||||
						margin-left: auto;
 | 
			
		||||
						color: var(--fgTransparentWeak);
 | 
			
		||||
 | 
			
		||||
						&:hover {
 | 
			
		||||
							color: var(--fg);
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .body {
 | 
			
		||||
					padding: 0 8px;
 | 
			
		||||
 | 
			
		||||
					> .item {
 | 
			
		||||
						display: block;
 | 
			
		||||
						padding: 6px 8px;
 | 
			
		||||
						border-radius: 4px;
 | 
			
		||||
						white-space: nowrap;
 | 
			
		||||
						overflow: hidden;
 | 
			
		||||
						text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
						&:hover {
 | 
			
		||||
							text-decoration: none;
 | 
			
		||||
							background: rgba(0, 0, 0, 0.05);
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						&.active, &.active:hover {
 | 
			
		||||
							background: var(--accent);
 | 
			
		||||
							color: #fff !important;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						&.read {
 | 
			
		||||
							color: var(--fgTransparent);
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						> .icon {
 | 
			
		||||
							margin-right: 8px;
 | 
			
		||||
							opacity: 0.6;
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .a {
 | 
			
		||||
				margin: 12px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .main {
 | 
			
		||||
		display: flex;
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		flex-direction: column;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
		height: 100vh;
 | 
			
		||||
		position: relative;
 | 
			
		||||
		background: var(--panel);
 | 
			
		||||
 | 
			
		||||
		> .header {
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
			height: $header-height;
 | 
			
		||||
			background-color: var(--panel);
 | 
			
		||||
			border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
			user-select: none;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .body {
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			box-sizing: border-box;
 | 
			
		||||
			overflow: auto;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .side {
 | 
			
		||||
		width: 350px;
 | 
			
		||||
		border-left: solid 4px var(--divider);
 | 
			
		||||
		background: var(--panel);
 | 
			
		||||
 | 
			
		||||
		&.widgets.sideViewOpening {
 | 
			
		||||
			@media (max-width: 1400px) {
 | 
			
		||||
				display: none;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										112
									
								
								packages/client/src/ui/chat/note-header.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								packages/client/src/ui/chat/note-header.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,112 @@
 | 
			
		|||
<template>
 | 
			
		||||
<header class="dehvdgxo">
 | 
			
		||||
	<MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id">
 | 
			
		||||
		<MkUserName :user="note.user"/>
 | 
			
		||||
	</MkA>
 | 
			
		||||
	<span class="is-bot" v-if="note.user.isBot">bot</span>
 | 
			
		||||
	<span class="username"><MkAcct :user="note.user"/></span>
 | 
			
		||||
	<span class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></span>
 | 
			
		||||
	<span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></span>
 | 
			
		||||
	<div class="info">
 | 
			
		||||
		<span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span>
 | 
			
		||||
		<MkA class="created-at" :to="notePage(note)">
 | 
			
		||||
			<MkTime :time="note.createdAt"/>
 | 
			
		||||
		</MkA>
 | 
			
		||||
		<span class="visibility" v-if="note.visibility !== 'public'">
 | 
			
		||||
			<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
 | 
			
		||||
			<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
 | 
			
		||||
			<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
 | 
			
		||||
		</span>
 | 
			
		||||
		<span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
 | 
			
		||||
	</div>
 | 
			
		||||
</header>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import notePage from '@/filters/note';
 | 
			
		||||
import { userPage } from '@/filters/user';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		note: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		notePage,
 | 
			
		||||
		userPage
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.dehvdgxo {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	align-items: baseline;
 | 
			
		||||
	white-space: nowrap;
 | 
			
		||||
	font-size: 0.9em;
 | 
			
		||||
 | 
			
		||||
	> .name {
 | 
			
		||||
		display: block;
 | 
			
		||||
		margin: 0 .5em 0 0;
 | 
			
		||||
		padding: 0;
 | 
			
		||||
		overflow: hidden;
 | 
			
		||||
		font-size: 1em;
 | 
			
		||||
		font-weight: bold;
 | 
			
		||||
		text-decoration: none;
 | 
			
		||||
		text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
		&:hover {
 | 
			
		||||
			text-decoration: underline;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .is-bot {
 | 
			
		||||
		flex-shrink: 0;
 | 
			
		||||
		align-self: center;
 | 
			
		||||
		margin: 0 .5em 0 0;
 | 
			
		||||
		padding: 1px 6px;
 | 
			
		||||
		font-size: 80%;
 | 
			
		||||
		border: solid 0.5px var(--divider);
 | 
			
		||||
		border-radius: 3px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .admin,
 | 
			
		||||
	> .moderator {
 | 
			
		||||
		margin-right: 0.5em;
 | 
			
		||||
		color: var(--badge);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .username {
 | 
			
		||||
		margin: 0 .5em 0 0;
 | 
			
		||||
		overflow: hidden;
 | 
			
		||||
		text-overflow: ellipsis;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .info {
 | 
			
		||||
		font-size: 0.9em;
 | 
			
		||||
		opacity: 0.7;
 | 
			
		||||
 | 
			
		||||
		> .mobile {
 | 
			
		||||
			margin-right: 8px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .visibility {
 | 
			
		||||
			margin-left: 8px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .localOnly {
 | 
			
		||||
			margin-left: 8px;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										112
									
								
								packages/client/src/ui/chat/note-preview.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								packages/client/src/ui/chat/note-preview.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,112 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="hduudsxk">
 | 
			
		||||
	<MkAvatar class="avatar" :user="note.user"/>
 | 
			
		||||
	<div class="main">
 | 
			
		||||
		<XNoteHeader class="header" :note="note" :mini="true"/>
 | 
			
		||||
		<div class="body">
 | 
			
		||||
			<p v-if="note.cw != null" class="cw">
 | 
			
		||||
				<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
 | 
			
		||||
				<XCwButton v-model="showContent" :note="note"/>
 | 
			
		||||
			</p>
 | 
			
		||||
			<div class="content" v-show="note.cw == null || showContent">
 | 
			
		||||
				<XSubNote-content class="text" :note="note"/>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XNoteHeader from './note-header.vue';
 | 
			
		||||
import XSubNoteContent from './sub-note-content.vue';
 | 
			
		||||
import XCwButton from '@/components/cw-button.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNoteHeader,
 | 
			
		||||
		XSubNoteContent,
 | 
			
		||||
		XCwButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		note: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			showContent: false
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.hduudsxk {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	margin: 0;
 | 
			
		||||
	padding: 0;
 | 
			
		||||
	overflow: hidden;
 | 
			
		||||
	font-size: 0.95em;
 | 
			
		||||
 | 
			
		||||
	> .avatar {
 | 
			
		||||
 | 
			
		||||
		@media (min-width: 350px) {
 | 
			
		||||
			margin: 0 10px 0 0;
 | 
			
		||||
			width: 44px;
 | 
			
		||||
			height: 44px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		@media (min-width: 500px) {
 | 
			
		||||
			margin: 0 12px 0 0;
 | 
			
		||||
			width: 48px;
 | 
			
		||||
			height: 48px;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .avatar {
 | 
			
		||||
		flex-shrink: 0;
 | 
			
		||||
		display: block;
 | 
			
		||||
		margin: 0 10px 0 0;
 | 
			
		||||
		width: 40px;
 | 
			
		||||
		height: 40px;
 | 
			
		||||
		border-radius: 8px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .main {
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
 | 
			
		||||
		> .header {
 | 
			
		||||
			margin-bottom: 2px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .body {
 | 
			
		||||
 | 
			
		||||
			> .cw {
 | 
			
		||||
				cursor: default;
 | 
			
		||||
				display: block;
 | 
			
		||||
				margin: 0;
 | 
			
		||||
				padding: 0;
 | 
			
		||||
				overflow-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
				> .text {
 | 
			
		||||
					margin-right: 8px;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .content {
 | 
			
		||||
				> .text {
 | 
			
		||||
					cursor: default;
 | 
			
		||||
					margin: 0;
 | 
			
		||||
					padding: 0;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										137
									
								
								packages/client/src/ui/chat/note.sub.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								packages/client/src/ui/chat/note.sub.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,137 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="wrpstxzv" :class="{ children }">
 | 
			
		||||
	<div class="main">
 | 
			
		||||
		<MkAvatar class="avatar" :user="note.user"/>
 | 
			
		||||
		<div class="body">
 | 
			
		||||
			<XNoteHeader class="header" :note="note" :mini="true"/>
 | 
			
		||||
			<div class="body">
 | 
			
		||||
				<p v-if="note.cw != null" class="cw">
 | 
			
		||||
					<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" />
 | 
			
		||||
					<XCwButton v-model="showContent" :note="note"/>
 | 
			
		||||
				</p>
 | 
			
		||||
				<div class="content" v-show="note.cw == null || showContent">
 | 
			
		||||
					<XSubNote-content class="text" :note="note"/>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
	<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XNoteHeader from './note-header.vue';
 | 
			
		||||
import XSubNoteContent from './sub-note-content.vue';
 | 
			
		||||
import XCwButton from '@/components/cw-button.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	name: 'XSub',
 | 
			
		||||
 | 
			
		||||
	components: {
 | 
			
		||||
		XNoteHeader,
 | 
			
		||||
		XSubNoteContent,
 | 
			
		||||
		XCwButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		note: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		detail: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		children: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		// TODO
 | 
			
		||||
		truncate: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			default: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			showContent: false,
 | 
			
		||||
			replies: [],
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		if (this.detail) {
 | 
			
		||||
			os.api('notes/children', {
 | 
			
		||||
				noteId: this.note.id,
 | 
			
		||||
				limit: 5
 | 
			
		||||
			}).then(replies => {
 | 
			
		||||
				this.replies = replies;
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.wrpstxzv {
 | 
			
		||||
	padding: 16px 16px;
 | 
			
		||||
	font-size: 0.8em;
 | 
			
		||||
 | 
			
		||||
	&.children {
 | 
			
		||||
		padding: 10px 0 0 16px;
 | 
			
		||||
		font-size: 1em;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .main {
 | 
			
		||||
		display: flex;
 | 
			
		||||
 | 
			
		||||
		> .avatar {
 | 
			
		||||
			flex-shrink: 0;
 | 
			
		||||
			display: block;
 | 
			
		||||
			margin: 0 8px 0 0;
 | 
			
		||||
			width: 36px;
 | 
			
		||||
			height: 36px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .body {
 | 
			
		||||
			flex: 1;
 | 
			
		||||
			min-width: 0;
 | 
			
		||||
 | 
			
		||||
			> .header {
 | 
			
		||||
				margin-bottom: 2px;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .body {
 | 
			
		||||
				> .cw {
 | 
			
		||||
					cursor: default;
 | 
			
		||||
					display: block;
 | 
			
		||||
					margin: 0;
 | 
			
		||||
					padding: 0;
 | 
			
		||||
					overflow-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
					> .text {
 | 
			
		||||
						margin-right: 8px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .content {
 | 
			
		||||
					> .text {
 | 
			
		||||
						margin: 0;
 | 
			
		||||
						padding: 0;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .reply {
 | 
			
		||||
		border-left: solid 0.5px var(--divider);
 | 
			
		||||
		margin-top: 10px;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										1144
									
								
								packages/client/src/ui/chat/note.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1144
									
								
								packages/client/src/ui/chat/note.vue
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										94
									
								
								packages/client/src/ui/chat/notes.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								packages/client/src/ui/chat/notes.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,94 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="">
 | 
			
		||||
	<div class="_fullinfo" v-if="empty">
 | 
			
		||||
		<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 | 
			
		||||
		<div>{{ $ts.noNotes }}</div>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<MkLoading v-if="fetching"/>
 | 
			
		||||
 | 
			
		||||
	<MkError v-if="error" @retry="init()"/>
 | 
			
		||||
 | 
			
		||||
	<div v-show="more && reversed" style="margin-bottom: var(--margin);">
 | 
			
		||||
		<MkButton style="margin: 0 auto;" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
 | 
			
		||||
			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
			<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
		</MkButton>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :ad="true">
 | 
			
		||||
		<XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
 | 
			
		||||
	</XList>
 | 
			
		||||
 | 
			
		||||
	<div v-show="more && !reversed" style="margin-top: var(--margin);">
 | 
			
		||||
		<MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
 | 
			
		||||
			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
			<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
		</MkButton>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import paging from '@/scripts/paging';
 | 
			
		||||
import XNote from './note.vue';
 | 
			
		||||
import XList from './date-separated-list.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNote, XList, MkButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mixins: [
 | 
			
		||||
		paging({
 | 
			
		||||
			before: (self) => {
 | 
			
		||||
				self.$emit('before');
 | 
			
		||||
			},
 | 
			
		||||
 | 
			
		||||
			after: (self, e) => {
 | 
			
		||||
				self.$emit('after', e);
 | 
			
		||||
			}
 | 
			
		||||
		}),
 | 
			
		||||
	],
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		pagination: {
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		prop: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	emits: ['before', 'after'],
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		notes(): any[] {
 | 
			
		||||
			return this.prop ? this.items.map(item => item[this.prop]) : this.items;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		reversed(): boolean {
 | 
			
		||||
			return this.pagination.reversed;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		updated(oldValue, newValue) {
 | 
			
		||||
			const i = this.notes.findIndex(n => n === oldValue);
 | 
			
		||||
			if (this.prop) {
 | 
			
		||||
				this.items[i][this.prop] = newValue;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.items[i] = newValue;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$refs.notes.focus();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										259
									
								
								packages/client/src/ui/chat/pages/channel.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										259
									
								
								packages/client/src/ui/chat/pages/channel.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,259 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div v-if="channel" class="hhizbblb">
 | 
			
		||||
	<div class="info" v-if="date">
 | 
			
		||||
		<MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="tl" ref="body">
 | 
			
		||||
		<div class="new" v-if="queue > 0" :style="{ width: width + 'px', bottom: bottom + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
 | 
			
		||||
		<XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="true"/>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="bottom">
 | 
			
		||||
		<div class="typers" v-if="typers.length > 0">
 | 
			
		||||
			<I18n :src="$ts.typingUsers" text-tag="span" class="users">
 | 
			
		||||
				<template #users>
 | 
			
		||||
					<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
 | 
			
		||||
				</template>
 | 
			
		||||
			</I18n>
 | 
			
		||||
			<MkEllipsis/>
 | 
			
		||||
		</div>
 | 
			
		||||
		<XPostForm :channel="channel"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { computed, defineComponent, markRaw } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import XNotes from '../notes.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import * as sound from '@/scripts/sound';
 | 
			
		||||
import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
 | 
			
		||||
import follow from '@/directives/follow-append';
 | 
			
		||||
import XPostForm from '../post-form.vue';
 | 
			
		||||
import MkInfo from '@/components/ui/info.vue';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNotes,
 | 
			
		||||
		XPostForm,
 | 
			
		||||
		MkInfo,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	directives: {
 | 
			
		||||
		follow
 | 
			
		||||
	},
 | 
			
		||||
	
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			inChannel: true
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		channelId: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			channel: null as Misskey.entities.Channel | null,
 | 
			
		||||
			connection: null,
 | 
			
		||||
			pagination: null,
 | 
			
		||||
			baseQuery: {
 | 
			
		||||
				includeMyRenotes: this.$store.state.showMyRenotes,
 | 
			
		||||
				includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
 | 
			
		||||
				includeLocalRenotes: this.$store.state.showLocalRenotes
 | 
			
		||||
			},
 | 
			
		||||
			queue: 0,
 | 
			
		||||
			width: 0,
 | 
			
		||||
			top: 0,
 | 
			
		||||
			bottom: 0,
 | 
			
		||||
			typers: [],
 | 
			
		||||
			date: null,
 | 
			
		||||
			[symbols.PAGE_INFO]: computed(() => ({
 | 
			
		||||
				title: this.channel ? this.channel.name : '-',
 | 
			
		||||
				subtitle: this.channel ? this.channel.description : '-',
 | 
			
		||||
				icon: 'fas fa-satellite-dish',
 | 
			
		||||
				actions: [{
 | 
			
		||||
					icon: this.channel?.isFollowing ? 'fas fa-star' : 'far fa-star',
 | 
			
		||||
					text: this.channel?.isFollowing ? this.$ts.unfollow : this.$ts.follow,
 | 
			
		||||
					highlighted: this.channel?.isFollowing,
 | 
			
		||||
					handler: this.toggleChannelFollow
 | 
			
		||||
				}, {
 | 
			
		||||
					icon: 'fas fa-search',
 | 
			
		||||
					text: this.$ts.inChannelSearch,
 | 
			
		||||
					handler: this.inChannelSearch
 | 
			
		||||
				}, {
 | 
			
		||||
					icon: 'fas fa-calendar-alt',
 | 
			
		||||
					text: this.$ts.jumpToSpecifiedDate,
 | 
			
		||||
					handler: this.timetravel
 | 
			
		||||
				}]
 | 
			
		||||
			})),
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	async created() {
 | 
			
		||||
		this.channel = await os.api('channels/show', { channelId: this.channelId });
 | 
			
		||||
 | 
			
		||||
		const prepend = note => {
 | 
			
		||||
			(this.$refs.tl as any).prepend(note);
 | 
			
		||||
 | 
			
		||||
			this.$emit('note');
 | 
			
		||||
 | 
			
		||||
			sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		this.connection = markRaw(os.stream.useChannel('channel', {
 | 
			
		||||
			channelId: this.channelId
 | 
			
		||||
		}));
 | 
			
		||||
		this.connection.on('note', prepend);
 | 
			
		||||
		this.connection.on('typers', typers => {
 | 
			
		||||
			this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		this.pagination = {
 | 
			
		||||
			endpoint: 'channels/timeline',
 | 
			
		||||
			reversed: true,
 | 
			
		||||
			limit: 10,
 | 
			
		||||
			params: init => ({
 | 
			
		||||
				channelId: this.channelId,
 | 
			
		||||
				untilDate: this.date?.getTime(),
 | 
			
		||||
				...this.baseQuery
 | 
			
		||||
			})
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$refs.body.focus();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		goTop() {
 | 
			
		||||
			const container = getScrollContainer(this.$refs.body);
 | 
			
		||||
			container.scrollTop = 0;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		queueUpdated(q) {
 | 
			
		||||
			if (this.$refs.body.offsetWidth !== 0) {
 | 
			
		||||
				const rect = this.$refs.body.getBoundingClientRect();
 | 
			
		||||
				this.width = this.$refs.body.offsetWidth;
 | 
			
		||||
				this.top = rect.top;
 | 
			
		||||
				this.bottom = this.$refs.body.offsetHeight;
 | 
			
		||||
			}
 | 
			
		||||
			this.queue = q;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async inChannelSearch() {
 | 
			
		||||
			const { canceled, result: query } = await os.dialog({
 | 
			
		||||
				title: this.$ts.inChannelSearch,
 | 
			
		||||
				input: true
 | 
			
		||||
			});
 | 
			
		||||
			if (canceled || query == null || query === '') return;
 | 
			
		||||
			router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.channelId}`);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async toggleChannelFollow() {
 | 
			
		||||
			if (this.channel.isFollowing) {
 | 
			
		||||
				await os.apiWithDialog('channels/unfollow', {
 | 
			
		||||
					channelId: this.channel.id
 | 
			
		||||
				});
 | 
			
		||||
				this.channel.isFollowing = false;
 | 
			
		||||
			} else {
 | 
			
		||||
				await os.apiWithDialog('channels/follow', {
 | 
			
		||||
					channelId: this.channel.id
 | 
			
		||||
				});
 | 
			
		||||
				this.channel.isFollowing = true;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		openChannelMenu(ev) {
 | 
			
		||||
			os.popupMenu([{
 | 
			
		||||
				text: this.$ts.copyUrl,
 | 
			
		||||
				icon: 'fas fa-link',
 | 
			
		||||
				action: () => {
 | 
			
		||||
					copyToClipboard(`${url}/channels/${this.currentChannel.id}`);
 | 
			
		||||
				}
 | 
			
		||||
			}], ev.currentTarget || ev.target);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		timetravel(date?: Date) {
 | 
			
		||||
			this.date = date;
 | 
			
		||||
			this.$refs.tl.reload();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.hhizbblb {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	flex-direction: column;
 | 
			
		||||
	flex: 1;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
 | 
			
		||||
	> .info {
 | 
			
		||||
		padding: 16px 16px 0 16px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .top {
 | 
			
		||||
		padding: 16px 16px 0 16px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .bottom {
 | 
			
		||||
		padding: 0 16px 16px 16px;
 | 
			
		||||
		position: relative;
 | 
			
		||||
 | 
			
		||||
		> .typers {
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			bottom: 100%;
 | 
			
		||||
			padding: 0 8px 0 8px;
 | 
			
		||||
			font-size: 0.9em;
 | 
			
		||||
			background: var(--panel);
 | 
			
		||||
			border-radius: 0 8px 0 0;
 | 
			
		||||
			color: var(--fgTransparentWeak);
 | 
			
		||||
 | 
			
		||||
			> .users {
 | 
			
		||||
				> .user + .user:before {
 | 
			
		||||
					content: ", ";
 | 
			
		||||
					font-weight: normal;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .user:last-of-type:after {
 | 
			
		||||
					content: " ";
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .tl {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		padding: 16px 0;
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
 | 
			
		||||
		> .new {
 | 
			
		||||
			position: fixed;
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
 | 
			
		||||
			> button {
 | 
			
		||||
				display: block;
 | 
			
		||||
				margin: 16px auto;
 | 
			
		||||
				padding: 8px 16px;
 | 
			
		||||
				border-radius: 32px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										221
									
								
								packages/client/src/ui/chat/pages/timeline.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								packages/client/src/ui/chat/pages/timeline.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,221 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="dbiokgaf">
 | 
			
		||||
	<div class="info" v-if="date">
 | 
			
		||||
		<MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="top">
 | 
			
		||||
		<XPostForm/>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="tl" ref="body">
 | 
			
		||||
		<div class="new" v-if="queue > 0" :style="{ width: width + 'px', top: top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
 | 
			
		||||
		<XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { computed, defineComponent, markRaw } from 'vue';
 | 
			
		||||
import XNotes from '../notes.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import * as sound from '@/scripts/sound';
 | 
			
		||||
import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
 | 
			
		||||
import follow from '@/directives/follow-append';
 | 
			
		||||
import XPostForm from '../post-form.vue';
 | 
			
		||||
import MkInfo from '@/components/ui/info.vue';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNotes,
 | 
			
		||||
		XPostForm,
 | 
			
		||||
		MkInfo,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	directives: {
 | 
			
		||||
		follow
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		src: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			connection: null,
 | 
			
		||||
			connection2: null,
 | 
			
		||||
			pagination: null,
 | 
			
		||||
			baseQuery: {
 | 
			
		||||
				includeMyRenotes: this.$store.state.showMyRenotes,
 | 
			
		||||
				includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
 | 
			
		||||
				includeLocalRenotes: this.$store.state.showLocalRenotes
 | 
			
		||||
			},
 | 
			
		||||
			query: {},
 | 
			
		||||
			queue: 0,
 | 
			
		||||
			width: 0,
 | 
			
		||||
			top: 0,
 | 
			
		||||
			bottom: 0,
 | 
			
		||||
			typers: [],
 | 
			
		||||
			date: null,
 | 
			
		||||
			[symbols.PAGE_INFO]: computed(() => ({
 | 
			
		||||
				title: this.$ts.timeline,
 | 
			
		||||
				icon: 'fas fa-home',
 | 
			
		||||
				actions: [{
 | 
			
		||||
					icon: 'fas fa-calendar-alt',
 | 
			
		||||
					text: this.$ts.jumpToSpecifiedDate,
 | 
			
		||||
					handler: this.timetravel
 | 
			
		||||
				}]
 | 
			
		||||
			})),
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		const prepend = note => {
 | 
			
		||||
			(this.$refs.tl as any).prepend(note);
 | 
			
		||||
 | 
			
		||||
			this.$emit('note');
 | 
			
		||||
 | 
			
		||||
			sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const onChangeFollowing = () => {
 | 
			
		||||
			if (!this.$refs.tl.backed) {
 | 
			
		||||
				this.$refs.tl.reload();
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		let endpoint;
 | 
			
		||||
 | 
			
		||||
		if (this.src == 'home') {
 | 
			
		||||
			endpoint = 'notes/timeline';
 | 
			
		||||
			this.connection = markRaw(os.stream.useChannel('homeTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
 | 
			
		||||
			this.connection2 = markRaw(os.stream.useChannel('main'));
 | 
			
		||||
			this.connection2.on('follow', onChangeFollowing);
 | 
			
		||||
			this.connection2.on('unfollow', onChangeFollowing);
 | 
			
		||||
		} else if (this.src == 'local') {
 | 
			
		||||
			endpoint = 'notes/local-timeline';
 | 
			
		||||
			this.connection = markRaw(os.stream.useChannel('localTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		} else if (this.src == 'social') {
 | 
			
		||||
			endpoint = 'notes/hybrid-timeline';
 | 
			
		||||
			this.connection = markRaw(os.stream.useChannel('hybridTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		} else if (this.src == 'global') {
 | 
			
		||||
			endpoint = 'notes/global-timeline';
 | 
			
		||||
			this.connection = markRaw(os.stream.useChannel('globalTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.pagination = {
 | 
			
		||||
			endpoint: endpoint,
 | 
			
		||||
			limit: 10,
 | 
			
		||||
			params: init => ({
 | 
			
		||||
				untilDate: this.date?.getTime(),
 | 
			
		||||
				...this.baseQuery, ...this.query
 | 
			
		||||
			})
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
		if (this.connection2) this.connection2.dispose();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$refs.body.focus();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		goTop() {
 | 
			
		||||
			const container = getScrollContainer(this.$refs.body);
 | 
			
		||||
			container.scrollTop = 0;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		queueUpdated(q) {
 | 
			
		||||
			if (this.$refs.body.offsetWidth !== 0) {
 | 
			
		||||
				const rect = this.$refs.body.getBoundingClientRect();
 | 
			
		||||
				this.width = this.$refs.body.offsetWidth;
 | 
			
		||||
				this.top = rect.top;
 | 
			
		||||
				this.bottom = this.$refs.body.offsetHeight;
 | 
			
		||||
			}
 | 
			
		||||
			this.queue = q;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		timetravel(date?: Date) {
 | 
			
		||||
			this.date = date;
 | 
			
		||||
			this.$refs.tl.reload();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.dbiokgaf {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	flex-direction: column;
 | 
			
		||||
	flex: 1;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
 | 
			
		||||
	> .info {
 | 
			
		||||
		padding: 16px 16px 0 16px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .top {
 | 
			
		||||
		padding: 16px 16px 0 16px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .bottom {
 | 
			
		||||
		padding: 0 16px 16px 16px;
 | 
			
		||||
		position: relative;
 | 
			
		||||
 | 
			
		||||
		> .typers {
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			bottom: 100%;
 | 
			
		||||
			padding: 0 8px 0 8px;
 | 
			
		||||
			font-size: 0.9em;
 | 
			
		||||
			background: var(--panel);
 | 
			
		||||
			border-radius: 0 8px 0 0;
 | 
			
		||||
			color: var(--fgTransparentWeak);
 | 
			
		||||
 | 
			
		||||
			> .users {
 | 
			
		||||
				> .user + .user:before {
 | 
			
		||||
					content: ", ";
 | 
			
		||||
					font-weight: normal;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .user:last-of-type:after {
 | 
			
		||||
					content: " ";
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .tl {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		padding: 16px 0;
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
 | 
			
		||||
		> .new {
 | 
			
		||||
			position: fixed;
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
 | 
			
		||||
			> button {
 | 
			
		||||
				display: block;
 | 
			
		||||
				margin: 16px auto;
 | 
			
		||||
				padding: 8px 16px;
 | 
			
		||||
				border-radius: 32px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										772
									
								
								packages/client/src/ui/chat/post-form.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										772
									
								
								packages/client/src/ui/chat/post-form.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,772 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="pxiwixjf"
 | 
			
		||||
	@dragover.stop="onDragover"
 | 
			
		||||
	@dragenter="onDragenter"
 | 
			
		||||
	@dragleave="onDragleave"
 | 
			
		||||
	@drop.stop="onDrop"
 | 
			
		||||
>
 | 
			
		||||
	<div class="form">
 | 
			
		||||
		<div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div>
 | 
			
		||||
		<div v-if="visibility === 'specified'" class="to-specified">
 | 
			
		||||
			<span style="margin-right: 8px;">{{ $ts.recipient }}</span>
 | 
			
		||||
			<div class="visibleUsers">
 | 
			
		||||
				<span v-for="u in visibleUsers" :key="u.id">
 | 
			
		||||
					<MkAcct :user="u"/>
 | 
			
		||||
					<button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button>
 | 
			
		||||
				</span>
 | 
			
		||||
				<button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
 | 
			
		||||
		<textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" />
 | 
			
		||||
		<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
 | 
			
		||||
		<XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
 | 
			
		||||
		<footer>
 | 
			
		||||
			<div class="left">
 | 
			
		||||
				<button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button>
 | 
			
		||||
				<button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button>
 | 
			
		||||
				<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button>
 | 
			
		||||
				<button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button>
 | 
			
		||||
				<button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button>
 | 
			
		||||
				<button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="right">
 | 
			
		||||
				<span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
 | 
			
		||||
				<span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span>
 | 
			
		||||
				<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null">
 | 
			
		||||
					<span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
 | 
			
		||||
					<span v-if="visibility === 'home'"><i class="fas fa-home"></i></span>
 | 
			
		||||
					<span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span>
 | 
			
		||||
					<span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span>
 | 
			
		||||
				</button>
 | 
			
		||||
				<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</footer>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import insertTextAtCursor from 'insert-text-at-cursor';
 | 
			
		||||
import { length } from 'stringz';
 | 
			
		||||
import { toASCII } from 'punycode/';
 | 
			
		||||
import * as mfm from 'mfm-js';
 | 
			
		||||
import { host, url } from '@/config';
 | 
			
		||||
import { erase, unique } from '@/scripts/array';
 | 
			
		||||
import { extractMentions } from '@/scripts/extract-mentions';
 | 
			
		||||
import * as Acct from 'misskey-js/built/acct';
 | 
			
		||||
import { formatTimeString } from '@/scripts/format-time-string';
 | 
			
		||||
import { Autocomplete } from '@/scripts/autocomplete';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { selectFile } from '@/scripts/select-file';
 | 
			
		||||
import { notePostInterruptors, postFormActions } from '@/store';
 | 
			
		||||
import { isMobile } from '@/scripts/is-mobile';
 | 
			
		||||
import { throttle } from 'throttle-debounce';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')),
 | 
			
		||||
		XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue'))
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		reply: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		renote: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		channel: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		mention: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		specified: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		initialText: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		initialNote: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		share: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		autofocus: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	emits: ['posted', 'cancel', 'esc'],
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			posting: false,
 | 
			
		||||
			text: '',
 | 
			
		||||
			files: [],
 | 
			
		||||
			poll: null,
 | 
			
		||||
			useCw: false,
 | 
			
		||||
			cw: null,
 | 
			
		||||
			localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
 | 
			
		||||
			visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility,
 | 
			
		||||
			visibleUsers: [],
 | 
			
		||||
			autocomplete: null,
 | 
			
		||||
			draghover: false,
 | 
			
		||||
			quoteId: null,
 | 
			
		||||
			recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
 | 
			
		||||
			imeText: '',
 | 
			
		||||
			typing: throttle(3000, () => {
 | 
			
		||||
				if (this.channel) {
 | 
			
		||||
					os.stream.send('typingOnChannel', { channel: this.channel });
 | 
			
		||||
				}
 | 
			
		||||
			}),
 | 
			
		||||
			postFormActions,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		draftKey(): string {
 | 
			
		||||
			let key = this.channel ? `channel:${this.channel}` : '';
 | 
			
		||||
 | 
			
		||||
			if (this.renote) {
 | 
			
		||||
				key += `renote:${this.renote.id}`;
 | 
			
		||||
			} else if (this.reply) {
 | 
			
		||||
				key += `reply:${this.reply.id}`;
 | 
			
		||||
			} else {
 | 
			
		||||
				key += 'note';
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return key;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		placeholder(): string {
 | 
			
		||||
			if (this.renote) {
 | 
			
		||||
				return this.$ts._postForm.quotePlaceholder;
 | 
			
		||||
			} else if (this.reply) {
 | 
			
		||||
				return this.$ts._postForm.replyPlaceholder;
 | 
			
		||||
			} else if (this.channel) {
 | 
			
		||||
				return this.$ts._postForm.channelPlaceholder;
 | 
			
		||||
			} else {
 | 
			
		||||
				const xs = [
 | 
			
		||||
					this.$ts._postForm._placeholders.a,
 | 
			
		||||
					this.$ts._postForm._placeholders.b,
 | 
			
		||||
					this.$ts._postForm._placeholders.c,
 | 
			
		||||
					this.$ts._postForm._placeholders.d,
 | 
			
		||||
					this.$ts._postForm._placeholders.e,
 | 
			
		||||
					this.$ts._postForm._placeholders.f
 | 
			
		||||
				];
 | 
			
		||||
				return xs[Math.floor(Math.random() * xs.length)];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		submitText(): string {
 | 
			
		||||
			return this.renote
 | 
			
		||||
				? this.$ts.quote
 | 
			
		||||
				: this.reply
 | 
			
		||||
					? this.$ts.reply
 | 
			
		||||
					: this.$ts.note;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		textLength(): number {
 | 
			
		||||
			return length((this.text + this.imeText).trim());
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		canPost(): boolean {
 | 
			
		||||
			return !this.posting &&
 | 
			
		||||
				(1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
 | 
			
		||||
				(this.textLength <= this.max) &&
 | 
			
		||||
				(!this.poll || this.poll.choices.length >= 2);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		max(): number {
 | 
			
		||||
			return this.$instance ? this.$instance.maxNoteTextLength : 1000;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (this.initialText) {
 | 
			
		||||
			this.text = this.initialText;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.mention) {
 | 
			
		||||
			this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
 | 
			
		||||
			this.text += ' ';
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.reply && this.reply.user.host != null) {
 | 
			
		||||
			this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.reply && this.reply.text != null) {
 | 
			
		||||
			const ast = mfm.parse(this.reply.text);
 | 
			
		||||
 | 
			
		||||
			for (const x of extractMentions(ast)) {
 | 
			
		||||
				const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
 | 
			
		||||
 | 
			
		||||
				// 自分は除外
 | 
			
		||||
				if (this.$i.username == x.username && x.host == null) continue;
 | 
			
		||||
				if (this.$i.username == x.username && x.host == host) continue;
 | 
			
		||||
 | 
			
		||||
				// 重複は除外
 | 
			
		||||
				if (this.text.indexOf(`${mention} `) != -1) continue;
 | 
			
		||||
 | 
			
		||||
				this.text += `${mention} `;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.channel) {
 | 
			
		||||
			this.visibility = 'public';
 | 
			
		||||
			this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
 | 
			
		||||
		if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
 | 
			
		||||
			this.visibility = this.reply.visibility;
 | 
			
		||||
			if (this.reply.visibility === 'specified') {
 | 
			
		||||
				os.api('users/show', {
 | 
			
		||||
					userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
 | 
			
		||||
				}).then(users => {
 | 
			
		||||
					this.visibleUsers.push(...users);
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				if (this.reply.userId !== this.$i.id) {
 | 
			
		||||
					os.api('users/show', { userId: this.reply.userId }).then(user => {
 | 
			
		||||
						this.visibleUsers.push(user);
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.specified) {
 | 
			
		||||
			this.visibility = 'specified';
 | 
			
		||||
			this.visibleUsers.push(this.specified);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// keep cw when reply
 | 
			
		||||
		if (this.$store.state.keepCw && this.reply && this.reply.cw) {
 | 
			
		||||
			this.useCw = true;
 | 
			
		||||
			this.cw = this.reply.cw;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.autofocus) {
 | 
			
		||||
			this.focus();
 | 
			
		||||
 | 
			
		||||
			this.$nextTick(() => {
 | 
			
		||||
				this.focus();
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// TODO: detach when unmount
 | 
			
		||||
		new Autocomplete(this.$refs.text, this, { model: 'text' });
 | 
			
		||||
		new Autocomplete(this.$refs.cw, this, { model: 'cw' });
 | 
			
		||||
 | 
			
		||||
		this.$nextTick(() => {
 | 
			
		||||
			// 書きかけの投稿を復元
 | 
			
		||||
			if (!this.share && !this.mention && !this.specified) {
 | 
			
		||||
				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
 | 
			
		||||
				if (draft) {
 | 
			
		||||
					this.text = draft.data.text;
 | 
			
		||||
					this.useCw = draft.data.useCw;
 | 
			
		||||
					this.cw = draft.data.cw;
 | 
			
		||||
					this.visibility = draft.data.visibility;
 | 
			
		||||
					this.localOnly = draft.data.localOnly;
 | 
			
		||||
					this.files = (draft.data.files || []).filter(e => e);
 | 
			
		||||
					if (draft.data.poll) {
 | 
			
		||||
						this.poll = draft.data.poll;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// 削除して編集
 | 
			
		||||
			if (this.initialNote) {
 | 
			
		||||
				const init = this.initialNote;
 | 
			
		||||
				this.text = init.text ? init.text : '';
 | 
			
		||||
				this.files = init.files;
 | 
			
		||||
				this.cw = init.cw;
 | 
			
		||||
				this.useCw = init.cw != null;
 | 
			
		||||
				if (init.poll) {
 | 
			
		||||
					this.poll = init.poll;
 | 
			
		||||
				}
 | 
			
		||||
				this.visibility = init.visibility;
 | 
			
		||||
				this.localOnly = init.localOnly;
 | 
			
		||||
				this.quoteId = init.renote ? init.renote.id : null;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			this.$nextTick(() => this.watch());
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		watch() {
 | 
			
		||||
			this.$watch('text', () => this.saveDraft());
 | 
			
		||||
			this.$watch('useCw', () => this.saveDraft());
 | 
			
		||||
			this.$watch('cw', () => this.saveDraft());
 | 
			
		||||
			this.$watch('poll', () => this.saveDraft());
 | 
			
		||||
			this.$watch('files', () => this.saveDraft(), { deep: true });
 | 
			
		||||
			this.$watch('visibility', () => this.saveDraft());
 | 
			
		||||
			this.$watch('localOnly', () => this.saveDraft());
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		togglePoll() {
 | 
			
		||||
			if (this.poll) {
 | 
			
		||||
				this.poll = null;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.poll = {
 | 
			
		||||
					choices: ['', ''],
 | 
			
		||||
					multiple: false,
 | 
			
		||||
					expiresAt: null,
 | 
			
		||||
					expiredAfter: null,
 | 
			
		||||
				};
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		addTag(tag: string) {
 | 
			
		||||
			insertTextAtCursor(this.$refs.text, ` #${tag} `);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focus() {
 | 
			
		||||
			(this.$refs.text as any).focus();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		chooseFileFrom(ev) {
 | 
			
		||||
			selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => {
 | 
			
		||||
				for (const file of files) {
 | 
			
		||||
					this.files.push(file);
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		detachFile(id) {
 | 
			
		||||
			this.files = this.files.filter(x => x.id != id);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateFiles(files) {
 | 
			
		||||
			this.files = files;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateFileSensitive(file, sensitive) {
 | 
			
		||||
			this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateFileName(file, name) {
 | 
			
		||||
			this.files[this.files.findIndex(x => x.id === file.id)].name = name;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		upload(file: File, name?: string) {
 | 
			
		||||
			os.upload(file, this.$store.state.uploadFolder, name).then(res => {
 | 
			
		||||
				this.files.push(res);
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onPollUpdate(poll) {
 | 
			
		||||
			this.poll = poll;
 | 
			
		||||
			this.saveDraft();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		setVisibility() {
 | 
			
		||||
			if (this.channel) {
 | 
			
		||||
				// TODO: information dialog
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			os.popup(import('@/components/visibility-picker.vue'), {
 | 
			
		||||
				currentVisibility: this.visibility,
 | 
			
		||||
				currentLocalOnly: this.localOnly,
 | 
			
		||||
				src: this.$refs.visibilityButton
 | 
			
		||||
			}, {
 | 
			
		||||
				changeVisibility: visibility => {
 | 
			
		||||
					this.visibility = visibility;
 | 
			
		||||
					if (this.$store.state.rememberNoteVisibility) {
 | 
			
		||||
						this.$store.set('visibility', visibility);
 | 
			
		||||
					}
 | 
			
		||||
				},
 | 
			
		||||
				changeLocalOnly: localOnly => {
 | 
			
		||||
					this.localOnly = localOnly;
 | 
			
		||||
					if (this.$store.state.rememberNoteVisibility) {
 | 
			
		||||
						this.$store.set('localOnly', localOnly);
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		addVisibleUser() {
 | 
			
		||||
			os.selectUser().then(user => {
 | 
			
		||||
				this.visibleUsers.push(user);
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		removeVisibleUser(user) {
 | 
			
		||||
			this.visibleUsers = erase(user, this.visibleUsers);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		clear() {
 | 
			
		||||
			this.text = '';
 | 
			
		||||
			this.files = [];
 | 
			
		||||
			this.poll = null;
 | 
			
		||||
			this.quoteId = null;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onKeydown(e: KeyboardEvent) {
 | 
			
		||||
			if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
 | 
			
		||||
			if (e.which === 27) this.$emit('esc');
 | 
			
		||||
			this.typing();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onCompositionUpdate(e: CompositionEvent) {
 | 
			
		||||
			this.imeText = e.data;
 | 
			
		||||
			this.typing();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onCompositionEnd(e: CompositionEvent) {
 | 
			
		||||
			this.imeText = '';
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async onPaste(e: ClipboardEvent) {
 | 
			
		||||
			for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
 | 
			
		||||
				if (item.kind == 'file') {
 | 
			
		||||
					const file = item.getAsFile();
 | 
			
		||||
					const lio = file.name.lastIndexOf('.');
 | 
			
		||||
					const ext = lio >= 0 ? file.name.slice(lio) : '';
 | 
			
		||||
					const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
 | 
			
		||||
					this.upload(file, formatted);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const paste = e.clipboardData.getData('text');
 | 
			
		||||
 | 
			
		||||
			if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
 | 
			
		||||
				os.dialog({
 | 
			
		||||
					type: 'info',
 | 
			
		||||
					text: this.$ts.quoteQuestion,
 | 
			
		||||
					showCancelButton: true
 | 
			
		||||
				}).then(({ canceled }) => {
 | 
			
		||||
					if (canceled) {
 | 
			
		||||
						insertTextAtCursor(this.$refs.text, paste);
 | 
			
		||||
						return;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragover(e) {
 | 
			
		||||
			if (!e.dataTransfer.items[0]) return;
 | 
			
		||||
			const isFile = e.dataTransfer.items[0].kind == 'file';
 | 
			
		||||
			const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
 | 
			
		||||
			if (isFile || isDriveFile) {
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
				this.draghover = true;
 | 
			
		||||
				e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragenter(e) {
 | 
			
		||||
			this.draghover = true;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragleave(e) {
 | 
			
		||||
			this.draghover = false;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDrop(e): void {
 | 
			
		||||
			this.draghover = false;
 | 
			
		||||
 | 
			
		||||
			// ファイルだったら
 | 
			
		||||
			if (e.dataTransfer.files.length > 0) {
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
				for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			//#region ドライブのファイル
 | 
			
		||||
			const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
 | 
			
		||||
			if (driveFile != null && driveFile != '') {
 | 
			
		||||
				const file = JSON.parse(driveFile);
 | 
			
		||||
				this.files.push(file);
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
			}
 | 
			
		||||
			//#endregion
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		saveDraft() {
 | 
			
		||||
			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
 | 
			
		||||
 | 
			
		||||
			data[this.draftKey] = {
 | 
			
		||||
				updatedAt: new Date(),
 | 
			
		||||
				data: {
 | 
			
		||||
					text: this.text,
 | 
			
		||||
					useCw: this.useCw,
 | 
			
		||||
					cw: this.cw,
 | 
			
		||||
					visibility: this.visibility,
 | 
			
		||||
					localOnly: this.localOnly,
 | 
			
		||||
					files: this.files,
 | 
			
		||||
					poll: this.poll
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			localStorage.setItem('drafts', JSON.stringify(data));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		deleteDraft() {
 | 
			
		||||
			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
 | 
			
		||||
 | 
			
		||||
			delete data[this.draftKey];
 | 
			
		||||
 | 
			
		||||
			localStorage.setItem('drafts', JSON.stringify(data));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async post() {
 | 
			
		||||
			let data = {
 | 
			
		||||
				text: this.text == '' ? undefined : this.text,
 | 
			
		||||
				fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
 | 
			
		||||
				replyId: this.reply ? this.reply.id : undefined,
 | 
			
		||||
				renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
 | 
			
		||||
				channelId: this.channel ? this.channel : undefined,
 | 
			
		||||
				poll: this.poll,
 | 
			
		||||
				cw: this.useCw ? this.cw || '' : undefined,
 | 
			
		||||
				localOnly: this.localOnly,
 | 
			
		||||
				visibility: this.visibility,
 | 
			
		||||
				visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
 | 
			
		||||
				viaMobile: isMobile
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			// plugin
 | 
			
		||||
			if (notePostInterruptors.length > 0) {
 | 
			
		||||
				for (const interruptor of notePostInterruptors) {
 | 
			
		||||
					data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			this.posting = true;
 | 
			
		||||
			os.api('notes/create', data).then(() => {
 | 
			
		||||
				this.clear();
 | 
			
		||||
				this.$nextTick(() => {
 | 
			
		||||
					this.deleteDraft();
 | 
			
		||||
					this.$emit('posted');
 | 
			
		||||
					if (this.text && this.text != '') {
 | 
			
		||||
						const hashtags = mfm.parse(this.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
 | 
			
		||||
						const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
 | 
			
		||||
						localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
 | 
			
		||||
					}
 | 
			
		||||
					this.posting = false;
 | 
			
		||||
				});
 | 
			
		||||
			}).catch(err => {
 | 
			
		||||
				this.posting = false;
 | 
			
		||||
				os.dialog({
 | 
			
		||||
					type: 'error',
 | 
			
		||||
					text: err.message + '\n' + (err as any).id,
 | 
			
		||||
				});
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		cancel() {
 | 
			
		||||
			this.$emit('cancel');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		insertMention() {
 | 
			
		||||
			os.selectUser().then(user => {
 | 
			
		||||
				insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' ');
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async insertEmoji(ev) {
 | 
			
		||||
			os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showActions(ev) {
 | 
			
		||||
			os.popupMenu(postFormActions.map(action => ({
 | 
			
		||||
				text: action.title,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					action.handler({
 | 
			
		||||
						text: this.text
 | 
			
		||||
					}, (key, value) => {
 | 
			
		||||
						if (key === 'text') { this.text = value; }
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			})), ev.currentTarget || ev.target);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.pxiwixjf {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	border: solid 0.5px var(--divider);
 | 
			
		||||
	border-radius: 8px;
 | 
			
		||||
 | 
			
		||||
	> .form {
 | 
			
		||||
		> .preview {
 | 
			
		||||
			padding: 16px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .with-quote {
 | 
			
		||||
			margin: 0 0 8px 0;
 | 
			
		||||
			color: var(--accent);
 | 
			
		||||
 | 
			
		||||
			> button {
 | 
			
		||||
				padding: 4px 8px;
 | 
			
		||||
				color: var(--accentAlpha04);
 | 
			
		||||
 | 
			
		||||
				&:hover {
 | 
			
		||||
					color: var(--accentAlpha06);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				&:active {
 | 
			
		||||
					color: var(--accentDarken30);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .to-specified {
 | 
			
		||||
			padding: 6px 24px;
 | 
			
		||||
			margin-bottom: 8px;
 | 
			
		||||
			overflow: auto;
 | 
			
		||||
			white-space: nowrap;
 | 
			
		||||
 | 
			
		||||
			> .visibleUsers {
 | 
			
		||||
				display: inline;
 | 
			
		||||
				top: -1px;
 | 
			
		||||
				font-size: 14px;
 | 
			
		||||
 | 
			
		||||
				> button {
 | 
			
		||||
					padding: 4px;
 | 
			
		||||
					border-radius: 8px;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> span {
 | 
			
		||||
					margin-right: 14px;
 | 
			
		||||
					padding: 8px 0 8px 8px;
 | 
			
		||||
					border-radius: 8px;
 | 
			
		||||
					background: var(--X4);
 | 
			
		||||
 | 
			
		||||
					> button {
 | 
			
		||||
						padding: 4px 8px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .cw,
 | 
			
		||||
		> .text {
 | 
			
		||||
			display: block;
 | 
			
		||||
			box-sizing: border-box;
 | 
			
		||||
			padding: 16px;
 | 
			
		||||
			margin: 0;
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			font-size: 16px;
 | 
			
		||||
			border: none;
 | 
			
		||||
			border-radius: 0;
 | 
			
		||||
			background: transparent;
 | 
			
		||||
			color: var(--fg);
 | 
			
		||||
			font-family: inherit;
 | 
			
		||||
 | 
			
		||||
			&:focus {
 | 
			
		||||
				outline: none;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			&:disabled {
 | 
			
		||||
				opacity: 0.5;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .cw {
 | 
			
		||||
			z-index: 1;
 | 
			
		||||
			padding-bottom: 8px;
 | 
			
		||||
			border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .text {
 | 
			
		||||
			max-width: 100%;
 | 
			
		||||
			min-width: 100%;
 | 
			
		||||
			min-height: 60px;
 | 
			
		||||
 | 
			
		||||
			&.withCw {
 | 
			
		||||
				padding-top: 8px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> footer {
 | 
			
		||||
			$height: 44px;
 | 
			
		||||
			display: flex;
 | 
			
		||||
			padding: 0 8px 8px 8px;
 | 
			
		||||
			line-height: $height;
 | 
			
		||||
 | 
			
		||||
			> .left {
 | 
			
		||||
				> button {
 | 
			
		||||
					display: inline-block;
 | 
			
		||||
					padding: 0;
 | 
			
		||||
					margin: 0;
 | 
			
		||||
					font-size: 16px;
 | 
			
		||||
					width: $height;
 | 
			
		||||
					height: $height;
 | 
			
		||||
					border-radius: 6px;
 | 
			
		||||
 | 
			
		||||
					&:hover {
 | 
			
		||||
						background: var(--X5);
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					&.active {
 | 
			
		||||
						color: var(--accent);
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .right {
 | 
			
		||||
				margin-left: auto;
 | 
			
		||||
 | 
			
		||||
				> .text-count {
 | 
			
		||||
					opacity: 0.7;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .visibility {
 | 
			
		||||
					width: $height;
 | 
			
		||||
					margin: 0 8px;
 | 
			
		||||
 | 
			
		||||
					& + .localOnly {
 | 
			
		||||
						margin-left: 0 !important;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				
 | 
			
		||||
				> .local-only {
 | 
			
		||||
					margin: 0 0 0 12px;
 | 
			
		||||
					opacity: 0.7;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .submit {
 | 
			
		||||
					margin: 0;
 | 
			
		||||
					padding: 0 12px;
 | 
			
		||||
					line-height: 34px;
 | 
			
		||||
					font-weight: bold;
 | 
			
		||||
					border-radius: 4px;
 | 
			
		||||
 | 
			
		||||
					&:disabled {
 | 
			
		||||
						opacity: 0.7;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> i {
 | 
			
		||||
						margin-left: 6px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										157
									
								
								packages/client/src/ui/chat/side.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								packages/client/src/ui/chat/side.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,157 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mrajymqm _narrow_" v-if="component">
 | 
			
		||||
	<header class="header" @contextmenu.prevent.stop="onContextmenu">
 | 
			
		||||
		<MkHeader class="title" :info="pageInfo" :center="false"/>
 | 
			
		||||
	</header>
 | 
			
		||||
	<component :is="component" v-bind="props" :ref="changePage" class="body"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
 | 
			
		||||
import { resolve } from '@/router';
 | 
			
		||||
import { url } from '@/config';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			navHook: (path) => {
 | 
			
		||||
				this.navigate(path);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			path: null,
 | 
			
		||||
			component: null,
 | 
			
		||||
			props: {},
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			history: [],
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		url(): string {
 | 
			
		||||
			return url + this.path;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		navigate(path, record = true) {
 | 
			
		||||
			if (record && this.path) this.history.push(this.path);
 | 
			
		||||
			this.path = path;
 | 
			
		||||
			const { component, props } = resolve(path);
 | 
			
		||||
			this.component = component;
 | 
			
		||||
			this.props = props;
 | 
			
		||||
			this.$emit('open');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		back() {
 | 
			
		||||
			this.navigate(this.history.pop(), false);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		close() {
 | 
			
		||||
			this.path = null;
 | 
			
		||||
			this.component = null;
 | 
			
		||||
			this.props = {};
 | 
			
		||||
			this.$emit('close');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				type: 'label',
 | 
			
		||||
				text: this.path,
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-expand-alt',
 | 
			
		||||
				text: this.$ts.showInPage,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.$router.push(this.path);
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(this.path);
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, null, {
 | 
			
		||||
				icon: 'fas fa-external-link-alt',
 | 
			
		||||
				text: this.$ts.openInNewTab,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					window.open(this.url, '_blank');
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-link',
 | 
			
		||||
				text: this.$ts.copyLink,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					copyToClipboard(this.url);
 | 
			
		||||
				}
 | 
			
		||||
			}], e);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.mrajymqm {
 | 
			
		||||
	$header-height: 54px; // TODO: どこかに集約したい
 | 
			
		||||
 | 
			
		||||
	--root-margin: 16px;
 | 
			
		||||
	--margin: var(--marginHalf);
 | 
			
		||||
 | 
			
		||||
	height: 100%;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
	> .header {
 | 
			
		||||
		display: flex;
 | 
			
		||||
		position: sticky;
 | 
			
		||||
		z-index: 1000;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		height: $header-height;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		font-weight: bold;
 | 
			
		||||
		//background-color: var(--panel);
 | 
			
		||||
		-webkit-backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
		backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
		background-color: var(--header);
 | 
			
		||||
		border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
		> ._button {
 | 
			
		||||
			height: $header-height;
 | 
			
		||||
			width: $header-height;
 | 
			
		||||
 | 
			
		||||
			&:hover {
 | 
			
		||||
				color: var(--fgHighlighted);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .title {
 | 
			
		||||
			flex: 1;
 | 
			
		||||
			position: relative;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .body {
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										17
									
								
								packages/client/src/ui/chat/store.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								packages/client/src/ui/chat/store.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
import { markRaw } from 'vue';
 | 
			
		||||
import { Storage } from '../../pizzax';
 | 
			
		||||
 | 
			
		||||
export const store = markRaw(new Storage('chatUi', {
 | 
			
		||||
	widgets: {
 | 
			
		||||
		where: 'account',
 | 
			
		||||
		default: [] as {
 | 
			
		||||
			name: string;
 | 
			
		||||
			id: string;
 | 
			
		||||
			data: Record<string, any>;
 | 
			
		||||
		}[]
 | 
			
		||||
	},
 | 
			
		||||
	tl: {
 | 
			
		||||
		where: 'deviceAccount',
 | 
			
		||||
		default: 'home'
 | 
			
		||||
	},
 | 
			
		||||
}));
 | 
			
		||||
							
								
								
									
										62
									
								
								packages/client/src/ui/chat/sub-note-content.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								packages/client/src/ui/chat/sub-note-content.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,62 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="wrmlmaau">
 | 
			
		||||
	<div class="body">
 | 
			
		||||
		<span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
 | 
			
		||||
		<span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span>
 | 
			
		||||
		<MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
 | 
			
		||||
		<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
 | 
			
		||||
		<MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
 | 
			
		||||
	</div>
 | 
			
		||||
	<details v-if="note.files.length > 0">
 | 
			
		||||
		<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
 | 
			
		||||
		<XMediaList :media-list="note.files"/>
 | 
			
		||||
	</details>
 | 
			
		||||
	<details v-if="note.poll">
 | 
			
		||||
		<summary>{{ $ts.poll }}</summary>
 | 
			
		||||
		<XPoll :note="note"/>
 | 
			
		||||
	</details>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XPoll from '@/components/poll.vue';
 | 
			
		||||
import XMediaList from '@/components/media-list.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XPoll,
 | 
			
		||||
		XMediaList,
 | 
			
		||||
	},
 | 
			
		||||
	props: {
 | 
			
		||||
		note: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.wrmlmaau {
 | 
			
		||||
	overflow-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
	> .body {
 | 
			
		||||
		> .reply {
 | 
			
		||||
			margin-right: 6px;
 | 
			
		||||
			color: var(--accent);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .rp {
 | 
			
		||||
			margin-left: 4px;
 | 
			
		||||
			font-style: oblique;
 | 
			
		||||
			color: var(--renote);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										62
									
								
								packages/client/src/ui/chat/widgets.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								packages/client/src/ui/chat/widgets.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,62 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="qydbhufi">
 | 
			
		||||
	<XWidgets :edit="edit" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
 | 
			
		||||
 | 
			
		||||
	<button v-if="edit" @click="edit = false" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgetsExit }}</button>
 | 
			
		||||
	<button v-else @click="edit = true" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgets }}</button>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import XWidgets from '@/components/widgets.vue';
 | 
			
		||||
import { store } from './store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XWidgets,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			edit: false,
 | 
			
		||||
			widgets: store.reactiveState.widgets
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		addWidget(widget) {
 | 
			
		||||
			store.set('widgets', [widget, ...store.state.widgets]);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		removeWidget(widget) {
 | 
			
		||||
			store.set('widgets', store.state.widgets.filter(w => w.id != widget.id));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidget({ id, data }) {
 | 
			
		||||
			// TODO: throttleしたい
 | 
			
		||||
			store.set('widgets', store.state.widgets.map(w => w.id === id ? {
 | 
			
		||||
				...w,
 | 
			
		||||
				data: data
 | 
			
		||||
			} : w));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidgets(widgets) {
 | 
			
		||||
			store.set('widgets', widgets);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.qydbhufi {
 | 
			
		||||
	height: 100%;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
	padding: var(--margin);
 | 
			
		||||
 | 
			
		||||
	::v-deep(._panel) {
 | 
			
		||||
		box-shadow: none;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										210
									
								
								packages/client/src/ui/classic.header.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								packages/client/src/ui/classic.header.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,210 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="azykntjl">
 | 
			
		||||
	<div class="body">
 | 
			
		||||
		<div class="left">
 | 
			
		||||
			<MkA class="item index" active-class="active" to="/" exact v-click-anime v-tooltip="$ts.timeline">
 | 
			
		||||
				<i class="fas fa-home fa-fw"></i>
 | 
			
		||||
			</MkA>
 | 
			
		||||
			<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 ? 'MkA' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime v-tooltip="$ts[menuDef[item].title]">
 | 
			
		||||
					<i class="fa-fw" :class="menuDef[item].icon"></i>
 | 
			
		||||
					<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" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.controlPanel">
 | 
			
		||||
				<i class="fas fa-door-open fa-fw"></i>
 | 
			
		||||
			</MkA>
 | 
			
		||||
			<button class="item _button" @click="more" v-click-anime>
 | 
			
		||||
				<i class="fas fa-ellipsis-h fa-fw"></i>
 | 
			
		||||
				<span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
 | 
			
		||||
			</button>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div class="right">
 | 
			
		||||
			<MkA class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.settings">
 | 
			
		||||
				<i class="fas fa-cog fa-fw"></i>
 | 
			
		||||
			</MkA>
 | 
			
		||||
			<button class="item _button account" @click="openAccountMenu" v-click-anime>
 | 
			
		||||
				<MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/>
 | 
			
		||||
			</button>
 | 
			
		||||
			<div class="post" @click="post">
 | 
			
		||||
				<MkButton class="button" gradate full rounded>
 | 
			
		||||
					<i class="fas fa-pencil-alt fa-fw"></i>
 | 
			
		||||
				</MkButton>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { host } from '@/config';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import { openAccountMenu } from '@/account';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			host: host,
 | 
			
		||||
			accounts: [],
 | 
			
		||||
			connection: null,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			settingsWindowed: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		menu(): string[] {
 | 
			
		||||
			return this.$store.state.menu;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		otherNavItemIndicated(): boolean {
 | 
			
		||||
			for (const def in this.menuDef) {
 | 
			
		||||
				if (this.menu.includes(def)) continue;
 | 
			
		||||
				if (this.menuDef[def].indicated) return true;
 | 
			
		||||
			}
 | 
			
		||||
			return false;
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		'$store.reactiveState.menuDisplay.value'() {
 | 
			
		||||
			this.calcViewState();
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		window.addEventListener('resize', this.calcViewState);
 | 
			
		||||
		this.calcViewState();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		calcViewState() {
 | 
			
		||||
			this.settingsWindowed = (window.innerWidth > 1400);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		post() {
 | 
			
		||||
			os.post();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		search() {
 | 
			
		||||
			search();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		more(ev) {
 | 
			
		||||
			os.popup(import('@/components/launch-pad.vue'), {}, {
 | 
			
		||||
			}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		openAccountMenu,
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.azykntjl {
 | 
			
		||||
	$height: 60px;
 | 
			
		||||
	$avatar-size: 32px;
 | 
			
		||||
	$avatar-margin: 8px;
 | 
			
		||||
 | 
			
		||||
	position: sticky;
 | 
			
		||||
	top: 0;
 | 
			
		||||
	z-index: 1000;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	height: $height;
 | 
			
		||||
	background-color: var(--bg);
 | 
			
		||||
 | 
			
		||||
	> .body {
 | 
			
		||||
		max-width: 1380px;
 | 
			
		||||
		margin: 0 auto;
 | 
			
		||||
		display: flex;
 | 
			
		||||
 | 
			
		||||
		> .right,
 | 
			
		||||
		> .left {
 | 
			
		||||
 | 
			
		||||
			> .item {
 | 
			
		||||
				position: relative;
 | 
			
		||||
				font-size: 0.9em;
 | 
			
		||||
				display: inline-block;
 | 
			
		||||
				padding: 0 12px;
 | 
			
		||||
				line-height: $height;
 | 
			
		||||
 | 
			
		||||
				> i,
 | 
			
		||||
				> .avatar {
 | 
			
		||||
					margin-right: 0;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> i {
 | 
			
		||||
					left: 10px;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .avatar {
 | 
			
		||||
					width: $avatar-size;
 | 
			
		||||
					height: $avatar-size;
 | 
			
		||||
					vertical-align: middle;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .indicator {
 | 
			
		||||
					position: absolute;
 | 
			
		||||
					top: 0;
 | 
			
		||||
					left: 0;
 | 
			
		||||
					color: var(--navIndicator);
 | 
			
		||||
					font-size: 8px;
 | 
			
		||||
					animation: blink 1s infinite;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				&:hover {
 | 
			
		||||
					text-decoration: none;
 | 
			
		||||
					color: var(--navHoverFg);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				&.active {
 | 
			
		||||
					color: var(--navActive);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .divider {
 | 
			
		||||
				display: inline-block;
 | 
			
		||||
				height: 16px;
 | 
			
		||||
				margin: 0 10px;
 | 
			
		||||
				border-right: solid 0.5px var(--divider);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .post {
 | 
			
		||||
				display: inline-block;
 | 
			
		||||
			
 | 
			
		||||
				> .button {
 | 
			
		||||
					width: 40px;
 | 
			
		||||
					height: 40px;
 | 
			
		||||
					padding: 0;
 | 
			
		||||
					min-width: 0;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .account {
 | 
			
		||||
				display: inline-flex;
 | 
			
		||||
				align-items: center;
 | 
			
		||||
				vertical-align: top;
 | 
			
		||||
				margin-right: 8px;
 | 
			
		||||
 | 
			
		||||
				> .acct {
 | 
			
		||||
					margin-left: 8px;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .right {
 | 
			
		||||
			margin-left: auto;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										158
									
								
								packages/client/src/ui/classic.side.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								packages/client/src/ui/classic.side.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,158 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="qvzfzxam _narrow_" v-if="component">
 | 
			
		||||
	<div class="container">
 | 
			
		||||
		<header class="header" @contextmenu.prevent.stop="onContextmenu">
 | 
			
		||||
			<button class="_button" @click="back()" v-if="history.length > 0"><i class="fas fa-chevron-left"></i></button>
 | 
			
		||||
			<button class="_button" style="pointer-events: none;" v-else><!-- マージンのバランスを取るためのダミー --></button>
 | 
			
		||||
			<span class="title">{{ pageInfo.title }}</span>
 | 
			
		||||
			<button class="_button" @click="close()"><i class="fas fa-times"></i></button>
 | 
			
		||||
		</header>
 | 
			
		||||
		<MkHeader class="pageHeader" :info="pageInfo"/>
 | 
			
		||||
		<component :is="component" v-bind="props" :ref="changePage"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
 | 
			
		||||
import { resolve } from '@/router';
 | 
			
		||||
import { url } from '@/config';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			navHook: (path) => {
 | 
			
		||||
				this.navigate(path);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			path: null,
 | 
			
		||||
			component: null,
 | 
			
		||||
			props: {},
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			history: [],
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		url(): string {
 | 
			
		||||
			return url + this.path;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		navigate(path, record = true) {
 | 
			
		||||
			if (record && this.path) this.history.push(this.path);
 | 
			
		||||
			this.path = path;
 | 
			
		||||
			const { component, props } = resolve(path);
 | 
			
		||||
			this.component = component;
 | 
			
		||||
			this.props = props;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		back() {
 | 
			
		||||
			this.navigate(this.history.pop(), false);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		close() {
 | 
			
		||||
			this.path = null;
 | 
			
		||||
			this.component = null;
 | 
			
		||||
			this.props = {};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				type: 'label',
 | 
			
		||||
				text: this.path,
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-expand-alt',
 | 
			
		||||
				text: this.$ts.showInPage,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.$router.push(this.path);
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(this.path);
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, null, {
 | 
			
		||||
				icon: 'fas fa-external-link-alt',
 | 
			
		||||
				text: this.$ts.openInNewTab,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					window.open(this.url, '_blank');
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-link',
 | 
			
		||||
				text: this.$ts.copyLink,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					copyToClipboard(this.url);
 | 
			
		||||
				}
 | 
			
		||||
			}], e);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.qvzfzxam {
 | 
			
		||||
	$header-height: 58px; // TODO: どこかに集約したい
 | 
			
		||||
 | 
			
		||||
	--root-margin: 16px;
 | 
			
		||||
	--margin: var(--marginHalf);
 | 
			
		||||
 | 
			
		||||
	> .container {
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		width: 370px;
 | 
			
		||||
		height: 100vh;
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
		> .header {
 | 
			
		||||
			display: flex;
 | 
			
		||||
			position: sticky;
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
			top: 0;
 | 
			
		||||
			height: $header-height;
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			line-height: $header-height;
 | 
			
		||||
			text-align: center;
 | 
			
		||||
			font-weight: bold;
 | 
			
		||||
			//background-color: var(--panel);
 | 
			
		||||
			-webkit-backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
			backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
			background-color: var(--header);
 | 
			
		||||
 | 
			
		||||
			> ._button {
 | 
			
		||||
				height: $header-height;
 | 
			
		||||
				width: $header-height;
 | 
			
		||||
 | 
			
		||||
				&:hover {
 | 
			
		||||
					color: var(--fgHighlighted);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .title {
 | 
			
		||||
				flex: 1;
 | 
			
		||||
				position: relative;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										263
									
								
								packages/client/src/ui/classic.sidebar.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										263
									
								
								packages/client/src/ui/classic.sidebar.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,263 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="npcljfve" :class="{ iconOnly }">
 | 
			
		||||
	<button class="item _button account" @click="openAccountMenu" v-click-anime>
 | 
			
		||||
		<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
 | 
			
		||||
	</button>
 | 
			
		||||
	<div class="post" @click="post" data-cy-open-post-form>
 | 
			
		||||
		<MkButton class="button" gradate full rounded>
 | 
			
		||||
			<i class="fas fa-pencil-alt fa-fw"></i><span class="text" v-if="!iconOnly">{{ $ts.note }}</span>
 | 
			
		||||
		</MkButton>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="divider"></div>
 | 
			
		||||
	<MkA class="item index" active-class="active" to="/" exact v-click-anime>
 | 
			
		||||
		<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 v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'MkA' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime>
 | 
			
		||||
			<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" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime>
 | 
			
		||||
		<i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span>
 | 
			
		||||
	</MkA>
 | 
			
		||||
	<button class="item _button" @click="more" v-click-anime>
 | 
			
		||||
		<i class="fas fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span>
 | 
			
		||||
		<span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
 | 
			
		||||
	</button>
 | 
			
		||||
	<MkA class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime>
 | 
			
		||||
		<i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span>
 | 
			
		||||
	</MkA>
 | 
			
		||||
	<div class="divider"></div>
 | 
			
		||||
	<div class="about">
 | 
			
		||||
		<MkA class="link" to="/about" v-click-anime>
 | 
			
		||||
			<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" class="_ghost"/>
 | 
			
		||||
		</MkA>
 | 
			
		||||
	</div>
 | 
			
		||||
	<!--<MisskeyLogo class="misskey"/>-->
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { host } from '@/config';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import { openAccountMenu } from '@/account';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import { StickySidebar } from '@/scripts/sticky-sidebar';
 | 
			
		||||
//import MisskeyLogo from '@assets/client/misskey.svg';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkButton,
 | 
			
		||||
		//MisskeyLogo,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			host: host,
 | 
			
		||||
			accounts: [],
 | 
			
		||||
			connection: null,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			iconOnly: false,
 | 
			
		||||
			settingsWindowed: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		menu(): string[] {
 | 
			
		||||
			return this.$store.state.menu;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		otherNavItemIndicated(): boolean {
 | 
			
		||||
			for (const def in this.menuDef) {
 | 
			
		||||
				if (this.menu.includes(def)) continue;
 | 
			
		||||
				if (this.menuDef[def].indicated) return true;
 | 
			
		||||
			}
 | 
			
		||||
			return false;
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		'$store.reactiveState.menuDisplay.value'() {
 | 
			
		||||
			this.calcViewState();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		iconOnly() {
 | 
			
		||||
			this.$nextTick(() => {
 | 
			
		||||
				this.$emit('change-view-mode');
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		window.addEventListener('resize', this.calcViewState);
 | 
			
		||||
		this.calcViewState();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		const sticky = new StickySidebar(this.$el.parentElement, 16);
 | 
			
		||||
		window.addEventListener('scroll', () => {
 | 
			
		||||
			sticky.calc(window.scrollY);
 | 
			
		||||
		}, { passive: true });
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		calcViewState() {
 | 
			
		||||
			this.iconOnly = (window.innerWidth <= 1400) || (this.$store.state.menuDisplay === 'sideIcon');
 | 
			
		||||
			this.settingsWindowed = (window.innerWidth > 1400);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		post() {
 | 
			
		||||
			os.post();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		search() {
 | 
			
		||||
			search();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		more(ev) {
 | 
			
		||||
			os.popup(import('@/components/launch-pad.vue'), {}, {
 | 
			
		||||
			}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		openAccountMenu,
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.npcljfve {
 | 
			
		||||
	$ui-font-size: 1em; // TODO: どこかに集約したい
 | 
			
		||||
	$nav-icon-only-width: 78px; // TODO: どこかに集約したい
 | 
			
		||||
	$avatar-size: 32px;
 | 
			
		||||
	$avatar-margin: 8px;
 | 
			
		||||
 | 
			
		||||
	padding: 0 16px;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
	width: 260px;
 | 
			
		||||
 | 
			
		||||
	&.iconOnly {
 | 
			
		||||
		flex: 0 0 $nav-icon-only-width;
 | 
			
		||||
		width: $nav-icon-only-width !important;
 | 
			
		||||
 | 
			
		||||
		> .divider {
 | 
			
		||||
			margin: 8px auto;
 | 
			
		||||
			width: calc(100% - 32px);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .post {
 | 
			
		||||
			> .button {
 | 
			
		||||
				width: 46px;
 | 
			
		||||
				height: 46px;
 | 
			
		||||
				padding: 0;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .item {
 | 
			
		||||
			padding-left: 0;
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			text-align: center;
 | 
			
		||||
			font-size: $ui-font-size * 1.1;
 | 
			
		||||
			line-height: 3.7rem;
 | 
			
		||||
 | 
			
		||||
			> i,
 | 
			
		||||
			> .avatar {
 | 
			
		||||
				margin-right: 0;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> i {
 | 
			
		||||
				left: 10px;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .text {
 | 
			
		||||
				display: none;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .divider {
 | 
			
		||||
		margin: 10px 0;
 | 
			
		||||
		border-top: solid 0.5px var(--divider);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .post {
 | 
			
		||||
		position: sticky;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		z-index: 1;
 | 
			
		||||
		padding: 16px 0;
 | 
			
		||||
		background: var(--bg);
 | 
			
		||||
 | 
			
		||||
		> .button {
 | 
			
		||||
			min-width: 0;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .about {
 | 
			
		||||
		fill: currentColor;
 | 
			
		||||
		padding: 8px 0 16px 0;
 | 
			
		||||
		text-align: center;
 | 
			
		||||
 | 
			
		||||
		> .link {
 | 
			
		||||
			display: block;
 | 
			
		||||
			width: 32px;
 | 
			
		||||
			margin: 0 auto;
 | 
			
		||||
 | 
			
		||||
			img {
 | 
			
		||||
				display: block;
 | 
			
		||||
				width: 100%;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .item {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		display: block;
 | 
			
		||||
		font-size: $ui-font-size;
 | 
			
		||||
		line-height: 2.6rem;
 | 
			
		||||
		text-overflow: ellipsis;
 | 
			
		||||
		overflow: hidden;
 | 
			
		||||
		white-space: nowrap;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		text-align: left;
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
		> i {
 | 
			
		||||
			width: 32px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> i,
 | 
			
		||||
		> .avatar {
 | 
			
		||||
			margin-right: $avatar-margin;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .avatar {
 | 
			
		||||
			width: $avatar-size;
 | 
			
		||||
			height: $avatar-size;
 | 
			
		||||
			vertical-align: middle;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .indicator {
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			top: 0;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			color: var(--navIndicator);
 | 
			
		||||
			font-size: 8px;
 | 
			
		||||
			animation: blink 1s infinite;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&:hover {
 | 
			
		||||
			text-decoration: none;
 | 
			
		||||
			color: var(--navHoverFg);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&.active {
 | 
			
		||||
			color: var(--navActive);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										471
									
								
								packages/client/src/ui/classic.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										471
									
								
								packages/client/src/ui/classic.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,471 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-app" :class="{ wallpaper, isMobile }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`">
 | 
			
		||||
	<XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/>
 | 
			
		||||
 | 
			
		||||
	<div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }">
 | 
			
		||||
		<template v-if="!isMobile">
 | 
			
		||||
			<div class="sidebar" v-if="!showMenuOnTop">
 | 
			
		||||
				<XSidebar/>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="widgets left" ref="widgetsLeft" v-else>
 | 
			
		||||
				<XWidgets @mounted="attachSticky('widgetsLeft')" :place="'left'"/>
 | 
			
		||||
			</div>
 | 
			
		||||
		</template>
 | 
			
		||||
 | 
			
		||||
		<main class="main" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }">
 | 
			
		||||
			<div class="content">
 | 
			
		||||
				<MkStickyContainer>
 | 
			
		||||
					<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
 | 
			
		||||
					<router-view v-slot="{ Component }">
 | 
			
		||||
						<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
 | 
			
		||||
							<keep-alive :include="['timeline']">
 | 
			
		||||
								<component :is="Component" :ref="changePage"/>
 | 
			
		||||
							</keep-alive>
 | 
			
		||||
						</transition>
 | 
			
		||||
					</router-view>
 | 
			
		||||
				</MkStickyContainer>
 | 
			
		||||
			</div>
 | 
			
		||||
		</main>
 | 
			
		||||
 | 
			
		||||
		<div v-if="isDesktop" class="widgets right" ref="widgetsRight">
 | 
			
		||||
			<XWidgets @mounted="attachSticky('widgetsRight')" :place="null"/>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<div class="buttons" v-if="isMobile">
 | 
			
		||||
		<button class="button nav _button" @click="showDrawerNav" ref="navButton"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
 | 
			
		||||
		<button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button>
 | 
			
		||||
		<button class="button notifications _button" @click="$router.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 widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button>
 | 
			
		||||
		<button class="button post _button" @click="post"><i class="fas fa-pencil-alt"></i></button>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<XDrawerSidebar ref="drawerNav" class="sidebar" v-if="isMobile"/>
 | 
			
		||||
 | 
			
		||||
	<transition name="tray-back">
 | 
			
		||||
		<div class="tray-back _modalBg"
 | 
			
		||||
			v-if="widgetsShowing"
 | 
			
		||||
			@click="widgetsShowing = false"
 | 
			
		||||
			@touchstart.passive="widgetsShowing = false"
 | 
			
		||||
		></div>
 | 
			
		||||
	</transition>
 | 
			
		||||
 | 
			
		||||
	<transition name="tray">
 | 
			
		||||
		<XWidgets v-if="widgetsShowing" class="tray"/>
 | 
			
		||||
	</transition>
 | 
			
		||||
 | 
			
		||||
	<iframe v-if="$store.state.aiChanMode" class="ivnzpscs" ref="live2d" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe>
 | 
			
		||||
 | 
			
		||||
	<XCommon/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent, markRaw } from 'vue';
 | 
			
		||||
import { instanceName } from '@/config';
 | 
			
		||||
import { StickySidebar } from '@/scripts/sticky-sidebar';
 | 
			
		||||
import XSidebar from './classic.sidebar.vue';
 | 
			
		||||
import XDrawerSidebar from '@/ui/_common_/sidebar.vue';
 | 
			
		||||
import XCommon from './_common_/common.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
const DESKTOP_THRESHOLD = 1100;
 | 
			
		||||
const MOBILE_THRESHOLD = 600;
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCommon,
 | 
			
		||||
		XSidebar,
 | 
			
		||||
		XDrawerSidebar,
 | 
			
		||||
		XHeaderMenu: defineAsyncComponent(() => import('./classic.header.vue')),
 | 
			
		||||
		XWidgets: defineAsyncComponent(() => import('./classic.widgets.vue')),
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			shouldHeaderThin: this.showMenuOnTop,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			globalHeaderHeight: 0,
 | 
			
		||||
			isMobile: window.innerWidth <= MOBILE_THRESHOLD,
 | 
			
		||||
			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 | 
			
		||||
			widgetsShowing: false,
 | 
			
		||||
			fullView: false,
 | 
			
		||||
			wallpaper: localStorage.getItem('wallpaper') != null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		navIndicated(): boolean {
 | 
			
		||||
			for (const def in this.menuDef) {
 | 
			
		||||
				if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから
 | 
			
		||||
				if (this.menuDef[def].indicated) return true;
 | 
			
		||||
			}
 | 
			
		||||
			return false;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showMenuOnTop(): boolean {
 | 
			
		||||
			return !this.isMobile && this.$store.state.menuDisplay === 'top';
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		document.documentElement.style.overflowY = 'scroll';
 | 
			
		||||
 | 
			
		||||
		if (this.$store.state.widgets.length === 0) {
 | 
			
		||||
			this.$store.set('widgets', [{
 | 
			
		||||
				name: 'calendar',
 | 
			
		||||
				id: 'a', place: null, data: {}
 | 
			
		||||
			}, {
 | 
			
		||||
				name: 'notifications',
 | 
			
		||||
				id: 'b', place: null, data: {}
 | 
			
		||||
			}, {
 | 
			
		||||
				name: 'trends',
 | 
			
		||||
				id: 'c', place: null, data: {}
 | 
			
		||||
			}]);
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		window.addEventListener('resize', () => {
 | 
			
		||||
			this.isMobile = (window.innerWidth <= MOBILE_THRESHOLD);
 | 
			
		||||
			this.isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD);
 | 
			
		||||
		}, { passive: true });
 | 
			
		||||
 | 
			
		||||
		if (this.$store.state.aiChanMode) {
 | 
			
		||||
			const iframeRect = this.$refs.live2d.getBoundingClientRect();
 | 
			
		||||
			window.addEventListener('mousemove', ev => {
 | 
			
		||||
				this.$refs.live2d.contentWindow.postMessage({
 | 
			
		||||
					type: 'moveCursor',
 | 
			
		||||
					body: {
 | 
			
		||||
						x: ev.clientX - iframeRect.left,
 | 
			
		||||
						y: ev.clientY - iframeRect.top,
 | 
			
		||||
					}
 | 
			
		||||
				}, '*');
 | 
			
		||||
			}, { passive: true });
 | 
			
		||||
			window.addEventListener('touchmove', ev => {
 | 
			
		||||
				this.$refs.live2d.contentWindow.postMessage({
 | 
			
		||||
					type: 'moveCursor',
 | 
			
		||||
					body: {
 | 
			
		||||
						x: ev.touches[0].clientX - iframeRect.left,
 | 
			
		||||
						y: ev.touches[0].clientY - iframeRect.top,
 | 
			
		||||
					}
 | 
			
		||||
				}, '*');
 | 
			
		||||
			}, { passive: true });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
				document.title = `${this.pageInfo.title} | ${instanceName}`;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		attachSticky(ref) {
 | 
			
		||||
			const sticky = new StickySidebar(this.$refs[ref], this.$store.state.menuDisplay === 'top' ? 0 : 16, this.$store.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
 | 
			
		||||
			window.addEventListener('scroll', () => {
 | 
			
		||||
				sticky.calc(window.scrollY);
 | 
			
		||||
			}, { passive: true });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		post() {
 | 
			
		||||
			os.post();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		top() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		back() {
 | 
			
		||||
			history.back();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showDrawerNav() {
 | 
			
		||||
			this.$refs.drawerNav.show();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onTransition() {
 | 
			
		||||
			if (window._scroll) window._scroll();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			const isLink = (el: HTMLElement) => {
 | 
			
		||||
				if (el.tagName === 'A') return true;
 | 
			
		||||
				if (el.parentElement) {
 | 
			
		||||
					return isLink(el.parentElement);
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
			if (isLink(e.target)) return;
 | 
			
		||||
			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
 | 
			
		||||
			if (window.getSelection().toString() !== '') return;
 | 
			
		||||
			const path = this.$route.path;
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				type: 'label',
 | 
			
		||||
				text: path,
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: this.fullView ? 'fas fa-compress' : 'fas fa-expand',
 | 
			
		||||
				text: this.fullView ? this.$ts.quitFullView : this.$ts.fullView,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.fullView = !this.fullView;
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(path);
 | 
			
		||||
				}
 | 
			
		||||
			}], e);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onAiClick(ev) {
 | 
			
		||||
			//if (this.live2d) this.live2d.click(ev);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.tray-enter-active,
 | 
			
		||||
.tray-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);
 | 
			
		||||
}
 | 
			
		||||
.tray-enter-from,
 | 
			
		||||
.tray-leave-active {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
	transform: translateX(240px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tray-back-enter-active,
 | 
			
		||||
.tray-back-leave-active {
 | 
			
		||||
	opacity: 1;
 | 
			
		||||
	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
}
 | 
			
		||||
.tray-back-enter-from,
 | 
			
		||||
.tray-back-leave-active {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mk-app {
 | 
			
		||||
	$ui-font-size: 1em;
 | 
			
		||||
	$widgets-hide-threshold: 1200px;
 | 
			
		||||
	$nav-icon-only-width: 78px; // TODO: どこかに集約したい
 | 
			
		||||
 | 
			
		||||
	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 | 
			
		||||
	min-height: calc(var(--vh, 1vh) * 100);
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
	&.wallpaper {
 | 
			
		||||
		background: var(--wallpaperOverlay);
 | 
			
		||||
		//backdrop-filter: var(--blur, blur(4px));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	&.isMobile {
 | 
			
		||||
		> .columns {
 | 
			
		||||
			display: block;
 | 
			
		||||
			margin: 0;
 | 
			
		||||
 | 
			
		||||
			> .main {
 | 
			
		||||
				margin: 0;
 | 
			
		||||
				padding-bottom: 92px;
 | 
			
		||||
				border: none;
 | 
			
		||||
				width: 100%;
 | 
			
		||||
				border-radius: 0;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .columns {
 | 
			
		||||
		display: flex;
 | 
			
		||||
		justify-content: center;
 | 
			
		||||
		max-width: 100%;
 | 
			
		||||
		//margin: 32px 0;
 | 
			
		||||
 | 
			
		||||
		&.fullView {
 | 
			
		||||
			margin: 0;
 | 
			
		||||
		
 | 
			
		||||
			> .sidebar {
 | 
			
		||||
				display: none;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .widgets {
 | 
			
		||||
				display: none;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .main {
 | 
			
		||||
				margin: 0;
 | 
			
		||||
				border-radius: 0;
 | 
			
		||||
				box-shadow: none;
 | 
			
		||||
				width: 100%;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .main {
 | 
			
		||||
			min-width: 0;
 | 
			
		||||
			width: 750px;
 | 
			
		||||
			margin: 0 16px 0 0;
 | 
			
		||||
			background: var(--panel);
 | 
			
		||||
			border-left: solid 1px var(--divider);
 | 
			
		||||
			border-right: solid 1px var(--divider);
 | 
			
		||||
			border-radius: 0;
 | 
			
		||||
			overflow: clip;
 | 
			
		||||
			--margin: 12px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .widgets {
 | 
			
		||||
			//--panelBorder: none;
 | 
			
		||||
			width: 300px;
 | 
			
		||||
			margin-top: 16px;
 | 
			
		||||
 | 
			
		||||
			@media (max-width: $widgets-hide-threshold) {
 | 
			
		||||
				display: none;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			&.left {
 | 
			
		||||
				margin-right: 16px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .sidebar {
 | 
			
		||||
			margin-top: 16px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&.withGlobalHeader {
 | 
			
		||||
			> .main {
 | 
			
		||||
				margin-top: 0;
 | 
			
		||||
				border: solid 1px var(--divider);
 | 
			
		||||
				border-radius: var(--radius);
 | 
			
		||||
				--stickyTop: var(--globalHeaderHeight);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .widgets {
 | 
			
		||||
				--stickyTop: var(--globalHeaderHeight);
 | 
			
		||||
				margin-top: 0;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		@media (max-width: 850px) {
 | 
			
		||||
			margin: 0;
 | 
			
		||||
 | 
			
		||||
			> .sidebar {
 | 
			
		||||
				border-right: solid 0.5px var(--divider);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .main {
 | 
			
		||||
				margin: 0;
 | 
			
		||||
				border-radius: 0;
 | 
			
		||||
				box-shadow: none;
 | 
			
		||||
				width: 100%;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .buttons {
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		z-index: 1000;
 | 
			
		||||
		bottom: 0;
 | 
			
		||||
		padding: 16px;
 | 
			
		||||
		display: flex;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
		-webkit-backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
		backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
		background-color: var(--header);
 | 
			
		||||
		border-top: solid 0.5px var(--divider);
 | 
			
		||||
 | 
			
		||||
		> .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: 22px;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			&:disabled {
 | 
			
		||||
				cursor: default;
 | 
			
		||||
 | 
			
		||||
				> * {
 | 
			
		||||
					opacity: 0.5;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .tray-back {
 | 
			
		||||
		z-index: 1001;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .tray {
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		right: 0;
 | 
			
		||||
		z-index: 1001;
 | 
			
		||||
		// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 | 
			
		||||
		height: calc(var(--vh, 1vh) * 100);
 | 
			
		||||
		padding: var(--margin);
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
		background: var(--bg);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .ivnzpscs {
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		bottom: 0;
 | 
			
		||||
		right: 0;
 | 
			
		||||
		width: 300px;
 | 
			
		||||
		height: 600px;
 | 
			
		||||
		border: none;
 | 
			
		||||
		pointer-events: none;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										84
									
								
								packages/client/src/ui/classic.widgets.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								packages/client/src/ui/classic.widgets.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,84 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="ddiqwdnk">
 | 
			
		||||
	<XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value.filter(w => w.place === place)" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
 | 
			
		||||
	<MkAd class="a" :prefer="['square']"/>
 | 
			
		||||
 | 
			
		||||
	<button v-if="editMode" @click="editMode = false" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button>
 | 
			
		||||
	<button v-else @click="editMode = true" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import XWidgets from '@/components/widgets.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XWidgets
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		place: {
 | 
			
		||||
			type: String,
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	emits: ['mounted'],
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			editMode: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.$emit('mounted', this.$el);
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		addWidget(widget) {
 | 
			
		||||
			this.$store.set('widgets', [{
 | 
			
		||||
				...widget,
 | 
			
		||||
				place: this.place,
 | 
			
		||||
			}, ...this.$store.state.widgets]);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		removeWidget(widget) {
 | 
			
		||||
			this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidget({ id, data }) {
 | 
			
		||||
			this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? {
 | 
			
		||||
				...w,
 | 
			
		||||
				data: data
 | 
			
		||||
			} : w));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidgets(widgets) {
 | 
			
		||||
			this.$store.set('widgets', [
 | 
			
		||||
				...this.$store.state.widgets.filter(w => w.place !== this.place),
 | 
			
		||||
				...widgets
 | 
			
		||||
			]);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.ddiqwdnk {
 | 
			
		||||
	position: sticky;
 | 
			
		||||
	height: min-content;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
	padding-bottom: 8px;
 | 
			
		||||
 | 
			
		||||
	> .widgets,
 | 
			
		||||
	> .a {
 | 
			
		||||
		width: 300px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .edit {
 | 
			
		||||
		display: block;
 | 
			
		||||
		margin: 16px auto;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										229
									
								
								packages/client/src/ui/deck.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								packages/client/src/ui/deck.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,229 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-deck" :class="`${deckStore.reactiveState.columnAlign.value}`" @contextmenu.self.prevent="onContextmenu"
 | 
			
		||||
	:style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }"
 | 
			
		||||
>
 | 
			
		||||
	<XSidebar ref="nav"/>
 | 
			
		||||
 | 
			
		||||
	<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
 | 
			
		||||
			class="column"
 | 
			
		||||
			:ref="ids[0]"
 | 
			
		||||
			:key="ids[0]"
 | 
			
		||||
			:column="columns.find(c => c.id === ids[0])"
 | 
			
		||||
			@parent-focus="moveFocus(ids[0], $event)"
 | 
			
		||||
			:style="columns.find(c => c.id === ids[0]).flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0]).width + 'px' }"
 | 
			
		||||
		/>
 | 
			
		||||
	</template>
 | 
			
		||||
 | 
			
		||||
	<button v-if="$i" class="nav _button" @click="showNav()"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
 | 
			
		||||
	<button v-if="$i" class="post _buttonPrimary" @click="post()"><i class="fas fa-pencil-alt"></i></button>
 | 
			
		||||
 | 
			
		||||
	<XCommon/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { v4 as uuid } from 'uuid';
 | 
			
		||||
import { host } from '@/config';
 | 
			
		||||
import DeckColumnCore from '@/ui/deck/column-core.vue';
 | 
			
		||||
import XSidebar from '@/ui/_common_/sidebar.vue';
 | 
			
		||||
import { getScrollContainer } from '@/scripts/scroll';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import XCommon from './_common_/common.vue';
 | 
			
		||||
import { deckStore, addColumn, loadDeck } from './deck/deck-store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCommon,
 | 
			
		||||
		XSidebar,
 | 
			
		||||
		DeckColumnCore,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return deckStore.state.navWindow ? {
 | 
			
		||||
			navHook: (url) => {
 | 
			
		||||
				os.pageWindow(url);
 | 
			
		||||
			}
 | 
			
		||||
		} : {};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			deckStore,
 | 
			
		||||
			host: host,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			wallpaper: localStorage.getItem('wallpaper') != null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		columns() {
 | 
			
		||||
			return deckStore.reactiveState.columns.value;
 | 
			
		||||
		},
 | 
			
		||||
		layout() {
 | 
			
		||||
			return deckStore.reactiveState.layout.value;
 | 
			
		||||
		},
 | 
			
		||||
		navIndicated(): boolean {
 | 
			
		||||
			if (!this.$i) return false;
 | 
			
		||||
			for (const def in this.menuDef) {
 | 
			
		||||
				if (this.menuDef[def].indicated) return true;
 | 
			
		||||
			}
 | 
			
		||||
			return false;
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		document.documentElement.style.overflowY = 'hidden';
 | 
			
		||||
		document.documentElement.style.scrollBehavior = 'auto';
 | 
			
		||||
		window.addEventListener('wheel', this.onWheel);
 | 
			
		||||
		loadDeck();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		onWheel(e) {
 | 
			
		||||
			if (getScrollContainer(e.target) == null) {
 | 
			
		||||
				document.documentElement.scrollLeft += e.deltaY > 0 ? 96 : -96;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showNav() {
 | 
			
		||||
			this.$refs.nav.show();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		post() {
 | 
			
		||||
			os.post();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async addColumn(ev) {
 | 
			
		||||
			const columns = [
 | 
			
		||||
				'main',
 | 
			
		||||
				'widgets',
 | 
			
		||||
				'notifications',
 | 
			
		||||
				'tl',
 | 
			
		||||
				'antenna',
 | 
			
		||||
				'list',
 | 
			
		||||
				'mentions',
 | 
			
		||||
				'direct',
 | 
			
		||||
			];
 | 
			
		||||
 | 
			
		||||
			const { canceled, result: column } = await os.dialog({
 | 
			
		||||
				title: this.$ts._deck.addColumn,
 | 
			
		||||
				type: null,
 | 
			
		||||
				select: {
 | 
			
		||||
					items: columns.map(column => ({
 | 
			
		||||
						value: column, text: this.$t('_deck._columns.' + column)
 | 
			
		||||
					}))
 | 
			
		||||
				},
 | 
			
		||||
				showCancelButton: true
 | 
			
		||||
			});
 | 
			
		||||
			if (canceled) return;
 | 
			
		||||
 | 
			
		||||
			addColumn({
 | 
			
		||||
				type: column,
 | 
			
		||||
				id: uuid(),
 | 
			
		||||
				name: this.$t('_deck._columns.' + column),
 | 
			
		||||
				width: 330,
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				text: this.$ts._deck.addColumn,
 | 
			
		||||
				icon: null,
 | 
			
		||||
				action: this.addColumn
 | 
			
		||||
			}], e);
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.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;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .column {
 | 
			
		||||
		flex-shrink: 0;
 | 
			
		||||
		margin-right: var(--deckMargin);
 | 
			
		||||
 | 
			
		||||
		&.folder {
 | 
			
		||||
			display: flex;
 | 
			
		||||
			flex-direction: column;
 | 
			
		||||
 | 
			
		||||
			> *:not(:last-child) {
 | 
			
		||||
				margin-bottom: var(--deckMargin);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .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;
 | 
			
		||||
 | 
			
		||||
		@media (min-width: ($nav-hide-threshold + 1px)) {
 | 
			
		||||
			display: none;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .post {
 | 
			
		||||
		right: 32px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .nav {
 | 
			
		||||
		left: 32px;
 | 
			
		||||
		background: var(--panel);
 | 
			
		||||
		color: var(--fg);
 | 
			
		||||
 | 
			
		||||
		&:hover {
 | 
			
		||||
			background: var(--X2);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .indicator {
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			top: 0;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			color: var(--indicator);
 | 
			
		||||
			font-size: 16px;
 | 
			
		||||
			animation: blink 1s infinite;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										80
									
								
								packages/client/src/ui/deck/antenna-column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								packages/client/src/ui/deck/antenna-column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,80 @@
 | 
			
		|||
<template>
 | 
			
		||||
<XColumn :func="{ handler: setAntenna, title: $ts.selectAntenna }" :column="column" :is-stacked="isStacked">
 | 
			
		||||
	<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">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import XTimeline from '@/components/timeline.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { updateColumn } from './deck-store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XTimeline,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		mediaOnly() {
 | 
			
		||||
			(this.$refs.timeline as any).reload();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (this.column.antennaId == null) {
 | 
			
		||||
			this.setAntenna();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		async setAntenna() {
 | 
			
		||||
			const antennas = await os.api('antennas/list');
 | 
			
		||||
			const { canceled, result: antenna } = await os.dialog({
 | 
			
		||||
				title: this.$ts.selectAntenna,
 | 
			
		||||
				type: null,
 | 
			
		||||
				select: {
 | 
			
		||||
					items: antennas.map(x => ({
 | 
			
		||||
						value: x, text: x.name
 | 
			
		||||
					})),
 | 
			
		||||
				default: this.column.antennaId
 | 
			
		||||
				},
 | 
			
		||||
				showCancelButton: true
 | 
			
		||||
			});
 | 
			
		||||
			if (canceled) return;
 | 
			
		||||
			updateColumn(this.column.id, {
 | 
			
		||||
				antennaId: antenna.id
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focus() {
 | 
			
		||||
			(this.$refs.timeline as any).focus();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										52
									
								
								packages/client/src/ui/deck/column-core.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								packages/client/src/ui/deck/column-core.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
<template>
 | 
			
		||||
<!-- TODO: リファクタの余地がありそう -->
 | 
			
		||||
<XMainColumn v-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">
 | 
			
		||||
import { defineComponent } 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';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XMainColumn,
 | 
			
		||||
		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>
 | 
			
		||||
							
								
								
									
										408
									
								
								packages/client/src/ui/deck/column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										408
									
								
								packages/client/src/ui/deck/column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,408 @@
 | 
			
		|||
<template>
 | 
			
		||||
<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
 | 
			
		||||
<section class="dnpfarvg _panel _narrow_" :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }"
 | 
			
		||||
	@dragover.prevent.stop="onDragover"
 | 
			
		||||
	@dragleave="onDragleave"
 | 
			
		||||
	@drop.prevent.stop="onDrop"
 | 
			
		||||
	v-hotkey="keymap"
 | 
			
		||||
	:style="{ '--deckColumnHeaderHeight': deckStore.reactiveState.columnHeaderHeight.value + '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 && !isMainColumn">
 | 
			
		||||
			<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-if="func" class="menu _button" v-tooltip="func.title" @click.stop="func.handler"><i :class="func.icon || 'fas fa-cog'"></i></button>
 | 
			
		||||
	</header>
 | 
			
		||||
	<div ref="body" v-show="active">
 | 
			
		||||
		<slot></slot>
 | 
			
		||||
	</div>
 | 
			
		||||
</section>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from './deck-store';
 | 
			
		||||
import { deckStore } from './deck-store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	provide: {
 | 
			
		||||
		shouldHeaderThin: true,
 | 
			
		||||
		shouldOmitHeaderTitle: true,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: null
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		func: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: null
 | 
			
		||||
		},
 | 
			
		||||
		naked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		indicated: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			deckStore,
 | 
			
		||||
			dragging: false,
 | 
			
		||||
			draghover: false,
 | 
			
		||||
			dropready: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		isMainColumn(): boolean {
 | 
			
		||||
			return this.column.type === 'main';
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		active(): boolean {
 | 
			
		||||
			return this.column.active !== false;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		keymap(): any {
 | 
			
		||||
			return {
 | 
			
		||||
				'shift+up': () => this.$parent.$emit('parent-focus', 'up'),
 | 
			
		||||
				'shift+down': () => this.$parent.$emit('parent-focus', 'down'),
 | 
			
		||||
				'shift+left': () => this.$parent.$emit('parent-focus', 'left'),
 | 
			
		||||
				'shift+right': () => this.$parent.$emit('parent-focus', 'right'),
 | 
			
		||||
			};
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		active(v) {
 | 
			
		||||
			this.$emit('change-active-state', v);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		dragging(v) {
 | 
			
		||||
			os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd');
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		os.deckGlobalEvents.on('column.dragStart', this.onOtherDragStart);
 | 
			
		||||
		os.deckGlobalEvents.on('column.dragEnd', this.onOtherDragEnd);
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		os.deckGlobalEvents.off('column.dragStart', this.onOtherDragStart);
 | 
			
		||||
		os.deckGlobalEvents.off('column.dragEnd', this.onOtherDragEnd);
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		onOtherDragStart() {
 | 
			
		||||
			this.dropready = true;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onOtherDragEnd() {
 | 
			
		||||
			this.dropready = false;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		toggleActive() {
 | 
			
		||||
			if (!this.isStacked) return;
 | 
			
		||||
			updateColumn(this.column.id, {
 | 
			
		||||
				active: !this.column.active
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		getMenu() {
 | 
			
		||||
			const items = [{
 | 
			
		||||
				icon: 'fas fa-pencil-alt',
 | 
			
		||||
				text: this.$ts.edit,
 | 
			
		||||
				action: async () => {
 | 
			
		||||
					const { canceled, result } = await os.form(this.column.name, {
 | 
			
		||||
						name: {
 | 
			
		||||
							type: 'string',
 | 
			
		||||
							label: this.$ts.name,
 | 
			
		||||
							default: this.column.name
 | 
			
		||||
						},
 | 
			
		||||
						width: {
 | 
			
		||||
							type: 'number',
 | 
			
		||||
							label: this.$ts.width,
 | 
			
		||||
							default: this.column.width
 | 
			
		||||
						},
 | 
			
		||||
						flexible: {
 | 
			
		||||
							type: 'boolean',
 | 
			
		||||
							label: this.$ts.flexible,
 | 
			
		||||
							default: this.column.flexible
 | 
			
		||||
						}
 | 
			
		||||
					});
 | 
			
		||||
					if (canceled) return;
 | 
			
		||||
					updateColumn(this.column.id, result);
 | 
			
		||||
				}
 | 
			
		||||
			}, null, {
 | 
			
		||||
				icon: 'fas fa-arrow-left',
 | 
			
		||||
				text: this.$ts._deck.swapLeft,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					swapLeftColumn(this.column.id);
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-arrow-right',
 | 
			
		||||
				text: this.$ts._deck.swapRight,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					swapRightColumn(this.column.id);
 | 
			
		||||
				}
 | 
			
		||||
			}, this.isStacked ? {
 | 
			
		||||
				icon: 'fas fa-arrow-up',
 | 
			
		||||
				text: this.$ts._deck.swapUp,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					swapUpColumn(this.column.id);
 | 
			
		||||
				}
 | 
			
		||||
			} : undefined, this.isStacked ? {
 | 
			
		||||
				icon: 'fas fa-arrow-down',
 | 
			
		||||
				text: this.$ts._deck.swapDown,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					swapDownColumn(this.column.id);
 | 
			
		||||
				}
 | 
			
		||||
			} : undefined, null, {
 | 
			
		||||
				icon: 'fas fa-window-restore',
 | 
			
		||||
				text: this.$ts._deck.stackLeft,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					stackLeftColumn(this.column.id);
 | 
			
		||||
				}
 | 
			
		||||
			}, this.isStacked ? {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts._deck.popRight,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					popRightColumn(this.column.id);
 | 
			
		||||
				}
 | 
			
		||||
			} : undefined, null, {
 | 
			
		||||
				icon: 'fas fa-trash-alt',
 | 
			
		||||
				text: this.$ts.remove,
 | 
			
		||||
				danger: true,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					removeColumn(this.column.id);
 | 
			
		||||
				}
 | 
			
		||||
			}];
 | 
			
		||||
 | 
			
		||||
			return items;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			os.contextMenu(this.getMenu(), e);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		goTop() {
 | 
			
		||||
			this.$refs.body.scrollTo({
 | 
			
		||||
				top: 0,
 | 
			
		||||
				behavior: 'smooth'
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragstart(e) {
 | 
			
		||||
			e.dataTransfer.effectAllowed = 'move';
 | 
			
		||||
			e.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, this.column.id);
 | 
			
		||||
 | 
			
		||||
			// Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう
 | 
			
		||||
			// SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately
 | 
			
		||||
			setTimeout(() => {
 | 
			
		||||
				this.dragging = true;
 | 
			
		||||
			}, 10);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragend(e) {
 | 
			
		||||
			this.dragging = false;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragover(e) {
 | 
			
		||||
			// 自分自身がドラッグされている場合
 | 
			
		||||
			if (this.dragging) {
 | 
			
		||||
				// 自分自身にはドロップさせない
 | 
			
		||||
				e.dataTransfer.dropEffect = 'none';
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const isDeckColumn = e.dataTransfer.types[0] == _DATA_TRANSFER_DECK_COLUMN_;
 | 
			
		||||
 | 
			
		||||
			e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
 | 
			
		||||
 | 
			
		||||
			if (!this.dragging && isDeckColumn) this.draghover = true;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragleave() {
 | 
			
		||||
			this.draghover = false;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDrop(e) {
 | 
			
		||||
			this.draghover = false;
 | 
			
		||||
			os.deckGlobalEvents.emit('column.dragEnd');
 | 
			
		||||
 | 
			
		||||
			const id = e.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_);
 | 
			
		||||
			if (id != null && id != '') {
 | 
			
		||||
				swapColumn(this.column.id, id);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.dnpfarvg {
 | 
			
		||||
	--root-margin: 10px;
 | 
			
		||||
 | 
			
		||||
	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);
 | 
			
		||||
			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 {
 | 
			
		||||
			margin-left: auto;
 | 
			
		||||
			margin-right: -16px;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> div {
 | 
			
		||||
		height: calc(100% - var(--deckColumnHeaderHeight));
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
		overflow-x: hidden;
 | 
			
		||||
		-webkit-overflow-scrolling: touch;
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										298
									
								
								packages/client/src/ui/deck/deck-store.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										298
									
								
								packages/client/src/ui/deck/deck-store.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,298 @@
 | 
			
		|||
import { throttle } from 'throttle-debounce';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
import { api } from '@/os';
 | 
			
		||||
import { markRaw, watch } from 'vue';
 | 
			
		||||
import { Storage } from '../../pizzax';
 | 
			
		||||
 | 
			
		||||
type ColumnWidget = {
 | 
			
		||||
	name: string;
 | 
			
		||||
	id: string;
 | 
			
		||||
	data: Record<string, any>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type Column = {
 | 
			
		||||
	id: string;
 | 
			
		||||
	type: string;
 | 
			
		||||
	name: string | null;
 | 
			
		||||
	width: number;
 | 
			
		||||
	widgets?: ColumnWidget[];
 | 
			
		||||
	active?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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: 16
 | 
			
		||||
	},
 | 
			
		||||
	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 (e) {
 | 
			
		||||
		if (e.code === 'NO_SUCH_KEY') {
 | 
			
		||||
			// 後方互換性のため
 | 
			
		||||
			if (deckStore.state.profile === 'default') {
 | 
			
		||||
				saveDeck();
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			deckStore.set('columns', [{
 | 
			
		||||
				id: 'a',
 | 
			
		||||
				type: 'main',
 | 
			
		||||
				name: i18n.locale._deck._columns.main,
 | 
			
		||||
				width: 350,
 | 
			
		||||
			}, {
 | 
			
		||||
				id: 'b',
 | 
			
		||||
				type: 'notifications',
 | 
			
		||||
				name: i18n.locale._deck._columns.notifications,
 | 
			
		||||
				width: 330,
 | 
			
		||||
			}]);
 | 
			
		||||
			deckStore.set('layout', [['a'], ['b']]);
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		throw e;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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 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, data: 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: data
 | 
			
		||||
	} : 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();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								packages/client/src/ui/deck/direct-column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								packages/client/src/ui/deck/direct-column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,55 @@
 | 
			
		|||
<template>
 | 
			
		||||
<XColumn :column="column" :is-stacked="isStacked">
 | 
			
		||||
	<template #header><i class="fas fa-envelope" style="margin-right: 8px;"></i>{{ column.name }}</template>
 | 
			
		||||
 | 
			
		||||
	<XNotes :pagination="pagination" @before="before()" @after="after()"/>
 | 
			
		||||
</XColumn>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import Progress from '@/scripts/loading';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import XNotes from '@/components/notes.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XNotes
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'notes/mentions',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
					visibility: 'specified'
 | 
			
		||||
				})
 | 
			
		||||
			},
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		before() {
 | 
			
		||||
			Progress.start();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		after() {
 | 
			
		||||
			Progress.done();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										80
									
								
								packages/client/src/ui/deck/list-column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								packages/client/src/ui/deck/list-column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,80 @@
 | 
			
		|||
<template>
 | 
			
		||||
<XColumn :func="{ handler: setList, title: $ts.selectList }" :column="column" :is-stacked="isStacked">
 | 
			
		||||
	<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">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import XTimeline from '@/components/timeline.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { updateColumn } from './deck-store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XTimeline,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		mediaOnly() {
 | 
			
		||||
			(this.$refs.timeline as any).reload();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (this.column.listId == null) {
 | 
			
		||||
			this.setList();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		async setList() {
 | 
			
		||||
			const lists = await os.api('users/lists/list');
 | 
			
		||||
			const { canceled, result: list } = await os.dialog({
 | 
			
		||||
				title: this.$ts.selectList,
 | 
			
		||||
				type: null,
 | 
			
		||||
				select: {
 | 
			
		||||
					items: lists.map(x => ({
 | 
			
		||||
						value: x, text: x.name
 | 
			
		||||
					})),
 | 
			
		||||
					default: this.column.listId
 | 
			
		||||
				},
 | 
			
		||||
				showCancelButton: true
 | 
			
		||||
			});
 | 
			
		||||
			if (canceled) return;
 | 
			
		||||
			updateColumn(this.column.id, {
 | 
			
		||||
				listId: list.id
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focus() {
 | 
			
		||||
			(this.$refs.timeline as any).focus();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										91
									
								
								packages/client/src/ui/deck/main-column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								packages/client/src/ui/deck/main-column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,91 @@
 | 
			
		|||
<template>
 | 
			
		||||
<XColumn v-if="deckStore.state.alwaysShowMainColumn || $route.name !== 'index'" :column="column" :is-stacked="isStacked">
 | 
			
		||||
	<template #header>
 | 
			
		||||
		<template v-if="pageInfo">
 | 
			
		||||
			<i :class="pageInfo.icon"></i>
 | 
			
		||||
			{{ pageInfo.title }}
 | 
			
		||||
		</template>
 | 
			
		||||
	</template>
 | 
			
		||||
 | 
			
		||||
	<MkStickyContainer>
 | 
			
		||||
		<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
 | 
			
		||||
		<router-view v-slot="{ Component }">
 | 
			
		||||
			<transition>
 | 
			
		||||
				<keep-alive :include="['timeline']">
 | 
			
		||||
					<component :is="Component" :ref="changePage" @contextmenu.stop="onContextmenu"/>
 | 
			
		||||
				</keep-alive>
 | 
			
		||||
			</transition>
 | 
			
		||||
		</router-view>
 | 
			
		||||
	</MkStickyContainer>
 | 
			
		||||
</XColumn>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import XNotes from '@/components/notes.vue';
 | 
			
		||||
import { deckStore } from '@/ui/deck/deck-store';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XNotes
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			deckStore,
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		back() {
 | 
			
		||||
			history.back();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			const isLink = (el: HTMLElement) => {
 | 
			
		||||
				if (el.tagName === 'A') return true;
 | 
			
		||||
				if (el.parentElement) {
 | 
			
		||||
					return isLink(el.parentElement);
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
			if (isLink(e.target)) return;
 | 
			
		||||
			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
 | 
			
		||||
			if (window.getSelection().toString() !== '') return;
 | 
			
		||||
			const path = this.$route.path;
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				type: 'label',
 | 
			
		||||
				text: path,
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(path);
 | 
			
		||||
				}
 | 
			
		||||
			}], e);
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										52
									
								
								packages/client/src/ui/deck/mentions-column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								packages/client/src/ui/deck/mentions-column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
<template>
 | 
			
		||||
<XColumn :column="column" :is-stacked="isStacked">
 | 
			
		||||
	<template #header><i class="fas fa-at" style="margin-right: 8px;"></i>{{ column.name }}</template>
 | 
			
		||||
 | 
			
		||||
	<XNotes :pagination="pagination" @before="before()" @after="after()"/>
 | 
			
		||||
</XColumn>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import Progress from '@/scripts/loading';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import XNotes from '@/components/notes.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XNotes
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'notes/mentions',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
			},
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		before() {
 | 
			
		||||
			Progress.start();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		after() {
 | 
			
		||||
			Progress.done();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										53
									
								
								packages/client/src/ui/deck/notifications-column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								packages/client/src/ui/deck/notifications-column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
<template>
 | 
			
		||||
<XColumn :column="column" :is-stacked="isStacked" :func="{ handler: func, title: $ts.notificationSetting }">
 | 
			
		||||
	<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">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import XNotifications from '@/components/notifications.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { updateColumn } from './deck-store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XNotifications
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		func() {
 | 
			
		||||
			os.popup(import('@/components/notification-setting-window.vue'), {
 | 
			
		||||
				includingTypes: this.column.includingTypes,
 | 
			
		||||
			}, {
 | 
			
		||||
				done: async (res) => {
 | 
			
		||||
					const { includingTypes } = res;
 | 
			
		||||
					updateColumn(this.column.id, {
 | 
			
		||||
						includingTypes: includingTypes
 | 
			
		||||
					});
 | 
			
		||||
				},
 | 
			
		||||
			}, 'closed');
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										137
									
								
								packages/client/src/ui/deck/tl-column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								packages/client/src/ui/deck/tl-column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,137 @@
 | 
			
		|||
<template>
 | 
			
		||||
<XColumn :func="{ handler: setType, title: $ts.timeline }" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState">
 | 
			
		||||
	<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 class="iwaalbte" v-if="disabled">
 | 
			
		||||
		<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" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/>
 | 
			
		||||
</XColumn>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import XTimeline from '@/components/timeline.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { removeColumn, updateColumn } from './deck-store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XTimeline,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			disabled: false,
 | 
			
		||||
			indicated: false,
 | 
			
		||||
			columnActive: true,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		mediaOnly() {
 | 
			
		||||
			(this.$refs.timeline as any).reload();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (this.column.tl == null) {
 | 
			
		||||
			this.setType();
 | 
			
		||||
		} else {
 | 
			
		||||
			this.disabled = !this.$i.isModerator && !this.$i.isAdmin && (
 | 
			
		||||
				this.$instance.disableLocalTimeline && ['local', 'social'].includes(this.column.tl) ||
 | 
			
		||||
				this.$instance.disableGlobalTimeline && ['global'].includes(this.column.tl));
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		async setType() {
 | 
			
		||||
			const { canceled, result: src } = await os.dialog({
 | 
			
		||||
				title: this.$ts.timeline,
 | 
			
		||||
				type: null,
 | 
			
		||||
				select: {
 | 
			
		||||
					items: [{
 | 
			
		||||
						value: 'home', text: this.$ts._timelines.home
 | 
			
		||||
					}, {
 | 
			
		||||
						value: 'local', text: this.$ts._timelines.local
 | 
			
		||||
					}, {
 | 
			
		||||
						value: 'social', text: this.$ts._timelines.social
 | 
			
		||||
					}, {
 | 
			
		||||
						value: 'global', text: this.$ts._timelines.global
 | 
			
		||||
					}]
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
			if (canceled) {
 | 
			
		||||
				if (this.column.tl == null) {
 | 
			
		||||
					removeColumn(this.column.id);
 | 
			
		||||
				}
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
			updateColumn(this.column.id, {
 | 
			
		||||
				tl: src
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		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>
 | 
			
		||||
							
								
								
									
										71
									
								
								packages/client/src/ui/deck/widgets-column.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								packages/client/src/ui/deck/widgets-column.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
<template>
 | 
			
		||||
<XColumn :func="{ handler: func, title: $ts.editWidgets }" :naked="true" :column="column" :is-stacked="isStacked">
 | 
			
		||||
	<template #header><i class="fas fa-window-maximize" style="margin-right: 8px;"></i>{{ column.name }}</template>
 | 
			
		||||
 | 
			
		||||
	<div class="wtdtxvec">
 | 
			
		||||
		<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">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import XWidgets from '@/components/widgets.vue';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XWidgets,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		column: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
		isStacked: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			edit: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		addWidget(widget) {
 | 
			
		||||
			addColumnWidget(this.column.id, widget);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		removeWidget(widget) {
 | 
			
		||||
			removeColumnWidget(this.column.id, widget);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidget({ id, data }) {
 | 
			
		||||
			updateColumnWidget(this.column.id, id, data);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidgets(widgets) {
 | 
			
		||||
			setColumnWidgets(this.column.id, widgets);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		func() {
 | 
			
		||||
			this.edit = !this.edit;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.wtdtxvec {
 | 
			
		||||
	--margin: 8px;
 | 
			
		||||
	--panelBorder: none;
 | 
			
		||||
 | 
			
		||||
	padding: 0 var(--margin);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										70
									
								
								packages/client/src/ui/desktop.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								packages/client/src/ui/desktop.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,70 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-app" :class="{ wallpaper }" @contextmenu.prevent="() => {}">
 | 
			
		||||
	<XSidebar ref="nav" class="sidebar"/>
 | 
			
		||||
 | 
			
		||||
	<XCommon/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { host } from '@/config';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
import XCommon from './_common_/common.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import XSidebar from '@/ui/_common_/sidebar.vue';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import { ColdDeviceStorage } from '@/store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCommon,
 | 
			
		||||
		XSidebar
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			navHook: (url) => {
 | 
			
		||||
				os.pageWindow(url);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			host: host,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			wallpaper: localStorage.getItem('wallpaper') != null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		menu(): string[] {
 | 
			
		||||
			return this.$store.state.menu;
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		if (window.innerWidth < 1024) {
 | 
			
		||||
			localStorage.setItem('ui', 'default');
 | 
			
		||||
			location.reload();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		help() {
 | 
			
		||||
			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.mk-app {
 | 
			
		||||
	height: 100vh;
 | 
			
		||||
	width: 100vw;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										402
									
								
								packages/client/src/ui/universal.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										402
									
								
								packages/client/src/ui/universal.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,402 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-app" :class="{ wallpaper }">
 | 
			
		||||
	<XSidebar ref="nav" class="sidebar"/>
 | 
			
		||||
 | 
			
		||||
	<div class="contents" ref="contents" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }">
 | 
			
		||||
		<main ref="main">
 | 
			
		||||
			<div class="content">
 | 
			
		||||
				<MkStickyContainer>
 | 
			
		||||
					<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
 | 
			
		||||
					<router-view v-slot="{ Component }">
 | 
			
		||||
						<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
 | 
			
		||||
							<keep-alive :include="['timeline']">
 | 
			
		||||
								<component :is="Component" :ref="changePage"/>
 | 
			
		||||
							</keep-alive>
 | 
			
		||||
						</transition>
 | 
			
		||||
					</router-view>
 | 
			
		||||
				</MkStickyContainer>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="spacer"></div>
 | 
			
		||||
		</main>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<XSide v-if="isDesktop" class="side" ref="side"/>
 | 
			
		||||
 | 
			
		||||
	<div v-if="isDesktop" class="widgets" ref="widgets">
 | 
			
		||||
		<XWidgets @mounted="attachSticky"/>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<div class="buttons" :class="{ navHidden }">
 | 
			
		||||
		<button class="button nav _button" @click="showNav" ref="navButton"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
 | 
			
		||||
		<button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button>
 | 
			
		||||
		<button class="button notifications _button" @click="$router.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 widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button>
 | 
			
		||||
		<button class="button post _button" @click="post"><i class="fas fa-pencil-alt"></i></button>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<button class="widgetButton _button" :class="{ navHidden }" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button>
 | 
			
		||||
 | 
			
		||||
	<transition name="tray-back">
 | 
			
		||||
		<div class="tray-back _modalBg"
 | 
			
		||||
			v-if="widgetsShowing"
 | 
			
		||||
			@click="widgetsShowing = false"
 | 
			
		||||
			@touchstart.passive="widgetsShowing = false"
 | 
			
		||||
		></div>
 | 
			
		||||
	</transition>
 | 
			
		||||
 | 
			
		||||
	<transition name="tray">
 | 
			
		||||
		<XWidgets v-if="widgetsShowing" class="tray"/>
 | 
			
		||||
	</transition>
 | 
			
		||||
 | 
			
		||||
	<XCommon/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { instanceName } from '@/config';
 | 
			
		||||
import { StickySidebar } from '@/scripts/sticky-sidebar';
 | 
			
		||||
import XSidebar from '@/ui/_common_/sidebar.vue';
 | 
			
		||||
import XCommon from './_common_/common.vue';
 | 
			
		||||
import XSide from './classic.side.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
const DESKTOP_THRESHOLD = 1100;
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCommon,
 | 
			
		||||
		XSidebar,
 | 
			
		||||
		XWidgets: defineAsyncComponent(() => import('./universal.widgets.vue')),
 | 
			
		||||
		XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			sideViewHook: this.isDesktop ? (url) => {
 | 
			
		||||
				this.$refs.side.navigate(url);
 | 
			
		||||
			} : null
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			navHidden: false,
 | 
			
		||||
			widgetsShowing: false,
 | 
			
		||||
			wallpaper: localStorage.getItem('wallpaper') != null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		navIndicated(): boolean {
 | 
			
		||||
			for (const def in this.menuDef) {
 | 
			
		||||
				if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから
 | 
			
		||||
				if (this.menuDef[def].indicated) return true;
 | 
			
		||||
			}
 | 
			
		||||
			return false;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		document.documentElement.style.overflowY = 'scroll';
 | 
			
		||||
 | 
			
		||||
		if (this.$store.state.widgets.length === 0) {
 | 
			
		||||
			this.$store.set('widgets', [{
 | 
			
		||||
				name: 'calendar',
 | 
			
		||||
				id: 'a', place: 'right', data: {}
 | 
			
		||||
			}, {
 | 
			
		||||
				name: 'notifications',
 | 
			
		||||
				id: 'b', place: 'right', data: {}
 | 
			
		||||
			}, {
 | 
			
		||||
				name: 'trends',
 | 
			
		||||
				id: 'c', place: 'right', data: {}
 | 
			
		||||
			}]);
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.adjustUI();
 | 
			
		||||
 | 
			
		||||
		const ro = new ResizeObserver((entries, observer) => {
 | 
			
		||||
			this.adjustUI();
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		ro.observe(this.$refs.contents);
 | 
			
		||||
 | 
			
		||||
		window.addEventListener('resize', this.adjustUI, { passive: true });
 | 
			
		||||
 | 
			
		||||
		if (!this.isDesktop) {
 | 
			
		||||
			window.addEventListener('resize', () => {
 | 
			
		||||
				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
 | 
			
		||||
			}, { passive: true });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
				document.title = `${this.pageInfo.title} | ${instanceName}`;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		adjustUI() {
 | 
			
		||||
			const navWidth = this.$refs.nav.$el.offsetWidth;
 | 
			
		||||
			this.navHidden = navWidth === 0;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showNav() {
 | 
			
		||||
			this.$refs.nav.show();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		attachSticky(el) {
 | 
			
		||||
			const sticky = new StickySidebar(this.$refs.widgets);
 | 
			
		||||
			window.addEventListener('scroll', () => {
 | 
			
		||||
				sticky.calc(window.scrollY);
 | 
			
		||||
			}, { passive: true });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		post() {
 | 
			
		||||
			os.post();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		top() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		back() {
 | 
			
		||||
			history.back();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onTransition() {
 | 
			
		||||
			if (window._scroll) window._scroll();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			const isLink = (el: HTMLElement) => {
 | 
			
		||||
				if (el.tagName === 'A') return true;
 | 
			
		||||
				if (el.parentElement) {
 | 
			
		||||
					return isLink(el.parentElement);
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
			if (isLink(e.target)) return;
 | 
			
		||||
			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
 | 
			
		||||
			if (window.getSelection().toString() !== '') return;
 | 
			
		||||
			const path = this.$route.path;
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				type: 'label',
 | 
			
		||||
				text: path,
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-columns',
 | 
			
		||||
				text: this.$ts.openInSideView,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.$refs.side.navigate(path);
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(path);
 | 
			
		||||
				}
 | 
			
		||||
			}], e);
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.tray-enter-active,
 | 
			
		||||
.tray-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);
 | 
			
		||||
}
 | 
			
		||||
.tray-enter-from,
 | 
			
		||||
.tray-leave-active {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
	transform: translateX(240px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tray-back-enter-active,
 | 
			
		||||
.tray-back-leave-active {
 | 
			
		||||
	opacity: 1;
 | 
			
		||||
	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
}
 | 
			
		||||
.tray-back-enter-from,
 | 
			
		||||
.tray-back-leave-active {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mk-app {
 | 
			
		||||
	$ui-font-size: 1em; // TODO: どこかに集約したい
 | 
			
		||||
	$widgets-hide-threshold: 1090px;
 | 
			
		||||
 | 
			
		||||
	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 | 
			
		||||
	min-height: calc(var(--vh, 1vh) * 100);
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
	display: flex;
 | 
			
		||||
 | 
			
		||||
	&.wallpaper {
 | 
			
		||||
		background: var(--wallpaperOverlay);
 | 
			
		||||
		//backdrop-filter: var(--blur, blur(4px));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .sidebar {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .contents {
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
		background: var(--panel);
 | 
			
		||||
 | 
			
		||||
		> main {
 | 
			
		||||
			min-width: 0;
 | 
			
		||||
 | 
			
		||||
			> .spacer {
 | 
			
		||||
				height: 82px;
 | 
			
		||||
 | 
			
		||||
				@media (min-width: ($widgets-hide-threshold + 1px)) {
 | 
			
		||||
					display: none;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .side {
 | 
			
		||||
		min-width: 370px;
 | 
			
		||||
		max-width: 370px;
 | 
			
		||||
		border-left: solid 0.5px var(--divider);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .widgets {
 | 
			
		||||
		padding: 0 var(--margin);
 | 
			
		||||
		border-left: solid 0.5px var(--divider);
 | 
			
		||||
		background: var(--bg);
 | 
			
		||||
 | 
			
		||||
		@media (max-width: $widgets-hide-threshold) {
 | 
			
		||||
			display: none;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .widgetButton {
 | 
			
		||||
		display: block;
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		z-index: 1000;
 | 
			
		||||
		bottom: 32px;
 | 
			
		||||
		right: 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;
 | 
			
		||||
		background: var(--panel);
 | 
			
		||||
 | 
			
		||||
		&.navHidden {
 | 
			
		||||
			display: none;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		@media (min-width: ($widgets-hide-threshold + 1px)) {
 | 
			
		||||
			display: none;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .buttons {
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		z-index: 1000;
 | 
			
		||||
		bottom: 0;
 | 
			
		||||
		padding: 16px;
 | 
			
		||||
		display: flex;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
		-webkit-backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
		backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
		background-color: var(--header);
 | 
			
		||||
 | 
			
		||||
		&:not(.navHidden) {
 | 
			
		||||
			display: none;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .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: 22px;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			&:disabled {
 | 
			
		||||
				cursor: default;
 | 
			
		||||
 | 
			
		||||
				> * {
 | 
			
		||||
					opacity: 0.5;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .tray-back {
 | 
			
		||||
		z-index: 1001;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .tray {
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		right: 0;
 | 
			
		||||
		z-index: 1001;
 | 
			
		||||
		// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 | 
			
		||||
		height: calc(var(--vh, 1vh) * 100);
 | 
			
		||||
		padding: var(--margin);
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
		background: var(--bg);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										79
									
								
								packages/client/src/ui/universal.widgets.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								packages/client/src/ui/universal.widgets.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,79 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="efzpzdvf">
 | 
			
		||||
	<XWidgets :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
 | 
			
		||||
 | 
			
		||||
	<button v-if="editMode" @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button>
 | 
			
		||||
	<button v-else @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import XWidgets from '@/components/widgets.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XWidgets
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	emits: ['mounted'],
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			editMode: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.$emit('mounted', this.$el);
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		addWidget(widget) {
 | 
			
		||||
			this.$store.set('widgets', [{
 | 
			
		||||
				...widget,
 | 
			
		||||
				place: null,
 | 
			
		||||
			}, ...this.$store.state.widgets]);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		removeWidget(widget) {
 | 
			
		||||
			this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidget({ id, data }) {
 | 
			
		||||
			this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? {
 | 
			
		||||
				...w,
 | 
			
		||||
				data: data
 | 
			
		||||
			} : w));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidgets(widgets) {
 | 
			
		||||
			this.$store.set('widgets', widgets);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.efzpzdvf {
 | 
			
		||||
	position: sticky;
 | 
			
		||||
	height: min-content;
 | 
			
		||||
	min-height: 100vh;
 | 
			
		||||
	padding: var(--margin) 0;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
	> * {
 | 
			
		||||
		margin: var(--margin) 0;
 | 
			
		||||
		width: 300px;
 | 
			
		||||
 | 
			
		||||
		&:first-child {
 | 
			
		||||
			margin-top: 0;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .add {
 | 
			
		||||
		margin: 0 auto;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										19
									
								
								packages/client/src/ui/visitor.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/client/src/ui/visitor.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
<template>
 | 
			
		||||
<DesignB/>
 | 
			
		||||
<XCommon/>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import DesignA from './visitor/a.vue';
 | 
			
		||||
import DesignB from './visitor/b.vue';
 | 
			
		||||
import XCommon from './_common_/common.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCommon,
 | 
			
		||||
		DesignA,
 | 
			
		||||
		DesignB,
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										260
									
								
								packages/client/src/ui/visitor/a.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								packages/client/src/ui/visitor/a.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,260 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-app">
 | 
			
		||||
	<div class="banner" v-if="$route.path === '/'" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
 | 
			
		||||
		<div>
 | 
			
		||||
			<h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
 | 
			
		||||
			<div class="about" v-if="meta">
 | 
			
		||||
				<div class="desc" v-html="meta.description || $ts.introMisskey"></div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="action">
 | 
			
		||||
				<button class="_button primary" @click="signup()">{{ $ts.signup }}</button>
 | 
			
		||||
				<button class="_button" @click="signin()">{{ $ts.login }}</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="banner-mini" v-else :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
 | 
			
		||||
		<div>
 | 
			
		||||
			<h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<div class="main">
 | 
			
		||||
		<div class="contents" ref="contents" :class="{ wallpaper }">
 | 
			
		||||
			<header class="header" ref="header" v-show="$route.path !== '/'">
 | 
			
		||||
				<XHeader :info="pageInfo"/>
 | 
			
		||||
			</header>
 | 
			
		||||
			<main ref="main">
 | 
			
		||||
				<router-view v-slot="{ Component }">
 | 
			
		||||
					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
 | 
			
		||||
						<component :is="Component" :ref="changePage"/>
 | 
			
		||||
					</transition>
 | 
			
		||||
				</router-view>
 | 
			
		||||
			</main>
 | 
			
		||||
			<div class="powered-by">
 | 
			
		||||
				<b><MkA to="/">{{ host }}</MkA></b>
 | 
			
		||||
				<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { host, instanceName } from '@/config';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import XHeader from './header.vue';
 | 
			
		||||
import { ColdDeviceStorage } from '@/store';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
const DESKTOP_THRESHOLD = 1100;
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XHeader,
 | 
			
		||||
		MkPagination,
 | 
			
		||||
		MkButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			host,
 | 
			
		||||
			instanceName,
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			meta: null,
 | 
			
		||||
			narrow: window.innerWidth < 1280,
 | 
			
		||||
			announcements: {
 | 
			
		||||
				endpoint: 'announcements',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
			},
 | 
			
		||||
			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		keymap(): any {
 | 
			
		||||
			return {
 | 
			
		||||
				'd': () => {
 | 
			
		||||
					if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
 | 
			
		||||
					this.$store.set('darkMode', !this.$store.state.darkMode);
 | 
			
		||||
				},
 | 
			
		||||
				's': search,
 | 
			
		||||
				'h|/': this.help
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		document.documentElement.style.overflowY = 'scroll';
 | 
			
		||||
 | 
			
		||||
		os.api('meta', { detail: true }).then(meta => {
 | 
			
		||||
			this.meta = meta;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (!this.isDesktop) {
 | 
			
		||||
			window.addEventListener('resize', () => {
 | 
			
		||||
				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
 | 
			
		||||
			}, { passive: true });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		setParallax(el) {
 | 
			
		||||
			//new simpleParallax(el);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		top() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		help() {
 | 
			
		||||
			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onTransition() {
 | 
			
		||||
			if (window._scroll) window._scroll();
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.mk-app {
 | 
			
		||||
	min-height: 100vh;
 | 
			
		||||
 | 
			
		||||
	> .banner {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		text-align: center;
 | 
			
		||||
		background-position: center;
 | 
			
		||||
		background-size: cover;
 | 
			
		||||
 | 
			
		||||
		> div {
 | 
			
		||||
			height: 100%;
 | 
			
		||||
			background: rgba(0, 0, 0, 0.3);
 | 
			
		||||
 | 
			
		||||
			* {
 | 
			
		||||
				color: #fff;
 | 
			
		||||
			}
 | 
			
		||||
					
 | 
			
		||||
			> h1 {
 | 
			
		||||
				margin: 0;
 | 
			
		||||
				padding: 96px 32px 0 32px;
 | 
			
		||||
				text-shadow: 0 0 8px black;
 | 
			
		||||
 | 
			
		||||
				> .logo {
 | 
			
		||||
					vertical-align: bottom;
 | 
			
		||||
					max-height: 150px;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .about {
 | 
			
		||||
				padding: 32px;
 | 
			
		||||
				max-width: 580px;
 | 
			
		||||
				margin: 0 auto;
 | 
			
		||||
				box-sizing: border-box;
 | 
			
		||||
				text-shadow: 0 0 8px black;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .action {
 | 
			
		||||
				padding-bottom: 64px;
 | 
			
		||||
				
 | 
			
		||||
				> button {
 | 
			
		||||
					display: inline-block;
 | 
			
		||||
					padding: 10px 20px;
 | 
			
		||||
					box-sizing: border-box;
 | 
			
		||||
					text-align: center;
 | 
			
		||||
					border-radius: 999px;
 | 
			
		||||
					background: var(--panel);
 | 
			
		||||
					color: var(--fg);
 | 
			
		||||
 | 
			
		||||
					&.primary {
 | 
			
		||||
						background: var(--accent);
 | 
			
		||||
						color: #fff;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					&:first-child {
 | 
			
		||||
						margin-right: 16px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .banner-mini {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		text-align: center;
 | 
			
		||||
		background-position: center;
 | 
			
		||||
		background-size: cover;
 | 
			
		||||
 | 
			
		||||
		> div {
 | 
			
		||||
			position: relative;
 | 
			
		||||
			z-index: 1;
 | 
			
		||||
			height: 100%;
 | 
			
		||||
			background: rgba(0, 0, 0, 0.3);
 | 
			
		||||
 | 
			
		||||
			* {
 | 
			
		||||
				color: #fff !important;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> header {
 | 
			
		||||
				
 | 
			
		||||
			}
 | 
			
		||||
					
 | 
			
		||||
			> h1 {
 | 
			
		||||
				margin: 0;
 | 
			
		||||
				padding: 32px;
 | 
			
		||||
				text-shadow: 0 0 8px black;
 | 
			
		||||
 | 
			
		||||
				> .logo {
 | 
			
		||||
					vertical-align: bottom;
 | 
			
		||||
					max-height: 100px;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .main {
 | 
			
		||||
		> .contents {
 | 
			
		||||
			position: relative;
 | 
			
		||||
			z-index: 1;
 | 
			
		||||
 | 
			
		||||
			> .header {
 | 
			
		||||
				position: sticky;
 | 
			
		||||
				top: 0;
 | 
			
		||||
				left: 0;
 | 
			
		||||
				z-index: 1000;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .powered-by {
 | 
			
		||||
				padding: 28px;
 | 
			
		||||
				font-size: 14px;
 | 
			
		||||
				text-align: center;
 | 
			
		||||
				border-top: 1px solid var(--divider);
 | 
			
		||||
 | 
			
		||||
				> small {
 | 
			
		||||
					display: block;
 | 
			
		||||
					margin-top: 8px;
 | 
			
		||||
					opacity: 0.5;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										282
									
								
								packages/client/src/ui/visitor/b.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										282
									
								
								packages/client/src/ui/visitor/b.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,282 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-app">
 | 
			
		||||
	<a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
 | 
			
		||||
 | 
			
		||||
	<div class="side" v-if="!narrow && !root">
 | 
			
		||||
		<XKanban class="kanban" full/>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<div class="main">
 | 
			
		||||
		<XKanban class="banner" :powered-by="root" v-if="narrow && !root"/>
 | 
			
		||||
 | 
			
		||||
		<div class="contents">
 | 
			
		||||
			<XHeader class="header" :info="pageInfo" v-if="!root"/>
 | 
			
		||||
			<main>
 | 
			
		||||
				<router-view v-slot="{ Component }">
 | 
			
		||||
					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
 | 
			
		||||
						<component :is="Component" :ref="changePage"/>
 | 
			
		||||
					</transition>
 | 
			
		||||
				</router-view>
 | 
			
		||||
			</main>
 | 
			
		||||
			<div class="powered-by" v-if="!root">
 | 
			
		||||
				<b><MkA to="/">{{ host }}</MkA></b>
 | 
			
		||||
				<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<transition name="tray-back">
 | 
			
		||||
		<div class="menu-back _modalBg"
 | 
			
		||||
			v-if="showMenu"
 | 
			
		||||
			@click="showMenu = false"
 | 
			
		||||
			@touchstart.passive="showMenu = false"
 | 
			
		||||
		></div>
 | 
			
		||||
	</transition>
 | 
			
		||||
 | 
			
		||||
	<transition name="tray">
 | 
			
		||||
		<div v-if="showMenu" class="menu">
 | 
			
		||||
			<MkA to="/" class="link" active-class="active"><i class="fas fa-home icon"></i>{{ $ts.home }}</MkA>
 | 
			
		||||
			<MkA to="/explore" class="link" active-class="active"><i class="fas fa-hashtag icon"></i>{{ $ts.explore }}</MkA>
 | 
			
		||||
			<MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA>
 | 
			
		||||
			<MkA to="/channels" class="link" active-class="active"><i class="fas fa-satellite-dish icon"></i>{{ $ts.channel }}</MkA>
 | 
			
		||||
			<div class="action">
 | 
			
		||||
				<button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button>
 | 
			
		||||
				<button class="_button" @click="signin()">{{ $ts.login }}</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</transition>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { host, instanceName } from '@/config';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import XSigninDialog from '@/components/signin-dialog.vue';
 | 
			
		||||
import XSignupDialog from '@/components/signup-dialog.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import XHeader from './header.vue';
 | 
			
		||||
import XKanban from './kanban.vue';
 | 
			
		||||
import { ColdDeviceStorage } from '@/store';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
const DESKTOP_THRESHOLD = 1100;
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XHeader,
 | 
			
		||||
		XKanban,
 | 
			
		||||
		MkPagination,
 | 
			
		||||
		MkButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			host,
 | 
			
		||||
			instanceName,
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			meta: null,
 | 
			
		||||
			showMenu: false,
 | 
			
		||||
			narrow: window.innerWidth < 1280,
 | 
			
		||||
			announcements: {
 | 
			
		||||
				endpoint: 'announcements',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
			},
 | 
			
		||||
			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		keymap(): any {
 | 
			
		||||
			return {
 | 
			
		||||
				'd': () => {
 | 
			
		||||
					if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
 | 
			
		||||
					this.$store.set('darkMode', !this.$store.state.darkMode);
 | 
			
		||||
				},
 | 
			
		||||
				's': search,
 | 
			
		||||
				'h|/': this.help
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		root(): boolean {
 | 
			
		||||
			return this.$route.path === '/';
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		//document.documentElement.style.overflowY = 'scroll';
 | 
			
		||||
 | 
			
		||||
		os.api('meta', { detail: true }).then(meta => {
 | 
			
		||||
			this.meta = meta;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (!this.isDesktop) {
 | 
			
		||||
			window.addEventListener('resize', () => {
 | 
			
		||||
				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
 | 
			
		||||
			}, { passive: true });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		top() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		help() {
 | 
			
		||||
			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onTransition() {
 | 
			
		||||
			if (window._scroll) window._scroll();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		signin() {
 | 
			
		||||
			os.popup(XSigninDialog, {
 | 
			
		||||
				autoSet: true
 | 
			
		||||
			}, {}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		signup() {
 | 
			
		||||
			os.popup(XSignupDialog, {
 | 
			
		||||
				autoSet: true
 | 
			
		||||
			}, {}, 'closed');
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.tray-enter-active,
 | 
			
		||||
.tray-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);
 | 
			
		||||
}
 | 
			
		||||
.tray-enter-from,
 | 
			
		||||
.tray-leave-active {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
	transform: translateX(-240px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tray-back-enter-active,
 | 
			
		||||
.tray-back-leave-active {
 | 
			
		||||
	opacity: 1;
 | 
			
		||||
	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
}
 | 
			
		||||
.tray-back-enter-from,
 | 
			
		||||
.tray-back-leave-active {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mk-app {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	min-height: 100vh;
 | 
			
		||||
	background-position: center;
 | 
			
		||||
	background-size: cover;
 | 
			
		||||
	background-attachment: fixed;
 | 
			
		||||
 | 
			
		||||
	> .side {
 | 
			
		||||
		width: 500px;
 | 
			
		||||
		height: 100vh;
 | 
			
		||||
 | 
			
		||||
		> .kanban {
 | 
			
		||||
			position: fixed;
 | 
			
		||||
			top: 0;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			width: 500px;
 | 
			
		||||
			height: 100vh;
 | 
			
		||||
			overflow: auto;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .main {
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
 | 
			
		||||
		> .banner {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .contents {
 | 
			
		||||
			position: relative;
 | 
			
		||||
			z-index: 1;
 | 
			
		||||
 | 
			
		||||
			> .powered-by {
 | 
			
		||||
				padding: 28px;
 | 
			
		||||
				font-size: 14px;
 | 
			
		||||
				text-align: center;
 | 
			
		||||
				border-top: 1px solid var(--divider);
 | 
			
		||||
 | 
			
		||||
				> small {
 | 
			
		||||
					display: block;
 | 
			
		||||
					margin-top: 8px;
 | 
			
		||||
					opacity: 0.5;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .menu-back {
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		z-index: 1001;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		left: 0;
 | 
			
		||||
		width: 100vw;
 | 
			
		||||
		height: 100vh;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .menu {
 | 
			
		||||
		position: fixed;
 | 
			
		||||
		z-index: 1001;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		left: 0;
 | 
			
		||||
		width: 240px;
 | 
			
		||||
		height: 100vh;
 | 
			
		||||
		background: var(--panel);
 | 
			
		||||
 | 
			
		||||
		> .link {
 | 
			
		||||
			display: block;
 | 
			
		||||
			padding: 16px;
 | 
			
		||||
 | 
			
		||||
			> .icon {
 | 
			
		||||
				margin-right: 1em;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .action {
 | 
			
		||||
			padding: 16px;
 | 
			
		||||
 | 
			
		||||
			> button {
 | 
			
		||||
				display: block;
 | 
			
		||||
				width: 100%;
 | 
			
		||||
				padding: 10px;
 | 
			
		||||
				box-sizing: border-box;
 | 
			
		||||
				text-align: center;
 | 
			
		||||
				border-radius: 999px;
 | 
			
		||||
 | 
			
		||||
				&._button {
 | 
			
		||||
					background: var(--panel);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				&:first-child {
 | 
			
		||||
					margin-bottom: 16px;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										228
									
								
								packages/client/src/ui/visitor/header.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								packages/client/src/ui/visitor/header.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,228 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="sqxihjet">
 | 
			
		||||
	<div class="wide" v-if="narrow === false">
 | 
			
		||||
		<div class="content">
 | 
			
		||||
			<MkA to="/" class="link" active-class="active"><i class="fas fa-home icon"></i>{{ $ts.home }}</MkA>
 | 
			
		||||
			<MkA to="/explore" class="link" active-class="active"><i class="fas fa-hashtag icon"></i>{{ $ts.explore }}</MkA>
 | 
			
		||||
			<MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA>
 | 
			
		||||
			<MkA to="/channels" class="link" active-class="active"><i class="fas fa-satellite-dish icon"></i>{{ $ts.channel }}</MkA>
 | 
			
		||||
			<div class="page active link" v-if="info">
 | 
			
		||||
				<div class="title">
 | 
			
		||||
					<i v-if="info.icon" class="icon" :class="info.icon"></i>
 | 
			
		||||
					<MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
 | 
			
		||||
					<span v-if="info.title" class="text">{{ info.title }}</span>
 | 
			
		||||
					<MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/>
 | 
			
		||||
				</div>
 | 
			
		||||
				<button class="_button action" v-if="info.action" @click.stop="info.action.handler"><!-- TODO --></button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="right">
 | 
			
		||||
				<button class="_button search" @click="search()"><i class="fas fa-search icon"></i><span>{{ $ts.search }}</span></button>
 | 
			
		||||
				<button class="_buttonPrimary signup" @click="signup()">{{ $ts.signup }}</button>
 | 
			
		||||
				<button class="_button login" @click="signin()">{{ $ts.login }}</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="narrow" v-else-if="narrow === true">
 | 
			
		||||
		<button class="menu _button" @click="$parent.showMenu = true">
 | 
			
		||||
			<i class="fas fa-bars icon"></i>
 | 
			
		||||
		</button>
 | 
			
		||||
		<div class="title" v-if="info">
 | 
			
		||||
			<i v-if="info.icon" class="icon" :class="info.icon"></i>
 | 
			
		||||
			<MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
 | 
			
		||||
			<span v-if="info.title" class="text">{{ info.title }}</span>
 | 
			
		||||
			<MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/>
 | 
			
		||||
		</div>
 | 
			
		||||
		<button class="action _button" v-if="info && info.action" @click.stop="info.action.handler">
 | 
			
		||||
			<!-- TODO -->
 | 
			
		||||
		</button>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XSigninDialog from '@/components/signin-dialog.vue';
 | 
			
		||||
import XSignupDialog from '@/components/signup-dialog.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		info: {
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			narrow: null,
 | 
			
		||||
			showMenu: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.narrow = this.$el.clientWidth < 1300;
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		signin() {
 | 
			
		||||
			os.popup(XSigninDialog, {
 | 
			
		||||
				autoSet: true
 | 
			
		||||
			}, {}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		signup() {
 | 
			
		||||
			os.popup(XSignupDialog, {
 | 
			
		||||
				autoSet: true
 | 
			
		||||
			}, {}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		search
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.sqxihjet {
 | 
			
		||||
	$height: 60px;
 | 
			
		||||
	position: sticky;
 | 
			
		||||
	top: 0;
 | 
			
		||||
	left: 0;
 | 
			
		||||
	z-index: 1000;
 | 
			
		||||
	line-height: $height;
 | 
			
		||||
	-webkit-backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
	backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
	background-color: var(--X16);
 | 
			
		||||
 | 
			
		||||
	> .wide {
 | 
			
		||||
		> .content {
 | 
			
		||||
			max-width: 1400px;
 | 
			
		||||
			margin: 0 auto;
 | 
			
		||||
			display: flex;
 | 
			
		||||
			align-items: center;
 | 
			
		||||
 | 
			
		||||
			> .link {
 | 
			
		||||
				$line: 3px;
 | 
			
		||||
				display: inline-block;
 | 
			
		||||
				padding: 0 16px;
 | 
			
		||||
				line-height: $height - ($line * 2);
 | 
			
		||||
				border-top: solid $line transparent;
 | 
			
		||||
				border-bottom: solid $line transparent;
 | 
			
		||||
 | 
			
		||||
				> .icon {
 | 
			
		||||
					margin-right: 0.5em;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				&.page {
 | 
			
		||||
					border-bottom-color: var(--accent);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .page {
 | 
			
		||||
				> .title {
 | 
			
		||||
					display: inline-block;
 | 
			
		||||
					vertical-align: bottom;
 | 
			
		||||
					white-space: nowrap;
 | 
			
		||||
					overflow: hidden;
 | 
			
		||||
					text-overflow: ellipsis;
 | 
			
		||||
					position: relative;
 | 
			
		||||
 | 
			
		||||
					> .icon + .text {
 | 
			
		||||
						margin-left: 8px;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .avatar {
 | 
			
		||||
						$size: 32px;
 | 
			
		||||
						display: inline-block;
 | 
			
		||||
						width: $size;
 | 
			
		||||
						height: $size;
 | 
			
		||||
						vertical-align: middle;
 | 
			
		||||
						margin-right: 8px;
 | 
			
		||||
						pointer-events: none;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					&._button {
 | 
			
		||||
						&:hover {
 | 
			
		||||
							color: var(--fgHighlighted);
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					&.selected {
 | 
			
		||||
						box-shadow: 0 -2px 0 0 var(--accent) inset;
 | 
			
		||||
						color: var(--fgHighlighted);
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .action {
 | 
			
		||||
					padding: 0 0 0 16px;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .right {
 | 
			
		||||
				margin-left: auto;
 | 
			
		||||
 | 
			
		||||
				> .search {
 | 
			
		||||
					background: var(--bg);
 | 
			
		||||
					border-radius: 999px;
 | 
			
		||||
					width: 230px;
 | 
			
		||||
					line-height: $height - 20px;
 | 
			
		||||
					margin-right: 16px;
 | 
			
		||||
					text-align: left;
 | 
			
		||||
 | 
			
		||||
					> * {
 | 
			
		||||
						opacity: 0.7;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .icon {
 | 
			
		||||
						padding: 0 16px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .signup {
 | 
			
		||||
					border-radius: 999px;
 | 
			
		||||
					padding: 0 24px;
 | 
			
		||||
					line-height: $height - 20px;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .login {
 | 
			
		||||
					padding: 0 16px;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .narrow {
 | 
			
		||||
		display: flex;
 | 
			
		||||
 | 
			
		||||
		> .menu,
 | 
			
		||||
		> .action {
 | 
			
		||||
			width: $height;
 | 
			
		||||
			height: $height;
 | 
			
		||||
			font-size: 20px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .title {
 | 
			
		||||
			flex: 1;
 | 
			
		||||
			white-space: nowrap;
 | 
			
		||||
			overflow: hidden;
 | 
			
		||||
			text-overflow: ellipsis;
 | 
			
		||||
			position: relative;
 | 
			
		||||
			text-align: center;
 | 
			
		||||
 | 
			
		||||
			> .icon + .text {
 | 
			
		||||
				margin-left: 8px;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .avatar {
 | 
			
		||||
				$size: 32px;
 | 
			
		||||
				display: inline-block;
 | 
			
		||||
				width: $size;
 | 
			
		||||
				height: $size;
 | 
			
		||||
				vertical-align: middle;
 | 
			
		||||
				margin-right: 8px;
 | 
			
		||||
				pointer-events: none;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										256
									
								
								packages/client/src/ui/visitor/kanban.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								packages/client/src/ui/visitor/kanban.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,256 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }">
 | 
			
		||||
	<div class="back" :class="{ transparent }"></div>
 | 
			
		||||
	<div class="contents">
 | 
			
		||||
		<div class="wrapper">
 | 
			
		||||
			<h1 v-if="meta" :class="{ full }">
 | 
			
		||||
				<MkA to="/" class="link"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></MkA>
 | 
			
		||||
			</h1>
 | 
			
		||||
			<template v-if="full">
 | 
			
		||||
				<div class="about" v-if="meta">
 | 
			
		||||
					<div class="desc" v-html="meta.description || $ts.introMisskey"></div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="action">
 | 
			
		||||
					<button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button>
 | 
			
		||||
					<button class="_button" @click="signin()">{{ $ts.login }}</button>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="announcements panel">
 | 
			
		||||
					<header>{{ $ts.announcements }}</header>
 | 
			
		||||
					<MkPagination :pagination="announcements" #default="{items}" class="list">
 | 
			
		||||
						<section class="item" v-for="(announcement, i) in items" :key="announcement.id">
 | 
			
		||||
							<div class="title">{{ announcement.title }}</div>
 | 
			
		||||
							<div class="content">
 | 
			
		||||
								<Mfm :text="announcement.text"/>
 | 
			
		||||
								<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
 | 
			
		||||
							</div>
 | 
			
		||||
						</section>
 | 
			
		||||
					</MkPagination>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="powered-by" v-if="poweredBy">
 | 
			
		||||
					<b><MkA to="/">{{ host }}</MkA></b>
 | 
			
		||||
					<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
 | 
			
		||||
				</div>
 | 
			
		||||
			</template>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { host, instanceName } from '@/config';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import XSigninDialog from '@/components/signin-dialog.vue';
 | 
			
		||||
import XSignupDialog from '@/components/signup-dialog.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkPagination,
 | 
			
		||||
		MkButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		full: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
		poweredBy: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			host,
 | 
			
		||||
			instanceName,
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			meta: null,
 | 
			
		||||
			narrow: window.innerWidth < 1280,
 | 
			
		||||
			announcements: {
 | 
			
		||||
				endpoint: 'announcements',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		os.api('meta', { detail: true }).then(meta => {
 | 
			
		||||
			this.meta = meta;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		signin() {
 | 
			
		||||
			os.popup(XSigninDialog, {
 | 
			
		||||
				autoSet: true
 | 
			
		||||
			}, {}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		signup() {
 | 
			
		||||
			os.popup(XSignupDialog, {
 | 
			
		||||
				autoSet: true
 | 
			
		||||
			}, {}, 'closed');
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.rwqkcmrc {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
	background-position: center;
 | 
			
		||||
	background-size: cover;
 | 
			
		||||
	// TODO: パララックスにしたい
 | 
			
		||||
 | 
			
		||||
	> .back {
 | 
			
		||||
		position: absolute;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		left: 0;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		height: 100%;
 | 
			
		||||
		background: rgba(0, 0, 0, 0.3);
 | 
			
		||||
 | 
			
		||||
		&.transparent {
 | 
			
		||||
			-webkit-backdrop-filter: var(--blur, blur(12px));
 | 
			
		||||
			backdrop-filter: var(--blur, blur(12px));
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .contents {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		z-index: 1;
 | 
			
		||||
		height: inherit;
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
 | 
			
		||||
		> .wrapper {
 | 
			
		||||
			max-width: 380px;
 | 
			
		||||
			padding: 0 16px;
 | 
			
		||||
			box-sizing: border-box;
 | 
			
		||||
			margin: 0 auto;
 | 
			
		||||
 | 
			
		||||
			> .panel {
 | 
			
		||||
				-webkit-backdrop-filter: var(--blur, blur(8px));
 | 
			
		||||
				backdrop-filter: var(--blur, blur(8px));
 | 
			
		||||
				background: rgba(0, 0, 0, 0.5);
 | 
			
		||||
				border-radius: var(--radius);
 | 
			
		||||
 | 
			
		||||
				&, * {
 | 
			
		||||
					color: #fff !important;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> h1 {
 | 
			
		||||
				display: block;
 | 
			
		||||
				margin: 0;
 | 
			
		||||
				padding: 32px 0 32px 0;
 | 
			
		||||
				color: #fff;
 | 
			
		||||
 | 
			
		||||
				&.full {
 | 
			
		||||
					padding: 64px 0 0 0;
 | 
			
		||||
 | 
			
		||||
					> .link {
 | 
			
		||||
						> ::v-deep(.logo) {
 | 
			
		||||
							max-height: 130px;
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .link {
 | 
			
		||||
					display: block;
 | 
			
		||||
 | 
			
		||||
					> ::v-deep(.logo) {
 | 
			
		||||
						vertical-align: bottom;
 | 
			
		||||
						max-height: 100px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .about {
 | 
			
		||||
				display: block;
 | 
			
		||||
				margin: 24px 0;
 | 
			
		||||
				text-align: center;
 | 
			
		||||
				box-sizing: border-box;
 | 
			
		||||
				text-shadow: 0 0 8px black;
 | 
			
		||||
				color: #fff;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .action {
 | 
			
		||||
				> button {
 | 
			
		||||
					display: block;
 | 
			
		||||
					width: 100%;
 | 
			
		||||
					padding: 10px;
 | 
			
		||||
					box-sizing: border-box;
 | 
			
		||||
					text-align: center;
 | 
			
		||||
					border-radius: 999px;
 | 
			
		||||
 | 
			
		||||
					&._button {
 | 
			
		||||
						background: var(--panel);
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					&:first-child {
 | 
			
		||||
						margin-bottom: 16px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .announcements {
 | 
			
		||||
				margin: 32px 0;
 | 
			
		||||
				text-align: left;
 | 
			
		||||
 | 
			
		||||
				> header {
 | 
			
		||||
					padding: 12px 16px;
 | 
			
		||||
					border-bottom: solid 1px rgba(255, 255, 255, 0.5);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .list {
 | 
			
		||||
					max-height: 300px;
 | 
			
		||||
					overflow: auto;
 | 
			
		||||
 | 
			
		||||
					> .item {
 | 
			
		||||
						padding: 12px 16px;
 | 
			
		||||
 | 
			
		||||
						& + .item {
 | 
			
		||||
							border-top: solid 1px rgba(255, 255, 255, 0.5);
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						> .title {
 | 
			
		||||
							font-weight: bold;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						> .content {
 | 
			
		||||
							> img {
 | 
			
		||||
								max-width: 100%;
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .powered-by {
 | 
			
		||||
				padding: 28px;
 | 
			
		||||
				font-size: 14px;
 | 
			
		||||
				text-align: center;
 | 
			
		||||
				border-top: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
				color: #fff;
 | 
			
		||||
 | 
			
		||||
				> small {
 | 
			
		||||
					display: block;
 | 
			
		||||
					margin-top: 8px;
 | 
			
		||||
					opacity: 0.5;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										106
									
								
								packages/client/src/ui/zen.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								packages/client/src/ui/zen.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,106 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="mk-app">
 | 
			
		||||
	<div class="contents">
 | 
			
		||||
		<header class="header">
 | 
			
		||||
			<MkHeader :info="pageInfo"/>
 | 
			
		||||
		</header>
 | 
			
		||||
		<main ref="main">
 | 
			
		||||
			<div class="content">
 | 
			
		||||
				<router-view v-slot="{ Component }">
 | 
			
		||||
					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
 | 
			
		||||
						<keep-alive :include="['timeline']">
 | 
			
		||||
							<component :is="Component" :ref="changePage"/>
 | 
			
		||||
						</keep-alive>
 | 
			
		||||
					</transition>
 | 
			
		||||
				</router-view>
 | 
			
		||||
			</div>
 | 
			
		||||
		</main>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<XCommon/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { host } from '@/config';
 | 
			
		||||
import XCommon from './_common_/common.vue';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCommon,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			host: host,
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		document.documentElement.style.overflowY = 'scroll';
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		top() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		help() {
 | 
			
		||||
			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onTransition() {
 | 
			
		||||
			if (window._scroll) window._scroll();
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.mk-app {
 | 
			
		||||
	$header-height: 52px;
 | 
			
		||||
	$ui-font-size: 1em; // TODO: どこかに集約したい
 | 
			
		||||
 | 
			
		||||
	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 | 
			
		||||
	min-height: calc(var(--vh, 1vh) * 100);
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
	> .contents {
 | 
			
		||||
		padding-top: $header-height;
 | 
			
		||||
 | 
			
		||||
		> .header {
 | 
			
		||||
			position: fixed;
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
			top: 0;
 | 
			
		||||
			height: $header-height;
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			line-height: $header-height;
 | 
			
		||||
			text-align: center;
 | 
			
		||||
			//background-color: var(--panel);
 | 
			
		||||
			-webkit-backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
			backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
			background-color: var(--header);
 | 
			
		||||
			border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> main {
 | 
			
		||||
			> .content {
 | 
			
		||||
				> * {
 | 
			
		||||
					// ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 | 
			
		||||
					min-height: calc((var(--vh, 1vh) * 100) - #{$header-height});
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue