summaryrefslogtreecommitdiff
path: root/packages/client
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-07-03 14:40:02 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-07-03 14:40:02 +0900
commit44c85aff86cfa97797880e9b246ea4c75dc82984 (patch)
tree73178a011d32e51ae667bd42463209ef037976fa /packages/client
parent12.112.0-beta.13 (diff)
downloadmisskey-44c85aff86cfa97797880e9b246ea4c75dc82984.tar.gz
misskey-44c85aff86cfa97797880e9b246ea4c75dc82984.tar.bz2
misskey-44c85aff86cfa97797880e9b246ea4c75dc82984.zip
feat(client): status bar (experimental)
Diffstat (limited to 'packages/client')
-rw-r--r--packages/client/src/components/global/sticky-container.vue8
-rw-r--r--packages/client/src/pages/settings/index.vue6
-rw-r--r--packages/client/src/pages/settings/statusbars.statusbar.vue122
-rw-r--r--packages/client/src/pages/settings/statusbars.vue61
-rw-r--r--packages/client/src/store.ts13
-rw-r--r--packages/client/src/ui/_common_/statusbar-federation.vue103
-rw-r--r--packages/client/src/ui/_common_/statusbar-rss.vue88
-rw-r--r--packages/client/src/ui/_common_/statusbar-user-list.vue104
-rw-r--r--packages/client/src/ui/_common_/statusbars.vue75
-rw-r--r--packages/client/src/ui/deck.vue86
-rw-r--r--packages/client/src/ui/universal.vue41
11 files changed, 656 insertions, 51 deletions
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
@@ -115,6 +115,11 @@ const menuDef = computed(() => [{
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,
to: '/settings/menu',
active: props.initialPage === '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>