From 44c85aff86cfa97797880e9b246ea4c75dc82984 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sun, 3 Jul 2022 14:40:02 +0900
Subject: [PATCH] feat(client): status bar (experimental)

---
 locales/ja-JP.yml                             |   2 +
 .../components/global/sticky-container.vue    |   8 +-
 packages/client/src/pages/settings/index.vue  |   6 +
 .../pages/settings/statusbars.statusbar.vue   | 122 ++++++++++++++++++
 .../client/src/pages/settings/statusbars.vue  |  61 +++++++++
 packages/client/src/store.ts                  |  13 ++
 .../src/ui/_common_/statusbar-federation.vue  | 103 +++++++++++++++
 .../client/src/ui/_common_/statusbar-rss.vue  |  88 +++++++++++++
 .../src/ui/_common_/statusbar-user-list.vue   | 104 +++++++++++++++
 .../client/src/ui/_common_/statusbars.vue     |  75 +++++++++++
 packages/client/src/ui/deck.vue               |  86 +++++++-----
 packages/client/src/ui/universal.vue          |  41 +++---
 12 files changed, 658 insertions(+), 51 deletions(-)
 create mode 100644 packages/client/src/pages/settings/statusbars.statusbar.vue
 create mode 100644 packages/client/src/pages/settings/statusbars.vue
 create mode 100644 packages/client/src/ui/_common_/statusbar-federation.vue
 create mode 100644 packages/client/src/ui/_common_/statusbar-rss.vue
 create mode 100644 packages/client/src/ui/_common_/statusbar-user-list.vue
 create mode 100644 packages/client/src/ui/_common_/statusbars.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d333ac29df..01d0016883 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -864,6 +864,8 @@ numberOfPageCache: "ページキャッシュ数"
 numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。"
 logoutConfirm: "ログアウトしますか?"
 lastActiveDate: "最終利用日時"
+statusbar: "ステータスバー"
+pleaseSelect: "選択してください"
 
 _emailUnavailable:
   used: "既に使用されています"
diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue
index 2603fac55d..44f4f065a6 100644
--- a/packages/client/src/components/global/sticky-container.vue
+++ b/packages/client/src/components/global/sticky-container.vue
@@ -9,11 +9,15 @@
 </div>
 </template>
 
+<script lang="ts">
+// なんか動かない
+//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
+const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
+</script>
+
 <script lang="ts" setup>
 import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue';
 
-const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
-
 const rootEl = $ref<HTMLElement>();
 const headerEl = $ref<HTMLElement>();
 const bodyEl = $ref<HTMLElement>();
diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue
index 8e445a77d7..76410ec12f 100644
--- a/packages/client/src/pages/settings/index.vue
+++ b/packages/client/src/pages/settings/index.vue
@@ -113,6 +113,11 @@ const menuDef = computed(() => [{
 		text: i18n.ts.theme,
 		to: '/settings/theme',
 		active: props.initialPage === 'theme',
+	}, {
+		icon: 'fas fa-list-ul',
+		text: i18n.ts.statusbar,
+		to: '/settings/statusbars',
+		active: props.initialPage === 'statusbars',
 	}, {
 		icon: 'fas fa-list-ul',
 		text: i18n.ts.menu,
@@ -221,6 +226,7 @@ const component = computed(() => {
 		case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
 		case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
 		case 'menu': return defineAsyncComponent(() => import('./menu.vue'));
+		case 'statusbars': return defineAsyncComponent(() => import('./statusbars.vue'));
 		case 'sounds': return defineAsyncComponent(() => import('./sounds.vue'));
 		case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue'));
 		case 'deck': return defineAsyncComponent(() => import('./deck.vue'));
diff --git a/packages/client/src/pages/settings/statusbars.statusbar.vue b/packages/client/src/pages/settings/statusbars.statusbar.vue
new file mode 100644
index 0000000000..ad2fa557a3
--- /dev/null
+++ b/packages/client/src/pages/settings/statusbars.statusbar.vue
@@ -0,0 +1,122 @@
+<template>
+<div class="_formRoot">
+	<FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock">
+		<template #label>{{ i18n.ts.type }}</template>
+		<option value="rss">RSS</option>
+		<option value="federation">Federation</option>
+		<option value="userList">User list timeline</option>
+	</FormSelect>
+
+	<MkInput v-model="statusbar.name" class="_formBlock">
+		<template #label>Name</template>
+	</MkInput>
+
+	<MkSwitch v-model="statusbar.black" class="_formBlock">
+		<template #label>Black</template>
+	</MkSwitch>
+
+	<template v-if="statusbar.type === 'rss'">
+		<MkInput v-model="statusbar.props.url" class="_formBlock" type="url">
+			<template #label>URL</template>
+		</MkInput>
+		<MkInput v-model="statusbar.props.refreshIntervalSec" class="_formBlock" type="number">
+			<template #label>Refresh interval</template>
+		</MkInput>
+		<MkInput v-model="statusbar.props.marqueeDuration" class="_formBlock" type="number">
+			<template #label>Duration</template>
+		</MkInput>
+		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+			<template #label>Reverse</template>
+		</MkSwitch>
+	</template>
+	<template v-else-if="statusbar.type === 'federation'">
+		<MkInput v-model="statusbar.props.refreshIntervalSec" class="_formBlock" type="number">
+			<template #label>Refresh interval</template>
+		</MkInput>
+		<MkInput v-model="statusbar.props.marqueeDuration" class="_formBlock" type="number">
+			<template #label>Duration</template>
+		</MkInput>
+		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+			<template #label>Reverse</template>
+		</MkSwitch>
+		<MkSwitch v-model="statusbar.props.colored" class="_formBlock">
+			<template #label>Colored</template>
+		</MkSwitch>
+	</template>
+	<template v-else-if="statusbar.type === 'userList' && userLists != null">
+		<FormSelect v-model="statusbar.props.userListId" class="_formBlock">
+			<template #label>{{ i18n.ts.userList }}</template>
+			<option v-for="list in userLists" :value="list.id">{{ list.name }}</option>
+		</FormSelect>
+		<MkInput v-model="statusbar.props.refreshIntervalSec" class="_formBlock" type="number">
+			<template #label>Refresh interval</template>
+		</MkInput>
+		<MkInput v-model="statusbar.props.marqueeDuration" class="_formBlock" type="number">
+			<template #label>Duration</template>
+		</MkInput>
+		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+			<template #label>Reverse</template>
+		</MkSwitch>
+	</template>
+
+	<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+		<FormButton @click="save">save</FormButton>
+		<FormButton danger @click="del">Delete</FormButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref, watch } from 'vue';
+import FormSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	_id: string;
+	userLists: any[] | null;
+}>();
+
+const statusbar = reactive(JSON.parse(JSON.stringify(defaultStore.state.statusbars.find(x => x.id === props._id))));
+
+watch(() => statusbar.type, () => {
+	if (statusbar.type === 'rss') {
+		statusbar.name = 'NEWS';
+		statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews';
+		statusbar.props.refreshIntervalSec = 120;
+		statusbar.props.display = 'marquee';
+		statusbar.props.marqueeDuration = 100;
+		statusbar.props.marqueeReverse = false;
+	} else if (statusbar.type === 'federation') {
+		statusbar.name = 'FEDERATION';
+		statusbar.props.refreshIntervalSec = 120;
+		statusbar.props.display = 'marquee';
+		statusbar.props.marqueeDuration = 100;
+		statusbar.props.marqueeReverse = false;
+		statusbar.props.colored = false;
+	} else if (statusbar.type === 'userList') {
+		statusbar.name = 'LIST TL';
+		statusbar.props.refreshIntervalSec = 120;
+		statusbar.props.display = 'marquee';
+		statusbar.props.marqueeDuration = 100;
+		statusbar.props.marqueeReverse = false;
+	}
+});
+
+async function save() {
+	const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id);
+	const statusbars = JSON.parse(JSON.stringify(defaultStore.state.statusbars));
+	statusbars[i] = JSON.parse(JSON.stringify(statusbar));
+	defaultStore.set('statusbars', statusbars);
+}
+
+function del() {
+	defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id));
+}
+</script>
diff --git a/packages/client/src/pages/settings/statusbars.vue b/packages/client/src/pages/settings/statusbars.vue
new file mode 100644
index 0000000000..dea5e0ffd4
--- /dev/null
+++ b/packages/client/src/pages/settings/statusbars.vue
@@ -0,0 +1,61 @@
+<template>
+<div class="_formRoot">
+	<FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock">
+		<template #label>{{ x.type ?? i18n.ts.notSet }}</template>
+		<template #suffix>{{ x.name }}</template>
+		<XStatusbar :_id="x.id" :user-lists="userLists"/>
+	</FormFolder>
+	<FormButton @click="add">add</FormButton>
+	<FormRadios v-model="statusbarSize" class="_formBlock">
+		<template #label>Size</template>
+		<option value="verySmall">{{ i18n.ts.small }}+</option>
+		<option value="small">{{ i18n.ts.small }}</option>
+		<option value="medium">{{ i18n.ts.medium }}</option>
+		<option value="large">{{ i18n.ts.large }}</option>
+	</FormRadios>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XStatusbar from './statusbars.statusbar.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const statusbarSize = computed(defaultStore.makeGetterSetter('statusbarSize'));
+const statusbars = defaultStore.reactiveState.statusbars;
+
+let userLists = $ref();
+
+onMounted(() => {
+	os.api('users/lists/list').then(res => {
+		userLists = res;
+	});
+});
+
+async function add() {
+	defaultStore.push('statusbars', {
+		id: uuid(),
+		type: null,
+		black: false,
+		props: {},
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.statusbar,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
+});
+</script>
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index 94d9d91385..cde907017d 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -88,6 +88,19 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'deviceAccount',
 		default: false,
 	},
+	statusbars: {
+		where: 'deviceAccount',
+		default: [] as {
+			name: string;
+			id: string;
+			type: string;
+			props: Record<string, any>;
+		}[],
+	},
+	statusbarSize: {
+		where: 'deviceAccount',
+		default: 'medium',
+	},
 	widgets: {
 		where: 'deviceAccount',
 		default: [] as {
diff --git a/packages/client/src/ui/_common_/statusbar-federation.vue b/packages/client/src/ui/_common_/statusbar-federation.vue
new file mode 100644
index 0000000000..87b954b900
--- /dev/null
+++ b/packages/client/src/ui/_common_/statusbar-federation.vue
@@ -0,0 +1,103 @@
+<template>
+<span v-if="!fetching" class="nmidsaqw">
+	<template v-if="display === 'marquee'">
+		<transition name="change" mode="default">
+			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
+				<span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }">
+					<img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/>
+					<MkA :to="`/instance-info/${instance.host}`" class="host _monospace">
+						{{ instance.host }}
+					</MkA>
+					<span class="divider"></span>
+				</span>
+			</MarqueeText>
+		</transition>
+	</template>
+	<template v-else-if="display === 'oneByOne'">
+		<!-- TODO -->
+	</template>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import MarqueeText from '@/components/marquee.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import { notePage } from '@/filters/note';
+
+const props = defineProps<{
+	display?: 'marquee' | 'oneByOne';
+	colored?: boolean;
+	marqueeDuration?: number;
+	marqueeReverse?: boolean;
+	oneByOneInterval?: number;
+	refreshIntervalSec?: number;
+}>();
+
+const instances = ref<misskey.entities.Instance[]>([]);
+const fetching = ref(true);
+let key = $ref(0);
+
+const tick = () => {
+	os.api('federation/instances', {
+		sort: '+lastCommunicatedAt',
+		limit: 30,
+	}).then(res => {
+		instances.value = res;
+		fetching.value = false;
+		key++;
+	});
+};
+
+useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
+	immediate: true,
+	afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.change-enter-active, .change-leave-active {
+	position: absolute;
+	top: 0;
+  transition: all 1s ease;
+}
+.change-enter-from {
+  opacity: 0;
+	transform: translateY(-100%);
+}
+.change-leave-to {
+  opacity: 0;
+	transform: translateY(100%);
+}
+
+.nmidsaqw {
+	display: inline-block;
+	position: relative;
+
+	::v-deep(.item) {
+		display: inline-block;
+		vertical-align: bottom;
+		margin-right: 3em;
+
+		> .icon {
+			display: inline-block;
+			height: var(--height);
+			aspect-ratio: 1;
+			vertical-align: bottom;
+			margin-right: 1em;
+		}
+
+		> .host {
+			vertical-align: bottom;
+		}
+
+		&.colored {
+			padding-right: 1em;
+			color: #fff;
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/ui/_common_/statusbar-rss.vue b/packages/client/src/ui/_common_/statusbar-rss.vue
new file mode 100644
index 0000000000..ddfc6faaab
--- /dev/null
+++ b/packages/client/src/ui/_common_/statusbar-rss.vue
@@ -0,0 +1,88 @@
+<template>
+<span v-if="!fetching" class="xbhtxfms">
+	<template v-if="display === 'marquee'">
+		<transition name="change" mode="default">
+			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
+				<span v-for="item in items" class="item">
+					<a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span>
+				</span>
+			</MarqueeText>
+		</transition>
+	</template>
+	<template v-else-if="display === 'oneByOne'">
+		<!-- TODO -->
+	</template>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
+import MarqueeText from '@/components/marquee.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+
+const props = defineProps<{
+	url?: string;
+	display?: 'marquee' | 'oneByOne';
+	marqueeDuration?: number;
+	marqueeReverse?: boolean;
+	oneByOneInterval?: number;
+	refreshIntervalSec?: number;
+}>();
+
+const items = ref([]);
+const fetching = ref(true);
+let key = $ref(0);
+
+const tick = () => {
+	fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => {
+		res.json().then(feed => {
+			items.value = feed.items;
+			fetching.value = false;
+			key++;
+		});
+	});
+};
+
+useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
+	immediate: true,
+	afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.change-enter-active, .change-leave-active {
+	position: absolute;
+	top: 0;
+  transition: all 1s ease;
+}
+.change-enter-from {
+  opacity: 0;
+	transform: translateY(-100%);
+}
+.change-leave-to {
+  opacity: 0;
+	transform: translateY(100%);
+}
+
+.xbhtxfms {
+	display: inline-block;
+	position: relative;
+
+	::v-deep(.item) {
+		display: inline-flex;
+		align-items: center;
+		vertical-align: bottom;
+		margin: 0;
+
+		> .divider {
+			display: inline-block;
+			width: 0.5px;
+			height: var(--height);
+			margin: 0 1em;
+			background: currentColor;
+			opacity: 0.7;
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/ui/_common_/statusbar-user-list.vue b/packages/client/src/ui/_common_/statusbar-user-list.vue
new file mode 100644
index 0000000000..01240dc6bc
--- /dev/null
+++ b/packages/client/src/ui/_common_/statusbar-user-list.vue
@@ -0,0 +1,104 @@
+<template>
+<span v-if="!fetching" class="osdsvwzy">
+	<template v-if="display === 'marquee'">
+		<transition name="change" mode="default">
+			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
+				<span v-for="note in notes" :key="note.id" class="item">
+					<img class="avatar" :src="note.user.avatarUrl" decoding="async"/>
+					<MkA class="text" :to="notePage(note)">
+						<Mfm :text="getNoteSummary(note)" :plain="true" :nowrap="true" :custom-emojis="note.emojis"/>
+					</MkA>
+					<span class="divider"></span>
+				</span>
+			</MarqueeText>
+		</transition>
+	</template>
+	<template v-else-if="display === 'oneByOne'">
+		<!-- TODO -->
+	</template>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import MarqueeText from '@/components/marquee.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import { notePage } from '@/filters/note';
+
+const props = defineProps<{
+	userListId?: string;
+	display?: 'marquee' | 'oneByOne';
+	marqueeDuration?: number;
+	marqueeReverse?: boolean;
+	oneByOneInterval?: number;
+	refreshIntervalSec?: number;
+}>();
+
+const notes = ref<misskey.entities.Note[]>([]);
+const fetching = ref(true);
+let key = $ref(0);
+
+const tick = () => {
+	if (props.userListId == null) return;
+	os.api('notes/user-list-timeline', {
+		listId: props.userListId,
+	}).then(res => {
+		notes.value = res;
+		fetching.value = false;
+		key++;
+	});
+};
+
+useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
+	immediate: true,
+	afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.change-enter-active, .change-leave-active {
+	position: absolute;
+	top: 0;
+  transition: all 1s ease;
+}
+.change-enter-from {
+  opacity: 0;
+	transform: translateY(-100%);
+}
+.change-leave-to {
+  opacity: 0;
+	transform: translateY(100%);
+}
+
+.osdsvwzy {
+	display: inline-block;
+	position: relative;
+
+	::v-deep(.item) {
+		display: inline-flex;
+		align-items: center;
+		vertical-align: bottom;
+		margin: 0;
+
+		> .avatar {
+			display: inline-block;
+			height: var(--height);
+			aspect-ratio: 1;
+			vertical-align: bottom;
+			margin-right: 8px;
+		}
+
+		> .divider {
+			display: inline-block;
+			width: 0.5px;
+			height: 16px;
+			margin: 0 1em;
+			background: currentColor;
+			opacity: 0;
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/ui/_common_/statusbars.vue b/packages/client/src/ui/_common_/statusbars.vue
new file mode 100644
index 0000000000..86d2812f59
--- /dev/null
+++ b/packages/client/src/ui/_common_/statusbars.vue
@@ -0,0 +1,75 @@
+<template>
+<div
+	class="dlrsnxqu" :class="{
+		verySmall: defaultStore.reactiveState.statusbarSize.value === 'verySmall',
+		small: defaultStore.reactiveState.statusbarSize.value === 'small',
+		medium: defaultStore.reactiveState.statusbarSize.value === 'medium',
+		large: defaultStore.reactiveState.statusbarSize.value === 'large'
+	}"
+>
+	<div v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="{ black: x.black }">
+		<span class="name">{{ x.name }}</span>
+		<XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url"/>
+		<XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/>
+		<XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue'));
+const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue'));
+const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue'));
+</script>
+
+<style lang="scss" scoped>
+.dlrsnxqu {
+	--height: 24px;
+	background: var(--panel);
+	font-size: 0.85em;
+
+	&.verySmall {
+		--height: 16px;
+		font-size: 0.75em;
+	}
+
+	&.small {
+		--height: 20px;
+		font-size: 0.8em;
+	}
+
+	&.large {
+		--height: 26px;
+		font-size: 0.875em;
+	}
+
+	> .item {
+		display: inline-flex;
+		vertical-align: bottom;
+		width: 100%;
+		line-height: var(--height);
+		height: var(--height);
+		overflow: clip;
+		contain: strict;
+
+		> .name {
+			padding: 0 6px;
+			font-weight: bold;
+			color: var(--accent);
+		}
+
+		> .body {
+			min-width: 0;
+			flex: 1;
+		}
+
+		&.black {
+			background: #000;
+			color: #fff;
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue
index b3b9ddd556..111cf8022c 100644
--- a/packages/client/src/ui/deck.vue
+++ b/packages/client/src/ui/deck.vue
@@ -5,26 +5,31 @@
 >
 	<XSidebar v-if="!isMobile"/>
 
-	<template v-for="ids in layout">
-		<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
-		<section
-			v-if="ids.length > 1"
-			class="folder column"
-			:style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
-		>
-			<DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
-		</section>
-		<DeckColumnCore
-			v-else
-			:ref="ids[0]"
-			:key="ids[0]"
-			class="column"
-			:column="columns.find(c => c.id === ids[0])"
-			:is-stacked="false"
-			:style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }"
-			@parent-focus="moveFocus(ids[0], $event)"
-		/>
-	</template>
+	<div class="main">
+		<XStatusBars class="statusbars"/>
+		<div ref="columnsEl" class="columns">
+			<template v-for="ids in layout">
+				<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+				<section
+					v-if="ids.length > 1"
+					class="folder column"
+					:style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
+				>
+					<DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
+				</section>
+				<DeckColumnCore
+					v-else
+					:ref="ids[0]"
+					:key="ids[0]"
+					class="column"
+					:column="columns.find(c => c.id === ids[0])"
+					:is-stacked="false"
+					:style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }"
+					@parent-focus="moveFocus(ids[0], $event)"
+				/>
+			</template>
+		</div>
+	</div>
 
 	<div v-if="isMobile" class="buttons">
 		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
@@ -51,7 +56,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, provide, ref, watch } from 'vue';
+import { computed, defineAsyncComponent, onMounted, provide, ref, watch } from 'vue';
 import { v4 as uuid } from 'uuid';
 import XCommon from './_common_/common.vue';
 import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store';
@@ -64,6 +69,7 @@ import { menuDef } from '@/menu';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
 import { mainRouter } from '@/router';
+const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
 
 if (deckStore.state.navWindow) {
 	mainRouter.navHook = (path) => {
@@ -94,6 +100,8 @@ const menuIndicated = computed(() => {
 	return false;
 });
 
+let columnsEl = $ref<HTMLElement>();
+
 const addColumn = async (ev) => {
 	const columns = [
 		'main',
@@ -134,8 +142,10 @@ provide('shouldSpacerMin', true);
 document.documentElement.style.overflowY = 'hidden';
 document.documentElement.style.scrollBehavior = 'auto';
 window.addEventListener('wheel', (ev) => {
-	if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) {
-		document.documentElement.scrollLeft += ev.deltaY;
+	if (ev.target === columnsEl && ev.deltaX === 0) {
+		columnsEl.scrollLeft += ev.deltaY;
+	} else if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) {
+		columnsEl.scrollLeft += ev.deltaY;
 	}
 });
 loadDeck();
@@ -179,7 +189,6 @@ function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') {
 	height: calc(var(--vh, 1vh) * 100);
 	box-sizing: border-box;
 	flex: 1;
-	padding: var(--deckMargin);
 
 	&.center {
 		> .column:first-of-type {
@@ -195,16 +204,31 @@ function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') {
 		padding-bottom: 100px;
 	}
 
-	> .column {
-		flex-shrink: 0;
-		margin-right: var(--deckMargin);
+	> .main {
+		flex: 1;
+		min-width: 0;
+		display: flex;
+		flex-direction: column;
 
-		&.folder {
+		> .columns {
 			display: flex;
-			flex-direction: column;
+			flex: 1;
+			padding: var(--deckMargin);
+			overflow-x: auto;
+			overflow-y: clip;
 
-			> *:not(:last-child) {
-				margin-bottom: var(--deckMargin);
+			> .column {
+				flex-shrink: 0;
+				margin-right: var(--deckMargin);
+
+				&.folder {
+					display: flex;
+					flex-direction: column;
+
+					> *:not(:last-child) {
+						margin-bottom: var(--deckMargin);
+					}
+				}
 			}
 		}
 	}
diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue
index 3614f7de53..8c48510a44 100644
--- a/packages/client/src/ui/universal.vue
+++ b/packages/client/src/ui/universal.vue
@@ -2,14 +2,15 @@
 <div class="dkgtipfy" :class="{ wallpaper }">
 	<XSidebar v-if="!isMobile" class="sidebar"/>
 
-	<div class="contents" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
-		<main>
-			<div class="content">
+	<MkStickyContainer class="contents">
+		<template #header><XStatusBars :class="$style.statusbars"/></template>
+		<main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
+			<div :class="$style.content">
 				<RouterView/>
 			</div>
-			<div class="spacer"></div>
+			<div :class="$style.spacer"></div>
 		</main>
-	</div>
+	</MkStickyContainer>
 
 	<div v-if="isDesktop" ref="widgetsEl" class="widgets">
 		<XWidgets @mounted="attachSticky"/>
@@ -71,6 +72,7 @@ import { mainRouter } from '@/router';
 import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
 const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/sidebar.vue'));
+const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
 
 const DESKTOP_THRESHOLD = 1100;
 const MOBILE_THRESHOLD = 500;
@@ -235,18 +237,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null;
 		width: 100%;
 		min-width: 0;
 		background: var(--bg);
-
-		> main {
-			min-width: 0;
-
-			> .spacer {
-				height: calc(env(safe-area-inset-bottom, 0px) + 96px);
-
-				@media (min-width: ($widgets-hide-threshold + 1px)) {
-					display: none;
-				}
-			}
-		}
 	}
 
 	> .widgets {
@@ -396,5 +386,20 @@ const wallpaper = localStorage.getItem('wallpaper') != null;
 }
 </style>
 
-<style lang="scss">
+<style lang="scss" module>
+.statusbars {
+	position: sticky;
+	top: 0;
+	left: 0;
+}
+
+.spacer {
+	$widgets-hide-threshold: 1090px;
+
+	height: calc(env(safe-area-inset-bottom, 0px) + 96px);
+
+	@media (min-width: ($widgets-hide-threshold + 1px)) {
+		display: none;
+	}
+}
 </style>