diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-04-10 12:40:50 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-04-10 12:40:50 +0900 |
| commit | d3fe02fb3e8dd7bfc45c246d54d45acccd5959c7 (patch) | |
| tree | 4cd72e5e5868e89ef3dd83ada99495da2bd7cd85 /src/client/ui | |
| parent | Fix punycode deprecation warning (#7426) (diff) | |
| download | sharkey-d3fe02fb3e8dd7bfc45c246d54d45acccd5959c7.tar.gz sharkey-d3fe02fb3e8dd7bfc45c246d54d45acccd5959c7.tar.bz2 sharkey-d3fe02fb3e8dd7bfc45c246d54d45acccd5959c7.zip | |
Default UI redesign (#7429)
* wip
* wip
* wip
* wip
* Update default.sidebar.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update sticky-sidebar.ts
* wip
* wip
* Update messaging-room.form.vue
* Update timeline.vue
Diffstat (limited to 'src/client/ui')
| -rw-r--r-- | src/client/ui/_common_/header.vue | 84 | ||||
| -rw-r--r-- | src/client/ui/_common_/sidebar.vue | 458 | ||||
| -rw-r--r-- | src/client/ui/chat/index.vue | 12 | ||||
| -rw-r--r-- | src/client/ui/chat/note-header.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/chat/note.sub.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/chat/note.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/chat/post-form.vue | 4 | ||||
| -rw-r--r-- | src/client/ui/chat/side.vue | 4 | ||||
| -rw-r--r-- | src/client/ui/deck.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/deck/column.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/deck/main-column.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/default.side.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/default.sidebar.vue | 362 | ||||
| -rw-r--r-- | src/client/ui/default.vue | 235 | ||||
| -rw-r--r-- | src/client/ui/default.widgets.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/desktop.vue | 2 | ||||
| -rw-r--r-- | src/client/ui/universal.vue | 433 | ||||
| -rw-r--r-- | src/client/ui/universal.widgets.vue | 81 | ||||
| -rw-r--r-- | src/client/ui/zen.vue | 2 |
19 files changed, 1485 insertions, 208 deletions
diff --git a/src/client/ui/_common_/header.vue b/src/client/ui/_common_/header.vue index f150653a84..4c914edbbd 100644 --- a/src/client/ui/_common_/header.vue +++ b/src/client/ui/_common_/header.vue @@ -12,14 +12,16 @@ <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> </div> </div> - <button class="_button action" v-if="info.action" @click.stop="info.action.handler"><Fa :icon="info.action.icon" :key="info.action.icon"/></button> + <button class="_button menu" @click.stop="menu"><Fa :icon="faEllipsisH"/></button> + <!--<button class="_button action" v-if="info.action" @click.stop="info.action.handler"><Fa :icon="info.action.icon" :key="info.action.icon"/></button>--> </template> </div> </template> <script lang="ts"> import { defineComponent } from 'vue'; -import { faChevronLeft, faCircle } from '@fortawesome/free-solid-svg-icons'; +import { faChevronLeft, faCircle, faShareAlt, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; +import { modalMenu } from '@client/os'; export default defineComponent({ props: { @@ -42,7 +44,7 @@ export default defineComponent({ return { canBack: false, height: 0, - faChevronLeft, faCircle + faChevronLeft, faCircle, faShareAlt, faEllipsisH, }; }, @@ -66,6 +68,23 @@ export default defineComponent({ back() { if (this.canBack) this.$router.back(); }, + + share() { + navigator.share(this.info.share); + }, + + menu(ev) { + const menu = this.info.menu ? this.info.menu() : []; + if (this.info.share) { + if (menu.length > 0) menu.push(null); + menu.push({ + text: this.$ts.share, + icon: faShareAlt, + action: this.share + }); + } + modalMenu(menu, ev.currentTarget || ev.target); + } } }); </script> @@ -74,59 +93,33 @@ export default defineComponent({ .fdidabkb { &.center { text-align: center; - } - > .back { - height: var(--height); - width: var(--height); + > .titleContainer { + margin: 0 auto; + } } - > .action { + > .back, + > .menu { + position: absolute; + z-index: 1; + top: 0; height: var(--height); width: var(--height); } - > .titleContainer { - width: calc(100% - (var(--height) * 2)); - - > .title { - height: var(--height); - - > .avatar { - $size: 32px; - margin: calc((var(--height) - #{$size}) / 2) 8px calc((var(--height) - #{$size}) / 2) 0; - pointer-events: none; - } - } - } -} -</style> - -<style lang="scss" scoped> -.fdidabkb { > .back { - position: absolute; - z-index: 1; - top: 0; left: 0; } - > .action { - position: absolute; - z-index: 1; - top: 0; + > .menu { right: 0; } - &.center { - > .titleContainer { - margin: 0 auto; - } - } - > .titleContainer { overflow: auto; white-space: nowrap; + width: calc(100% - (var(--height) * 2)); > .title { display: inline-block; @@ -136,16 +129,7 @@ export default defineComponent({ text-overflow: ellipsis; padding: 0 16px; position: relative; - - > .indicator { - position: absolute; - top: initial; - right: 8px; - top: 8px; - color: var(--indicator); - font-size: 12px; - animation: blink 1s infinite; - } + height: var(--height); > .icon + .text { margin-left: 8px; @@ -157,6 +141,8 @@ export default defineComponent({ width: $size; height: $size; vertical-align: bottom; + margin: calc((var(--height) - #{$size}) / 2) 8px calc((var(--height) - #{$size}) / 2) 0; + pointer-events: none; } } } diff --git a/src/client/ui/_common_/sidebar.vue b/src/client/ui/_common_/sidebar.vue new file mode 100644 index 0000000000..6243d6fcc2 --- /dev/null +++ b/src/client/ui/_common_/sidebar.vue @@ -0,0 +1,458 @@ +<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"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + <MkA class="item index" active-class="active" to="/" exact> + <Fa :icon="faHome" fixed-width/><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"> + <Fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $ts[menuDef[item].title] }}</span> + <i v-if="menuDef[item].indicated"><Fa :icon="faCircle"/></i> + </component> + </template> + <div class="divider"></div> + <button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$i.isAdmin || $i.isModerator" @click="oepnInstanceMenu"> + <Fa :icon="faServer" fixed-width/><span class="text">{{ $ts.instance }}</span> + </button> + <button class="item _button" @click="more"> + <Fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $ts.more }}</span> + <i v-if="otherNavItemIndicated"><Fa :icon="faCircle"/></i> + </button> + <MkA class="item" active-class="active" to="/settings"> + <Fa :icon="faCog" fixed-width/><span class="text">{{ $ts.settings }}</span> + </MkA> + <button class="item _button post" @click="post"> + <Fa :icon="faPencilAlt" fixed-width/><span class="text">{{ $ts.note }}</span> + </button> + </div> + </nav> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram, faStream, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons'; +import { host } from '@client/config'; +import { search } from '@client/scripts/search'; +import * as os from '@client/os'; +import { sidebarDef } from '@client/sidebar'; +import { getAccounts, addAccount, login } from '@client/account'; + +export default defineComponent({ + props: { + defaultHidden: { + type: Boolean, + required: false, + default: false, + } + }, + + data() { + return { + host: host, + showing: false, + accounts: [], + connection: null, + menuDef: sidebarDef, + iconOnly: false, + hidden: this.defaultHidden, + faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.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.sidebarDisplay.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.sidebarDisplay === 'icon'); + if (!this.defaultHidden) { + this.hidden = (window.innerWidth <= 650); + } + }, + + show() { + this.showing = true; + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + async openAccountMenu(ev) { + const storedAccounts = getAccounts().filter(x => x.id !== this.$i.id); + const accountsPromise = os.api('users/show', { userIds: storedAccounts.map(x => x.id) }); + + const accountItemPromises = storedAccounts.map(a => new Promise(res => { + accountsPromise.then(accounts => { + const account = accounts.find(x => x.id === a.id); + if (account == null) return res(null); + res({ + type: 'user', + user: account, + action: () => { this.switchAccount(account); } + }); + }); + })); + + os.modalMenu([...[{ + type: 'link', + text: this.$ts.profile, + to: `/@${ this.$i.username }`, + avatar: this.$i, + }, null, ...accountItemPromises, { + icon: faPlus, + text: this.$ts.addAcount, + action: () => { + os.modalMenu([{ + text: this.$ts.existingAcount, + action: () => { this.addAcount(); }, + }, { + text: this.$ts.createAccount, + action: () => { this.createAccount(); }, + }], ev.currentTarget || ev.target); + }, + }]], ev.currentTarget || ev.target, { + align: 'left' + }); + }, + + oepnInstanceMenu(ev) { + os.modalMenu([{ + type: 'link', + text: this.$ts.dashboard, + to: '/instance', + icon: faTachometerAlt, + }, null, this.$i.isAdmin ? { + type: 'link', + text: this.$ts.settings, + to: '/instance/settings', + icon: faCog, + } : undefined, { + type: 'link', + text: this.$ts.customEmojis, + to: '/instance/emojis', + icon: faLaugh, + }, { + type: 'link', + text: this.$ts.users, + to: '/instance/users', + icon: faUsers, + }, { + type: 'link', + text: this.$ts.files, + to: '/instance/files', + icon: faCloud, + }, { + type: 'link', + text: this.$ts.jobQueue, + to: '/instance/queue', + icon: faExchangeAlt, + }, { + type: 'link', + text: this.$ts.federation, + to: '/instance/federation', + icon: faGlobe, + }, { + type: 'link', + text: this.$ts.relays, + to: '/instance/relays', + icon: faProjectDiagram, + }, { + type: 'link', + text: this.$ts.announcements, + to: '/instance/announcements', + icon: faBroadcastTower, + }, { + type: 'link', + text: this.$ts.abuseReports, + to: '/instance/abuses', + icon: faExclamationCircle, + }, { + type: 'link', + text: this.$ts.logs, + to: '/instance/logs', + icon: faStream, + }], ev.currentTarget || ev.target); + }, + + more(ev) { + os.popup(import('@client/components/launch-pad.vue'), {}, { + }, 'closed'); + }, + + addAcount() { + os.popup(import('@client/components/signin-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + os.success(); + }, + }, 'closed'); + }, + + createAccount() { + os.popup(import('@client/components/signup-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + this.switchAccountWithToken(res.i); + }, + }, 'closed'); + }, + + switchAccount(account: any) { + const storedAccounts = getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + this.switchAccountWithToken(token); + }, + + switchAccountWithToken(token: string) { + login(token); + }, + } +}); +</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; + width: 100%; + text-align: center; + font-size: $ui-font-size * 1.1; + line-height: 3.7rem; + + > [data-icon], + > .avatar { + margin-right: 0; + } + + > i { + left: 10px; + } + + > .text { + display: none; + } + + &: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; + background: var(--navBg); + + > .divider { + margin: 16px 0; + border-top: solid 0.5px var(--divider); + } + + > .item { + position: relative; + display: block; + padding-left: 24px; + font-size: $ui-font-size; + line-height: 3rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > [data-icon] { + width: 32px; + } + + > [data-icon], + > .avatar { + margin-right: $avatar-margin; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > i { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + + &:first-child, &:last-child { + position: sticky; + z-index: 1; + padding-top: 8px; + padding-bottom: 8px; + background: var(--X14); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + } + + &:first-child { + top: 0; + margin-bottom: 16px; + border-bottom: solid 0.5px var(--divider); + } + + &:last-child { + bottom: 0; + margin-top: 16px; + border-top: solid 0.5px var(--divider); + } + } + } + } +} +</style> diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue index 91d3fb5c9d..d5c455d123 100644 --- a/src/client/ui/chat/index.vue +++ b/src/client/ui/chat/index.vue @@ -136,7 +136,7 @@ import { defineComponent, defineAsyncComponent } from 'vue'; import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, faAt, faLink, faEllipsisH, faGlobe } from '@fortawesome/free-solid-svg-icons'; import { faBell, faStar as farStar, faEnvelope, faComments, faCalendarAlt } from '@fortawesome/free-regular-svg-icons'; import { instanceName, url } from '@client/config'; -import XSidebar from '@client/components/sidebar.vue'; +import XSidebar from '@client/ui/_common_/sidebar.vue'; import XWidgets from './widgets.vue'; import XCommon from '../_common_/common.vue'; import XSide from './side.vue'; @@ -354,7 +354,7 @@ export default defineComponent({ flex-direction: column; width: 250px; height: 100vh; - border-right: solid 1px var(--divider); + border-right: solid 0.5px var(--divider); > .header, > .footer { $padding: 8px; @@ -367,11 +367,11 @@ export default defineComponent({ user-select: none; &.header { - border-bottom: solid 1px var(--divider); + border-bottom: solid 0.5px var(--divider); } &.footer { - border-top: solid 1px var(--divider); + border-top: solid 0.5px var(--divider); } > .left, > .right { @@ -526,7 +526,7 @@ export default defineComponent({ padding: $padding; box-sizing: border-box; background-color: var(--panel); - border-bottom: solid 1px var(--divider); + border-bottom: solid 0.5px var(--divider); user-select: none; > .left { @@ -599,7 +599,7 @@ export default defineComponent({ > .side { width: 350px; - border-left: solid 1px var(--divider); + border-left: solid 0.5px var(--divider); &.widgets.sideViewOpening { @media (max-width: 1400px) { diff --git a/src/client/ui/chat/note-header.vue b/src/client/ui/chat/note-header.vue index 55228c4c38..be08183d39 100644 --- a/src/client/ui/chat/note-header.vue +++ b/src/client/ui/chat/note-header.vue @@ -79,7 +79,7 @@ export default defineComponent({ margin: 0 .5em 0 0; padding: 1px 6px; font-size: 80%; - border: solid 1px var(--divider); + border: solid 0.5px var(--divider); border-radius: 3px; } diff --git a/src/client/ui/chat/note.sub.vue b/src/client/ui/chat/note.sub.vue index 6c778d1468..bb528dd936 100644 --- a/src/client/ui/chat/note.sub.vue +++ b/src/client/ui/chat/note.sub.vue @@ -130,7 +130,7 @@ export default defineComponent({ } > .reply { - border-left: solid 1px var(--divider); + border-left: solid 0.5px var(--divider); margin-top: 10px; } } diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue index f6789f214d..3764bbb65a 100644 --- a/src/client/ui/chat/note.vue +++ b/src/client/ui/chat/note.vue @@ -1127,7 +1127,7 @@ export default defineComponent({ } > .reply { - border-top: solid 1px var(--divider); + border-top: solid 0.5px var(--divider); } } diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue index 9dd87edac4..bdf18cf290 100644 --- a/src/client/ui/chat/post-form.vue +++ b/src/client/ui/chat/post-form.vue @@ -615,7 +615,7 @@ export default defineComponent({ <style lang="scss" scoped> .pxiwixjf { position: relative; - border: solid 1px var(--divider); + border: solid 0.5px var(--divider); border-radius: 8px; > .form { @@ -696,7 +696,7 @@ export default defineComponent({ > .cw { z-index: 1; padding-bottom: 8px; - border-bottom: solid 1px var(--divider); + border-bottom: solid 0.5px var(--divider); } > .text { diff --git a/src/client/ui/chat/side.vue b/src/client/ui/chat/side.vue index 2645874ce4..a3c03b6d06 100644 --- a/src/client/ui/chat/side.vue +++ b/src/client/ui/chat/side.vue @@ -117,7 +117,7 @@ export default defineComponent({ .mrajymqm { $header-height: 54px; // TODO: どこかに集約したい - --section-padding: 16px; + --root-margin: 16px; --margin: var(--marginHalf); height: 100%; @@ -137,7 +137,7 @@ export default defineComponent({ -webkit-backdrop-filter: blur(32px); backdrop-filter: blur(32px); background-color: var(--header); - border-bottom: solid 1px var(--divider); + border-bottom: solid 0.5px var(--divider); box-sizing: border-box; > ._button { diff --git a/src/client/ui/deck.vue b/src/client/ui/deck.vue index a63db17b01..0429dbc9b1 100644 --- a/src/client/ui/deck.vue +++ b/src/client/ui/deck.vue @@ -36,7 +36,7 @@ import { } from '@fortawesome/free-regular-svg-icons'; import { v4 as uuid } from 'uuid'; import { host } from '@client/config'; import DeckColumnCore from '@client/ui/deck/column-core.vue'; -import XSidebar from '@client/components/sidebar.vue'; +import XSidebar from '@client/ui/_common_/sidebar.vue'; import { getScrollContainer } from '@client/scripts/scroll'; import * as os from '@client/os'; import { sidebarDef } from '@client/sidebar'; diff --git a/src/client/ui/deck/column.vue b/src/client/ui/deck/column.vue index 6a242c691a..3fae7c27ee 100644 --- a/src/client/ui/deck/column.vue +++ b/src/client/ui/deck/column.vue @@ -265,7 +265,7 @@ export default defineComponent({ <style lang="scss" scoped> .dnpfarvg { - --section-padding: 10px; + --root-margin: 10px; height: 100%; overflow: hidden; diff --git a/src/client/ui/deck/main-column.vue b/src/client/ui/deck/main-column.vue index 4577b0b533..5a8c72d871 100644 --- a/src/client/ui/deck/main-column.vue +++ b/src/client/ui/deck/main-column.vue @@ -4,7 +4,7 @@ <XHeader :info="pageInfo"/> </template> - <router-view v-slot="{ Component }"> + <router-view v-slot="{ Component }" class="_flat_"> <transition> <keep-alive :include="['timeline']"> <component :is="Component" :ref="changePage" @contextmenu.stop="onContextmenu"/> diff --git a/src/client/ui/default.side.vue b/src/client/ui/default.side.vue index 995f987a6a..3a32cb4e13 100644 --- a/src/client/ui/default.side.vue +++ b/src/client/ui/default.side.vue @@ -118,7 +118,7 @@ export default defineComponent({ .qvzfzxam { $header-height: 58px; // TODO: どこかに集約したい - --section-padding: 16px; + --root-margin: 16px; --margin: var(--marginHalf); > .container { diff --git a/src/client/ui/default.sidebar.vue b/src/client/ui/default.sidebar.vue new file mode 100644 index 0000000000..710a9b1f85 --- /dev/null +++ b/src/client/ui/default.sidebar.vue @@ -0,0 +1,362 @@ +<template> +<div class="npcljfve" :class="{ iconOnly }"> + <button class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + <div class="post" @click="post"> + <MkButton class="button" primary full> + <Fa :icon="faPencilAlt" fixed-width/><span class="text" v-if="!iconOnly">{{ $ts.note }}</span> + </MkButton> + </div> + <div class="divider"></div> + <MkA class="item index" active-class="active" to="/" exact> + <Fa :icon="faHome" fixed-width/><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"> + <Fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $ts[menuDef[item].title] }}</span> + <i v-if="menuDef[item].indicated"><Fa :icon="faCircle"/></i> + </component> + </template> + <div class="divider"></div> + <button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$i.isAdmin || $i.isModerator" @click="oepnInstanceMenu"> + <Fa :icon="faServer" fixed-width/><span class="text">{{ $ts.instance }}</span> + </button> + <button class="item _button" @click="more"> + <Fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $ts.more }}</span> + <i v-if="otherNavItemIndicated"><Fa :icon="faCircle"/></i> + </button> + <MkA class="item" active-class="active" to="/settings"> + <Fa :icon="faCog" fixed-width/><span class="text">{{ $ts.settings }}</span> + </MkA> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram, faStream, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons'; +import { host } from '@client/config'; +import { search } from '@client/scripts/search'; +import * as os from '@client/os'; +import { sidebarDef } from '@client/sidebar'; +import { getAccounts, addAccount, login } from '@client/account'; +import MkButton from '@client/components/ui/button.vue'; +import { StickySidebar } from '@client/scripts/sticky-sidebar'; + +export default defineComponent({ + components: { + MkButton + }, + + data() { + return { + host: host, + accounts: [], + connection: null, + menuDef: sidebarDef, + iconOnly: false, + faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.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.sidebarDisplay.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.sidebarDisplay === 'icon'); + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + async openAccountMenu(ev) { + const storedAccounts = getAccounts().filter(x => x.id !== this.$i.id); + const accountsPromise = os.api('users/show', { userIds: storedAccounts.map(x => x.id) }); + + const accountItemPromises = storedAccounts.map(a => new Promise(res => { + accountsPromise.then(accounts => { + const account = accounts.find(x => x.id === a.id); + if (account == null) return res(null); + res({ + type: 'user', + user: account, + action: () => { this.switchAccount(account); } + }); + }); + })); + + os.modalMenu([...[{ + type: 'link', + text: this.$ts.profile, + to: `/@${ this.$i.username }`, + avatar: this.$i, + }, null, ...accountItemPromises, { + icon: faPlus, + text: this.$ts.addAcount, + action: () => { + os.modalMenu([{ + text: this.$ts.existingAcount, + action: () => { this.addAcount(); }, + }, { + text: this.$ts.createAccount, + action: () => { this.createAccount(); }, + }], ev.currentTarget || ev.target); + }, + }]], ev.currentTarget || ev.target, { + align: 'left' + }); + }, + + oepnInstanceMenu(ev) { + os.modalMenu([{ + type: 'link', + text: this.$ts.dashboard, + to: '/instance', + icon: faTachometerAlt, + }, null, this.$i.isAdmin ? { + type: 'link', + text: this.$ts.settings, + to: '/instance/settings', + icon: faCog, + } : undefined, { + type: 'link', + text: this.$ts.customEmojis, + to: '/instance/emojis', + icon: faLaugh, + }, { + type: 'link', + text: this.$ts.users, + to: '/instance/users', + icon: faUsers, + }, { + type: 'link', + text: this.$ts.files, + to: '/instance/files', + icon: faCloud, + }, { + type: 'link', + text: this.$ts.jobQueue, + to: '/instance/queue', + icon: faExchangeAlt, + }, { + type: 'link', + text: this.$ts.federation, + to: '/instance/federation', + icon: faGlobe, + }, { + type: 'link', + text: this.$ts.relays, + to: '/instance/relays', + icon: faProjectDiagram, + }, { + type: 'link', + text: this.$ts.announcements, + to: '/instance/announcements', + icon: faBroadcastTower, + }, { + type: 'link', + text: this.$ts.abuseReports, + to: '/instance/abuses', + icon: faExclamationCircle, + }, { + type: 'link', + text: this.$ts.logs, + to: '/instance/logs', + icon: faStream, + }], ev.currentTarget || ev.target); + }, + + more(ev) { + os.popup(import('@client/components/launch-pad.vue'), {}, { + }, 'closed'); + }, + + addAcount() { + os.popup(import('@client/components/signin-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + os.success(); + }, + }, 'closed'); + }, + + createAccount() { + os.popup(import('@client/components/signup-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + this.switchAccountWithToken(res.i); + }, + }, 'closed'); + }, + + switchAccount(account: any) { + const storedAccounts = getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + this.switchAccountWithToken(token); + }, + + switchAccountWithToken(token: string) { + login(token); + }, + } +}); +</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; + + > [data-icon], + > .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; + } + } + + > .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; + + > [data-icon] { + width: 32px; + } + + > [data-icon], + > .avatar { + margin-right: $avatar-margin; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > i { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + } +} +</style> diff --git a/src/client/ui/default.vue b/src/client/ui/default.vue index 38f98f6365..84d6c01094 100644 --- a/src/client/ui/default.vue +++ b/src/client/ui/default.vue @@ -1,13 +1,15 @@ <template> -<div class="mk-app" :class="{ wallpaper }"> - <XSidebar ref="nav" class="sidebar"/> +<div class="mk-app" :class="{ wallpaper, isMobile }"> + <div class="columns"> + <div class="sidebar" ref="sidebar" v-if="!isMobile"> + <XSidebar/> + </div> - <div class="contents" ref="contents" :class="{ withHeader: $store.state.titlebar }" @contextmenu.stop="onContextmenu"> - <header v-if="$store.state.titlebar" class="header" ref="header" @click="onHeaderClick"> - <XHeader :info="pageInfo"/> - </header> - <main ref="main"> - <div class="content"> + <main class="main _panel" @contextmenu.stop="onContextmenu"> + <header v-if="$store.state.titlebar" class="header" @click="onHeaderClick"> + <XHeader :info="pageInfo"/> + </header> + <div class="content _flat_"> <router-view v-slot="{ Component }"> <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> <keep-alive :include="['timeline']"> @@ -16,26 +18,22 @@ </transition> </router-view> </div> - <div class="spacer"></div> </main> - </div> - - <XSide v-if="isDesktop" class="side" ref="side"/> - <div v-if="isDesktop" class="widgets"> - <div ref="widgetsSpacer"></div> - <XWidgets @mounted="attachSticky"/> + <div v-if="isDesktop" class="widgets" ref="widgets"> + <XWidgets @mounted="attachSticky"/> + </div> </div> - <div class="buttons" :class="{ navHidden }"> - <button class="button nav _button" @click="showNav" ref="navButton"><Fa :icon="faBars"/><i v-if="navIndicated"><Fa :icon="faCircle"/></i></button> + <div class="buttons" v-if="isMobile"> + <button class="button nav _button" @click="showDrawerNav" ref="navButton"><Fa :icon="faBars"/><i v-if="navIndicated"><Fa :icon="faCircle"/></i></button> <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><Fa :icon="faHome"/></button> <button class="button notifications _button" @click="$router.push('/my/notifications')"><Fa :icon="faBell"/><i v-if="$i.hasUnreadNotification"><Fa :icon="faCircle"/></i></button> <button class="button widget _button" @click="widgetsShowing = true"><Fa :icon="faLayerGroup"/></button> <button class="button post _button" @click="post"><Fa :icon="faPencilAlt"/></button> </div> - <button class="widgetButton _button" :class="{ navHidden }" @click="widgetsShowing = true"><Fa :icon="faLayerGroup"/></button> + <XDrawerSidebar ref="drawerNav" class="sidebar" v-if="isMobile"/> <transition name="tray-back"> <div class="tray-back _modalBg" @@ -59,38 +57,31 @@ import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, fa import { faBell } from '@fortawesome/free-regular-svg-icons'; import { instanceName } from '@client/config'; import { StickySidebar } from '@client/scripts/sticky-sidebar'; -import XSidebar from '@client/components/sidebar.vue'; +import XSidebar from './default.sidebar.vue'; +import XDrawerSidebar from '@client/ui/_common_/sidebar.vue'; import XCommon from './_common_/common.vue'; import XHeader from './_common_/header.vue'; -import XSide from './default.side.vue'; import * as os from '@client/os'; import { sidebarDef } from '@client/sidebar'; const DESKTOP_THRESHOLD = 1100; +const MOBILE_THRESHOLD = 600; export default defineComponent({ components: { XCommon, XSidebar, + XDrawerSidebar, XHeader, XWidgets: defineAsyncComponent(() => import('./default.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: sidebarDef, - navHidden: false, + isMobile: window.innerWidth <= MOBILE_THRESHOLD, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, widgetsShowing: false, wallpaper: localStorage.getItem('wallpaper') != null, faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, @@ -125,21 +116,10 @@ export default defineComponent({ }, 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 }); - } + window.addEventListener('resize', () => { + this.isMobile = (window.innerWidth <= MOBILE_THRESHOLD); + this.isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD); + }, { passive: true }); }, methods: { @@ -151,20 +131,8 @@ export default defineComponent({ } }, - adjustUI() { - const navWidth = this.$refs.nav.$el.offsetWidth; - this.navHidden = navWidth === 0; - if (this.$refs.contents == null) return; - const width = this.$refs.contents.offsetWidth; - if (this.$refs.header) this.$refs.header.style.width = `${width}px`; - }, - - showNav() { - this.$refs.nav.show(); - }, - - attachSticky(el) { - const sticky = new StickySidebar(el, this.$refs.widgetsSpacer); + attachSticky() { + const sticky = new StickySidebar(this.$refs.widgets, 16); window.addEventListener('scroll', () => { sticky.calc(window.scrollY); }, { passive: true }); @@ -178,6 +146,10 @@ export default defineComponent({ window.scroll({ top: 0, behavior: 'smooth' }); }, + showDrawerNav() { + this.$refs.drawerNav.show(); + }, + onTransition() { if (window._scroll) window._scroll(); }, @@ -201,12 +173,6 @@ export default defineComponent({ type: 'label', text: path, }, { - icon: faColumns, - text: this.$ts.openInSideView, - action: () => { - this.$refs.side.navigate(path); - } - }, { icon: faWindowMaximize, text: this.$ts.openInWindow, action: () => { @@ -242,99 +208,98 @@ export default defineComponent({ } .mk-app { - $header-height: 58px; // TODO: どこかに集約したい - $ui-font-size: 1em; // TODO: どこかに集約したい - $widgets-hide-threshold: 1090px; + $header-height: 50px; + $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; - display: flex; &.wallpaper { background: var(--wallpaperOverlay); //backdrop-filter: blur(4px); } - > .contents { - width: 100%; - min-width: 0; + &.isMobile { + > .columns { + display: block; + margin: 0; - &.withHeader { - padding-top: $header-height; - } + > .main { + margin: 0; + border: none; + width: 100%; + border-radius: 0; - > .header { - position: fixed; - 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: blur(32px); - backdrop-filter: blur(32px); - background-color: var(--header); - //border-bottom: solid 1px var(--divider); - user-select: none; + > .header { + width: 100%; + } + } } + } + + > .columns { + display: flex; + justify-content: center; + max-width: 100%; + margin: 32px 0; + + > .main { + width: 750px; + margin: 0 16px 0 0; + background: var(--bg); + --margin: 12px; - > main { - min-width: 0; + > .header { + position: sticky; + z-index: 1000; + top: 0; + height: $header-height; + line-height: $header-height; + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: solid 0.5px var(--divider); + } > .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}); - } + background: var(--bg); + --stickyTop: #{$header-height}; } - > .spacer { - height: 82px; + @media (max-width: 850px) { + padding-top: $header-height; - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; + > .header { + position: fixed; + width: calc(100% - #{$nav-icon-only-width}); } } } - } - - > .side { - min-width: 370px; - max-width: 370px; - border-left: solid 1px var(--divider); - } - > .widgets { - padding: 0 var(--margin); - border-left: solid 1px var(--divider); + > .widgets { + //--panelShadow: none; - @media (max-width: $widgets-hide-threshold) { - display: none; + @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); + @media (max-width: 850px) { + margin: 0; - &.navHidden { - display: none; - } + > .sidebar { + border-right: solid 0.5px var(--divider); + } - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; + > .main { + margin: 0; + border-radius: 0; + box-shadow: none; + width: 100%; + } } } @@ -349,10 +314,7 @@ export default defineComponent({ -webkit-backdrop-filter: blur(32px); backdrop-filter: blur(32px); background-color: var(--header); - - &:not(.navHidden) { - display: none; - } + border-top: solid 0.5px var(--divider); > .button { position: relative; @@ -429,6 +391,3 @@ export default defineComponent({ } } </style> - -<style lang="scss"> -</style> diff --git a/src/client/ui/default.widgets.vue b/src/client/ui/default.widgets.vue index 35d3442bb2..b12de841a7 100644 --- a/src/client/ui/default.widgets.vue +++ b/src/client/ui/default.widgets.vue @@ -61,8 +61,6 @@ export default defineComponent({ .efzpzdvf { position: sticky; height: min-content; - min-height: 100vh; - padding: var(--margin) 0; box-sizing: border-box; > * { diff --git a/src/client/ui/desktop.vue b/src/client/ui/desktop.vue index 1480fd1840..a60aed6841 100644 --- a/src/client/ui/desktop.vue +++ b/src/client/ui/desktop.vue @@ -12,7 +12,7 @@ import { host } from '@client/config'; import { search } from '@client/scripts/search'; import XCommon from './_common_/common.vue'; import * as os from '@client/os'; -import XSidebar from '@client/components/sidebar.vue'; +import XSidebar from '@client/ui/_common_/sidebar.vue'; import { sidebarDef } from '@client/sidebar'; import { ColdDeviceStorage } from '@client/store'; diff --git a/src/client/ui/universal.vue b/src/client/ui/universal.vue new file mode 100644 index 0000000000..e1b368c25a --- /dev/null +++ b/src/client/ui/universal.vue @@ -0,0 +1,433 @@ +<template> +<div class="mk-app" :class="{ wallpaper }"> + <XSidebar ref="nav" class="sidebar"/> + + <div class="contents" ref="contents" :class="{ withHeader: $store.state.titlebar }" @contextmenu.stop="onContextmenu"> + <header v-if="$store.state.titlebar" class="header" ref="header" @click="onHeaderClick"> + <XHeader :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> + <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"><Fa :icon="faBars"/><i v-if="navIndicated"><Fa :icon="faCircle"/></i></button> + <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><Fa :icon="faHome"/></button> + <button class="button notifications _button" @click="$router.push('/my/notifications')"><Fa :icon="faBell"/><i v-if="$i.hasUnreadNotification"><Fa :icon="faCircle"/></i></button> + <button class="button widget _button" @click="widgetsShowing = true"><Fa :icon="faLayerGroup"/></button> + <button class="button post _button" @click="post"><Fa :icon="faPencilAlt"/></button> + </div> + + <button class="widgetButton _button" :class="{ navHidden }" @click="widgetsShowing = true"><Fa :icon="faLayerGroup"/></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 { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; +import { faBell } from '@fortawesome/free-regular-svg-icons'; +import { instanceName } from '@client/config'; +import { StickySidebar } from '@client/scripts/sticky-sidebar'; +import XSidebar from '@client/ui/_common_/sidebar.vue'; +import XCommon from './_common_/common.vue'; +import XHeader from './_common_/header.vue'; +import XSide from './default.side.vue'; +import * as os from '@client/os'; +import { sidebarDef } from '@client/sidebar'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XCommon, + XSidebar, + XHeader, + 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: sidebarDef, + navHidden: false, + widgetsShowing: false, + wallpaper: localStorage.getItem('wallpaper') != null, + faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, + }; + }, + + 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.INFO) { + this.pageInfo = page.INFO; + document.title = `${this.pageInfo.title} | ${instanceName}`; + } + }, + + adjustUI() { + const navWidth = this.$refs.nav.$el.offsetWidth; + this.navHidden = navWidth === 0; + if (this.$refs.contents == null) return; + const width = this.$refs.contents.offsetWidth; + if (this.$refs.header) this.$refs.header.style.width = `${width}px`; + }, + + 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' }); + }, + + 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'].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: faColumns, + text: this.$ts.openInSideView, + action: () => { + this.$refs.side.navigate(path); + } + }, { + icon: faWindowMaximize, + 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 { + $header-height: 58px; // TODO: どこかに集約したい + $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: blur(4px); + } + + > .contents { + width: 100%; + min-width: 0; + + &.withHeader { + padding-top: $header-height; + } + + > .header { + position: fixed; + 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: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + //border-bottom: solid 0.5px var(--divider); + user-select: none; + } + + > main { + min-width: 0; + + > .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}); + } + } + + > .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); + + @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: blur(32px); + backdrop-filter: 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); + } + + > i { + 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> diff --git a/src/client/ui/universal.widgets.vue b/src/client/ui/universal.widgets.vue new file mode 100644 index 0000000000..35d3442bb2 --- /dev/null +++ b/src/client/ui/universal.widgets.vue @@ -0,0 +1,81 @@ +<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;"><Fa :icon="faCheck"/> {{ $ts.editWidgetsExit }}</button> + <button v-else @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faPencilAlt"/> {{ $ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faPencilAlt, faPlus, faBars, faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; +import XWidgets from '@client/components/widgets.vue'; +import * as os from '@client/os'; + +export default defineComponent({ + components: { + XWidgets + }, + + emits: ['mounted'], + + data() { + return { + editMode: false, + faPencilAlt, faPlus, faBars, faTimes, faCheck, + }; + }, + + 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> diff --git a/src/client/ui/zen.vue b/src/client/ui/zen.vue index 9215a639b0..321eb1a025 100644 --- a/src/client/ui/zen.vue +++ b/src/client/ui/zen.vue @@ -94,7 +94,7 @@ export default defineComponent({ -webkit-backdrop-filter: blur(32px); backdrop-filter: blur(32px); background-color: var(--header); - border-bottom: solid 1px var(--divider); + border-bottom: solid 0.5px var(--divider); } > main { |