diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-10-16 19:55:44 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-10-16 19:55:44 +0900 |
| commit | 8a1f3a4c0b5732d0f08f0788d93c5934de8960c8 (patch) | |
| tree | be6fbcf3a1bbd78306d91e19ef6f3e7023f41561 /src/client/components | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.92.0 (diff) | |
| download | misskey-8a1f3a4c0b5732d0f08f0788d93c5934de8960c8.tar.gz misskey-8a1f3a4c0b5732d0f08f0788d93c5934de8960c8.tar.bz2 misskey-8a1f3a4c0b5732d0f08f0788d93c5934de8960c8.zip | |
Merge branch 'develop'
Diffstat (limited to 'src/client/components')
84 files changed, 2674 insertions, 1692 deletions
diff --git a/src/client/components/abuse-report-window.vue b/src/client/components/abuse-report-window.vue index 266c0d566f..21a19385ae 100644 --- a/src/client/components/abuse-report-window.vue +++ b/src/client/components/abuse-report-window.vue @@ -25,7 +25,7 @@ <script lang="ts"> import { defineComponent, markRaw } from 'vue'; import XWindow from '@client/components/ui/window.vue'; -import MkTextarea from '@client/components/ui/textarea.vue'; +import MkTextarea from '@client/components/form/textarea.vue'; import MkButton from '@client/components/ui/button.vue'; import * as os from '@client/os'; diff --git a/src/client/components/autocomplete.vue b/src/client/components/autocomplete.vue index 065ee6de2e..e621b26229 100644 --- a/src/client/components/autocomplete.vue +++ b/src/client/components/autocomplete.vue @@ -10,12 +10,12 @@ </li> <li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $ts.selectUser }}</li> </ol> - <ol class="hashtags" ref="suggests" v-if="hashtags.length > 0"> + <ol class="hashtags" ref="suggests" v-else-if="hashtags.length > 0"> <li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1"> <span class="name">{{ hashtag }}</span> </li> </ol> - <ol class="emojis" ref="suggests" v-if="emojis.length > 0"> + <ol class="emojis" ref="suggests" v-else-if="emojis.length > 0"> <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> <span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> <span class="emoji" v-else-if="!$store.state.useOsNativeEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span> @@ -24,6 +24,11 @@ <span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span> </li> </ol> + <ol class="mfmTags" ref="suggests" v-else-if="mfmTags.length > 0"> + <li v-for="tag in mfmTags" @click="complete(type, tag)" @keydown="onKeydown" tabindex="-1"> + <span class="tag">{{ tag }}</span> + </li> + </ol> </div> </template> @@ -106,6 +111,8 @@ emojiDefinitions.sort((a, b) => a.name.length - b.name.length); const emojiDb = markRaw(emojiDefinitions.concat(emjdb)); //#endregion +const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle']; + export default defineComponent({ props: { type: { @@ -137,11 +144,6 @@ export default defineComponent({ type: Number, required: true, }, - - showing: { - type: Boolean, - required: true - }, }, emits: ['done', 'closed'], @@ -154,18 +156,11 @@ export default defineComponent({ hashtags: [], emojis: [], items: [], + mfmTags: [], select: -1, } }, - watch: { - showing() { - if (!this.showing) { - this.$emit('closed'); - } - } - }, - updated() { this.setPosition(); this.items = (this.$refs.suggests as Element | undefined)?.children || []; @@ -236,7 +231,7 @@ export default defineComponent({ } } - if (this.type == 'user') { + if (this.type === 'user') { if (this.q == null) { this.users = []; this.fetching = false; @@ -262,7 +257,7 @@ export default defineComponent({ sessionStorage.setItem(cacheKey, JSON.stringify(users)); }); } - } else if (this.type == 'hashtag') { + } else if (this.type === 'hashtag') { if (this.q == null || this.q == '') { this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]'); this.fetching = false; @@ -286,7 +281,7 @@ export default defineComponent({ }); } } - } else if (this.type == 'emoji') { + } else if (this.type === 'emoji') { if (this.q == null || this.q == '') { // 最近使った絵文字をサジェスト this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null); @@ -314,6 +309,13 @@ export default defineComponent({ } this.emojis = matched; + } else if (this.type === 'mfmTag') { + if (this.q == null || this.q == '') { + this.mfmTags = MFM_TAGS; + return; + } + + this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q)); } }, @@ -490,5 +492,11 @@ export default defineComponent({ margin: 0 0 0 8px; } } + + > .mfmTags > li { + + .name { + } + } } </style> diff --git a/src/client/components/captcha.vue b/src/client/components/captcha.vue index 5da8ede3b9..baa922506e 100644 --- a/src/client/components/captcha.vue +++ b/src/client/components/captcha.vue @@ -39,7 +39,7 @@ export default defineComponent({ type: String, required: true, }, - value: { + modelValue: { type: String, }, }, @@ -116,7 +116,7 @@ export default defineComponent({ } }, callback(response?: string) { - this.$emit('update:value', typeof response == 'string' ? response : null); + this.$emit('update:modelValue', typeof response == 'string' ? response : null); }, }, }); diff --git a/src/client/components/channel-follow-button.vue b/src/client/components/channel-follow-button.vue index 6f9405b97f..bd8627f6e8 100644 --- a/src/client/components/channel-follow-button.vue +++ b/src/client/components/channel-follow-button.vue @@ -91,7 +91,7 @@ export default defineComponent({ width: 31px; } - &:focus { + &:focus-visible { &:after { content: ""; pointer-events: none; diff --git a/src/client/components/cw-button.vue b/src/client/components/cw-button.vue index d2336085ad..3a172f5d5e 100644 --- a/src/client/components/cw-button.vue +++ b/src/client/components/cw-button.vue @@ -1,7 +1,7 @@ <template> <button class="nrvgflfu _button" @click="toggle"> - <b>{{ value ? $ts._cw.hide : $ts._cw.show }}</b> - <span v-if="!value">{{ label }}</span> + <b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b> + <span v-if="!modelValue">{{ label }}</span> </button> </template> @@ -12,7 +12,7 @@ import { concat } from '../../prelude/array'; export default defineComponent({ props: { - value: { + modelValue: { type: Boolean, required: true }, @@ -36,7 +36,7 @@ export default defineComponent({ length, toggle() { - this.$emit('update:value', !this.value); + this.$emit('update:modelValue', !this.modelValue); } } }); diff --git a/src/client/components/form/base.vue b/src/client/components/debobigego/base.vue index 132942d527..f551a3478b 100644 --- a/src/client/components/form/base.vue +++ b/src/client/components/debobigego/base.vue @@ -21,39 +21,39 @@ export default defineComponent({ <style lang="scss" scoped> .rbusrurv { // 他のCSSからも参照されるので消さないように - --formXPadding: 32px; - --formYPadding: 32px; + --debobigegoXPadding: 32px; + --debobigegoYPadding: 32px; - --formContentHMargin: 16px; + --debobigegoContentHMargin: 16px; font-size: 95%; line-height: 1.3em; background: var(--bg); - padding: var(--formYPadding) var(--formXPadding); + padding: var(--debobigegoYPadding) var(--debobigegoXPadding); max-width: 750px; margin: 0 auto; &:not(.wide).max-width_400px { - --formXPadding: 0px; + --debobigegoXPadding: 0px; > ::v-deep(*) { - ._formPanel { + ._debobigegoPanel { border: solid 0.5px var(--divider); border-radius: 0; border-left: none; border-right: none; } - ._form_group { - > *:not(._formNoConcat) { - &:not(:last-child):not(._formNoConcatPrev) { - &._formPanel, ._formPanel { + ._debobigego_group { + > *:not(._debobigegoNoConcat) { + &:not(:last-child):not(._debobigegoNoConcatPrev) { + &._debobigegoPanel, ._debobigegoPanel { border-bottom: solid 0.5px var(--divider); } } - &:not(:first-child):not(._formNoConcatNext) { - &._formPanel, ._formPanel { + &:not(:first-child):not(._debobigegoNoConcatNext) { + &._debobigegoPanel, ._debobigegoPanel { border-top: none; } } diff --git a/src/client/components/form/button.vue b/src/client/components/debobigego/button.vue index b4f0890945..b883e817a4 100644 --- a/src/client/components/form/button.vue +++ b/src/client/components/debobigego/button.vue @@ -1,7 +1,7 @@ <template> -<div class="yzpgjkxe _formItem"> - <div class="_formLabel"><slot name="label"></slot></div> - <button class="main _button _formPanel _formClickable" :class="{ center, primary, danger }"> +<div class="yzpgjkxe _debobigegoItem"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <button class="main _button _debobigegoPanel _debobigegoClickable" :class="{ center, primary, danger }"> <slot></slot> <div class="suffix"> <slot name="suffix"></slot> @@ -10,13 +10,13 @@ </div> </div> </button> - <div class="_formCaption"><slot name="desc"></slot></div> + <div class="_debobigegoCaption"><slot name="desc"></slot></div> </div> </template> <script lang="ts"> import { defineComponent } from 'vue'; -import './form.scss'; +import './debobigego.scss'; export default defineComponent({ props: { diff --git a/src/client/components/form/form.scss b/src/client/components/debobigego/debobigego.scss index 00f40df9b1..833b656b66 100644 --- a/src/client/components/form/form.scss +++ b/src/client/components/debobigego/debobigego.scss @@ -1,9 +1,9 @@ -._formPanel { +._debobigegoPanel { background: var(--panel); border-radius: var(--radius); transition: background 0.2s ease; - &._formClickable { + &._debobigegoClickable { &:hover { //background: var(--panelHighlight); } @@ -15,8 +15,8 @@ } } -._formLabel, -._formCaption { +._debobigegoLabel, +._debobigegoCaption { font-size: 80%; color: var(--fgTransparentWeak); @@ -25,28 +25,28 @@ } } -._formLabel { +._debobigegoLabel { position: sticky; top: var(--stickyTop, 0px); z-index: 2; - margin: -8px calc(var(--formXPadding) * -1) 0 calc(var(--formXPadding) * -1); - padding: 8px calc(var(--formContentHMargin) + var(--formXPadding)) 8px calc(var(--formContentHMargin) + var(--formXPadding)); + margin: -8px calc(var(--debobigegoXPadding) * -1) 0 calc(var(--debobigegoXPadding) * -1); + padding: 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)) 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)); background: var(--X17); -webkit-backdrop-filter: var(--blur, blur(10px)); backdrop-filter: var(--blur, blur(10px)); } -._themeChanging_ ._formLabel { +._themeChanging_ ._debobigegoLabel { transition: none !important; background: transparent; } -._formCaption { - padding: 8px var(--formContentHMargin) 0 var(--formContentHMargin); +._debobigegoCaption { + padding: 8px var(--debobigegoContentHMargin) 0 var(--debobigegoContentHMargin); } -._formItem { - & + ._formItem { +._debobigegoItem { + & + ._debobigegoItem { margin-top: 24px; } } diff --git a/src/client/components/form/group.vue b/src/client/components/debobigego/group.vue index 34ccaeff07..cba2c6ec94 100644 --- a/src/client/components/form/group.vue +++ b/src/client/components/debobigego/group.vue @@ -1,10 +1,10 @@ <template> -<div class="vrtktovg _formItem _formNoConcat" v-size="{ max: [500] }" v-sticky-container> - <div class="_formLabel"><slot name="label"></slot></div> - <div class="main _form_group" ref="child"> +<div class="vrtktovg _debobigegoItem _debobigegoNoConcat" v-size="{ max: [500] }" v-sticky-container> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="main _debobigego_group" ref="child"> <slot></slot> </div> - <div class="_formCaption"><slot name="caption"></slot></div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> </div> </template> @@ -20,9 +20,9 @@ export default defineComponent({ const els = Array.from(child.value.children); for (let i = 0; i < els.length; i++) { const el = els[i]; - if (el.classList.contains('_formNoConcat')) { - if (els[i - 1]) els[i - 1].classList.add('_formNoConcatPrev'); - if (els[i + 1]) els[i + 1].classList.add('_formNoConcatNext'); + if (el.classList.contains('_debobigegoNoConcat')) { + if (els[i - 1]) els[i - 1].classList.add('_debobigegoNoConcatPrev'); + if (els[i + 1]) els[i + 1].classList.add('_debobigegoNoConcatNext'); } } }; @@ -52,21 +52,21 @@ export default defineComponent({ <style lang="scss" scoped> .vrtktovg { > .main { - > ::v-deep(*):not(._formNoConcat) { - &:not(._formNoConcatNext) { + > ::v-deep(*):not(._debobigegoNoConcat) { + &:not(._debobigegoNoConcatNext) { margin: 0; } - &:not(:last-child):not(._formNoConcatPrev) { - &._formPanel, ._formPanel { + &:not(:last-child):not(._debobigegoNoConcatPrev) { + &._debobigegoPanel, ._debobigegoPanel { border-bottom: solid 0.5px var(--divider); border-bottom-left-radius: 0; border-bottom-right-radius: 0; } } - &:not(:first-child):not(._formNoConcatNext) { - &._formPanel, ._formPanel { + &:not(:first-child):not(._debobigegoNoConcatNext) { + &._debobigegoPanel, ._debobigegoPanel { border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; diff --git a/src/client/components/form/info.vue b/src/client/components/debobigego/info.vue index 9fdcbdca62..41afb03304 100644 --- a/src/client/components/form/info.vue +++ b/src/client/components/debobigego/info.vue @@ -1,6 +1,6 @@ <template> -<div class="fzenkabp _formItem"> - <div class="_formPanel" :class="{ warn }"> +<div class="fzenkabp _debobigegoItem"> + <div class="_debobigegoPanel" :class="{ warn }"> <i v-if="warn" class="fas fa-exclamation-triangle"></i> <i v-else class="fas fa-info-circle"></i> <slot></slot> diff --git a/src/client/components/ui/input.vue b/src/client/components/debobigego/input.vue index a916a0b035..d113f04d27 100644 --- a/src/client/components/ui/input.vue +++ b/src/client/components/debobigego/input.vue @@ -1,49 +1,53 @@ <template> -<div class="matxzzsk"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ inline, disabled, focused }"> - <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> - <input ref="inputEl" - :type="type" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - :step="step" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - :list="id" - > - <datalist :id="id" v-if="datalist"> - <option v-for="data in datalist" :value="data"/> - </datalist> - <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> +<FormGroup class="_debobigegoItem"> + <template #label><slot></slot></template> + <div class="ztzhwixg _debobigegoItem" :class="{ inline, disabled }"> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _debobigegoPanel"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> + </div> </div> - <div class="caption"><slot name="caption"></slot></div> + <template #caption><slot name="desc"></slot></template> - <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> -</div> + <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> +</FormGroup> </template> <script lang="ts"> import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from './button.vue'; -import { debounce } from 'throttle-debounce'; +import './debobigego.scss'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; export default defineComponent({ components: { - MkButton, + FormGroup, + FormButton, }, - props: { modelValue: { - required: true + required: false }, type: { type: String, @@ -92,20 +96,13 @@ export default defineComponent({ required: false, default: false }, - debounce: { - type: Boolean, - required: false, - default: false - }, manualSave: { type: Boolean, required: false, default: false }, }, - emits: ['change', 'keydown', 'enter', 'update:modelValue'], - setup(props, context) { const { modelValue, type, autofocus } = toRefs(props); const v = ref(modelValue.value); @@ -140,19 +137,13 @@ export default defineComponent({ } }; - const debouncedUpdated = debounce(1000, updated); - - watch(modelValue, newValue => { + watch(modelValue.value, newValue => { v.value = newValue; }); watch(v, newValue => { if (!props.manualSave) { - if (props.debounce) { - debouncedUpdated(); - } else { - updated(); - } + updated(); } invalid.value = inputEl.value.validity.badInput; @@ -205,68 +196,59 @@ export default defineComponent({ </script> <style lang="scss" scoped> -.matxzzsk { - margin: 1.5em 0; +.ztzhwixg { + position: relative; - > .label { - font-size: 0.85em; - padding: 0 0 8px 12px; - user-select: none; + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; - &:empty { - display: none; - } - } - - > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; - color: var(--fgTransparentWeak); - - &:empty { - display: none; + &:not(:empty) + .input { + margin-left: 28px; } } > .input { - $height: 42px; + $height: 48px; position: relative; > input { - appearance: none; - -webkit-appearance: none; display: block; height: $height; width: 100%; margin: 0; - padding: 0 12px; + padding: 0 16px; font: inherit; font-weight: normal; font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 0.5px var(--inputBorder); - border-radius: 6px; + line-height: $height; + color: var(--inputText); + background: transparent; + border: none; + border-radius: 0; outline: none; box-shadow: none; box-sizing: border-box; - transition: border-color 0.1s ease-out; - &:hover { - border-color: var(--inputBorderHover); + &[type='file'] { + display: none; } } > .prefix, > .suffix { - display: flex; - align-items: center; + display: block; position: absolute; z-index: 1; top: 0; - padding: 0 12px; + padding: 0 16px; font-size: 1em; - height: $height; + line-height: $height; + color: var(--inputLabel); pointer-events: none; &:empty { @@ -285,32 +267,25 @@ export default defineComponent({ > .prefix { left: 0; - padding-right: 6px; + padding-right: 8px; } > .suffix { right: 0; - padding-left: 6px; - } - - &.inline { - display: inline-block; - margin: 0; + padding-left: 8px; } + } - &.focused { - > input { - border-color: var(--accent); - //box-shadow: 0 0 0 4px var(--focus); - } - } + &.inline { + display: inline-block; + margin: 0; + } - &.disabled { - opacity: 0.7; + &.disabled { + opacity: 0.7; - &, * { - cursor: not-allowed !important; - } + &, * { + cursor: not-allowed !important; } } } diff --git a/src/client/components/form/key-value-view.vue b/src/client/components/debobigego/key-value-view.vue index ca4c09867f..0e034a2d54 100644 --- a/src/client/components/form/key-value-view.vue +++ b/src/client/components/debobigego/key-value-view.vue @@ -1,6 +1,6 @@ <template> -<div class="_formItem"> - <div class="_formPanel anocepby"> +<div class="_debobigegoItem"> + <div class="_debobigegoPanel anocepby"> <span class="key"><slot name="key"></slot></span> <span class="value"><slot name="value"></slot></span> </div> @@ -9,7 +9,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import './form.scss'; +import './debobigego.scss'; export default defineComponent({ @@ -20,7 +20,7 @@ export default defineComponent({ .anocepby { display: flex; align-items: center; - padding: 14px var(--formContentHMargin); + padding: 14px var(--debobigegoContentHMargin); > .key { margin-right: 12px; diff --git a/src/client/components/form/link.vue b/src/client/components/debobigego/link.vue index e1d13c6431..885579eadf 100644 --- a/src/client/components/form/link.vue +++ b/src/client/components/debobigego/link.vue @@ -1,6 +1,6 @@ <template> -<div class="qmfkfnzi _formItem"> - <a class="main _button _formPanel _formClickable" :href="to" target="_blank" v-if="external"> +<div class="qmfkfnzi _debobigegoItem"> + <a class="main _button _debobigegoPanel _debobigegoClickable" :href="to" target="_blank" v-if="external"> <span class="icon"><slot name="icon"></slot></span> <span class="text"><slot></slot></span> <span class="right"> @@ -8,7 +8,7 @@ <i class="fas fa-external-link-alt icon"></i> </span> </a> - <MkA class="main _button _formPanel _formClickable" :class="{ active }" :to="to" :behavior="behavior" v-else> + <MkA class="main _button _debobigegoPanel _debobigegoClickable" :class="{ active }" :to="to" :behavior="behavior" v-else> <span class="icon"><slot name="icon"></slot></span> <span class="text"><slot></slot></span> <span class="right"> @@ -21,7 +21,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import './form.scss'; +import './debobigego.scss'; export default defineComponent({ props: { diff --git a/src/client/components/form/object-view.vue b/src/client/components/debobigego/object-view.vue index 59fb62b5e6..ea79daa915 100644 --- a/src/client/components/form/object-view.vue +++ b/src/client/components/debobigego/object-view.vue @@ -1,8 +1,8 @@ <template> -<FormGroup class="_formItem"> +<FormGroup class="_debobigegoItem"> <template #label><slot></slot></template> - <div class="drooglns _formItem" :class="{ tall }"> - <div class="input _formPanel"> + <div class="drooglns _debobigegoItem" :class="{ tall }"> + <div class="input _debobigegoPanel"> <textarea class="_monospace" v-model="v" readonly @@ -17,7 +17,7 @@ <script lang="ts"> import { defineComponent, ref, toRefs, watch } from 'vue'; import * as JSON5 from 'json5'; -import './form.scss'; +import './debobigego.scss'; import FormGroup from './group.vue'; export default defineComponent({ @@ -75,7 +75,7 @@ export default defineComponent({ max-width: 100%; min-height: 130px; margin: 0; - padding: 16px var(--formContentHMargin); + padding: 16px var(--debobigegoContentHMargin); box-sizing: border-box; font: inherit; font-weight: normal; diff --git a/src/client/components/form/pagination.vue b/src/client/components/debobigego/pagination.vue index 0a2f1ff0e1..2166f5065f 100644 --- a/src/client/components/form/pagination.vue +++ b/src/client/components/debobigego/pagination.vue @@ -1,5 +1,5 @@ <template> -<FormGroup class="uljviswt _formItem"> +<FormGroup class="uljviswt _debobigegoItem"> <template #label><slot name="label"></slot></template> <slot :items="items"></slot> <div class="empty" v-if="empty" key="_empty_"> diff --git a/src/client/components/debobigego/radios.vue b/src/client/components/debobigego/radios.vue new file mode 100644 index 0000000000..071c013afb --- /dev/null +++ b/src/client/components/debobigego/radios.vue @@ -0,0 +1,112 @@ +<script lang="ts"> +import { defineComponent, h } from 'vue'; +import MkRadio from '@client/components/form/radio.vue'; +import './debobigego.scss'; + +export default defineComponent({ + components: { + MkRadio + }, + props: { + modelValue: { + required: false + }, + }, + data() { + return { + value: this.modelValue, + } + }, + watch: { + modelValue() { + this.value = this.modelValue; + }, + value() { + this.$emit('update:modelValue', this.value); + } + }, + render() { + const label = this.$slots.desc(); + let options = this.$slots.default(); + + // なぜかFragmentになることがあるため + if (options.length === 1 && options[0].props == null) options = options[0].children; + + return h('div', { + class: 'cnklmpwm _debobigegoItem' + }, [ + h('div', { + class: '_debobigegoLabel', + }, label), + ...options.map(option => h('button', { + class: '_button _debobigegoPanel _debobigegoClickable', + key: option.key, + onClick: () => this.value = option.props.value, + }, [h('span', { + class: ['check', { checked: this.value === option.props.value }], + }), option.children])) + ]); + } +}); +</script> + +<style lang="scss"> +.cnklmpwm { + > button { + display: block; + width: 100%; + box-sizing: border-box; + padding: 14px 18px; + text-align: left; + + &:not(:first-of-type) { + border-top: none !important; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &:not(:last-of-type) { + border-bottom: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + > .check { + display: inline-block; + vertical-align: bottom; + position: relative; + width: 16px; + height: 16px; + margin-right: 8px; + background: none; + border: 2px solid var(--inputBorder); + border-radius: 100%; + transition: inherit; + + &:after { + content: ""; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: .4s cubic-bezier(.25,.8,.25,1); + } + + &.checked { + border-color: var(--accent); + + &:after { + background-color: var(--accent); + transform: scale(1); + opacity: 1; + } + } + } + } +} +</style> diff --git a/src/client/components/debobigego/range.vue b/src/client/components/debobigego/range.vue new file mode 100644 index 0000000000..26fb0f37c6 --- /dev/null +++ b/src/client/components/debobigego/range.vue @@ -0,0 +1,122 @@ +<template> +<div class="ifitouly _debobigegoItem" :class="{ focused, disabled }"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="_debobigegoPanel main"> + <input + type="range" + ref="input" + v-model="v" + :disabled="disabled" + :min="min" + :max="max" + :step="step" + @focus="focused = true" + @blur="focused = false" + @input="$emit('update:value', $event.target.value)" + /> + </div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: false, + default: 0 + }, + disabled: { + type: Boolean, + required: false, + default: false + }, + min: { + type: Number, + required: false, + default: 0 + }, + max: { + type: Number, + required: false, + default: 100 + }, + step: { + type: Number, + required: false, + default: 1 + }, + }, + data() { + return { + v: this.value, + focused: false + }; + }, + watch: { + value(v) { + this.v = parseFloat(v); + } + }, +}); +</script> + +<style lang="scss" scoped> +.ifitouly { + position: relative; + + > .main { + padding: 22px 16px; + + > input { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--X10); + height: 4px; + width: 100%; + box-sizing: border-box; + margin: 0; + outline: 0; + border: 0; + border-radius: 7px; + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + box-sizing: content-box; + } + + &::-moz-range-thumb { + -moz-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + } + } + } +} +</style> diff --git a/src/client/components/debobigego/select.vue b/src/client/components/debobigego/select.vue new file mode 100644 index 0000000000..7a31371afc --- /dev/null +++ b/src/client/components/debobigego/select.vue @@ -0,0 +1,145 @@ +<template> +<div class="yrtfrpux _debobigegoItem" :class="{ disabled, inline }"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _debobigegoPanel _debobigegoClickable" @click="focus"> + <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> + <select ref="input" + v-model="v" + :required="required" + :disabled="disabled" + @focus="focused = true" + @blur="focused = false" + > + <slot></slot> + </select> + <div class="suffix"> + <i class="fas fa-chevron-down"></i> + </div> + </div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + modelValue: { + required: false + }, + required: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + }; + }, + computed: { + v: { + get() { + return this.modelValue; + }, + set(v) { + this.$emit('update:modelValue', v); + } + }, + }, + methods: { + focus() { + this.$refs.input.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.yrtfrpux { + position: relative; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + display: flex; + position: relative; + + > select { + display: block; + flex: 1; + width: 100%; + padding: 0 16px; + font: inherit; + font-weight: normal; + font-size: 1em; + height: 48px; + background: none; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + appearance: none; + -webkit-appearance: none; + color: var(--fg); + + option, + optgroup { + color: var(--fg); + background: var(--bg); + } + } + + > .prefix, + > .suffix { + display: block; + align-self: center; + justify-self: center; + font-size: 1em; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: block; + min-width: 16px; + } + } + + > .prefix { + padding-right: 4px; + } + + > .suffix { + padding: 0 16px 0 0; + opacity: 0.7; + } + } +} +</style> diff --git a/src/client/components/form/suspense.vue b/src/client/components/debobigego/suspense.vue index d04dc07624..e59e0ba12d 100644 --- a/src/client/components/form/suspense.vue +++ b/src/client/components/debobigego/suspense.vue @@ -1,15 +1,15 @@ <template> <transition name="fade" mode="out-in"> - <div class="_formItem" v-if="pending"> - <div class="_formPanel"> + <div class="_debobigegoItem" v-if="pending"> + <div class="_debobigegoPanel"> <MkLoading/> </div> </div> - <div v-else-if="resolved" class="_formItem"> + <div v-else-if="resolved" class="_debobigegoItem"> <slot :result="result"></slot> </div> - <div class="_formItem" v-else> - <div class="_formPanel eiurkvay"> + <div class="_debobigegoItem" v-else> + <div class="_debobigegoPanel eiurkvay"> <div><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div> <MkButton inline @click="retry" class="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton> </div> @@ -19,7 +19,7 @@ <script lang="ts"> import { defineComponent, PropType, ref, watch } from 'vue'; -import './form.scss'; +import './debobigego.scss'; import MkButton from '@client/components/ui/button.vue'; export default defineComponent({ diff --git a/src/client/components/debobigego/switch.vue b/src/client/components/debobigego/switch.vue new file mode 100644 index 0000000000..9a69e18302 --- /dev/null +++ b/src/client/components/debobigego/switch.vue @@ -0,0 +1,132 @@ +<template> +<div class="ijnpvmgr _debobigegoItem"> + <div class="main _debobigegoPanel _debobigegoClickable" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click.prevent="toggle" + > + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" + > + <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff"> + <span class="handle"></span> + </span> + <span class="label"> + <span><slot></slot></span> + </span> + </div> + <div class="_debobigegoCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + modelValue: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.modelValue; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('update:modelValue', !this.checked); + } + } +}); +</script> + +<style lang="scss" scoped> +.ijnpvmgr { + > .main { + position: relative; + display: flex; + padding: 14px 16px; + cursor: pointer; + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 0; + width: 34px; + height: 22px; + background: var(--switchBg); + outline: none; + border-radius: 999px; + transition: all 0.3s; + cursor: pointer; + + > .handle { + position: absolute; + top: 0; + left: 3px; + bottom: 0; + margin: auto 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 16px; + height: 16px; + background-color: #fff; + pointer-events: none; + } + } + + > .label { + margin-left: 12px; + display: block; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + transition: inherit; + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent); + + > .handle { + transform: translateX(12px); + } + } + } + } +} +</style> diff --git a/src/client/components/debobigego/textarea.vue b/src/client/components/debobigego/textarea.vue new file mode 100644 index 0000000000..64e8d47126 --- /dev/null +++ b/src/client/components/debobigego/textarea.vue @@ -0,0 +1,161 @@ +<template> +<FormGroup class="_debobigegoItem"> + <template #label><slot></slot></template> + <div class="rivhosbp _debobigegoItem" :class="{ tall, pre }"> + <div class="input _debobigegoPanel"> + <textarea ref="input" :class="{ code, _monospace: code }" + v-model="v" + :required="required" + :readonly="readonly" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="!code" + @input="onInput" + @focus="focused = true" + @blur="focused = false" + ></textarea> + </div> + </div> + <template #caption><slot name="desc"></slot></template> + + <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent, ref, toRefs, watch } from 'vue'; +import './debobigego.scss'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + FormButton, + }, + props: { + modelValue: { + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + autocomplete: { + type: String, + required: false + }, + code: { + type: Boolean, + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + setup(props, context) { + const { modelValue } = toRefs(props); + const v = ref(modelValue.value); + const changed = ref(false); + const inputEl = ref(null); + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + watch(modelValue.value, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + }); + + return { + v, + updated, + changed, + focus, + onInput, + }; + } +}); +</script> + +<style lang="scss" scoped> +.rivhosbp { + position: relative; + + > .input { + position: relative; + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 16px; + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 1em; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + + &.code { + tab-size: 2; + } + } + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } + + &.pre { + > .input { + > textarea { + white-space: pre; + } + } + } +} +</style> diff --git a/src/client/components/form/tuple.vue b/src/client/components/debobigego/tuple.vue index 6c8a22d189..8a4599fd64 100644 --- a/src/client/components/form/tuple.vue +++ b/src/client/components/debobigego/tuple.vue @@ -1,5 +1,5 @@ <template> -<div class="wthhikgt _formItem" v-size="{ max: [500] }"> +<div class="wthhikgt _debobigegoItem" v-size="{ max: [500] }"> <slot></slot> </div> </template> diff --git a/src/client/components/dialog.vue b/src/client/components/dialog.vue index f3611f050e..dd4932f61f 100644 --- a/src/client/components/dialog.vue +++ b/src/client/components/dialog.vue @@ -40,8 +40,8 @@ import { defineComponent } from 'vue'; import MkModal from '@client/components/ui/modal.vue'; import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/ui/input.vue'; -import MkSelect from '@client/components/ui/select.vue'; +import MkInput from '@client/components/form/input.vue'; +import MkSelect from '@client/components/form/select.vue'; export default defineComponent({ components: { diff --git a/src/client/components/emoji-picker-window.vue b/src/client/components/emoji-picker-window.vue index 53b6ae6b32..b7b884565b 100644 --- a/src/client/components/emoji-picker-window.vue +++ b/src/client/components/emoji-picker-window.vue @@ -153,7 +153,7 @@ export default defineComponent({ height: var(--eachSize); border-radius: 4px; - &:focus { + &:focus-visible { outline: solid 2px var(--focus); z-index: 1; } diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue index d8703202c7..85a12a08e6 100644 --- a/src/client/components/emoji-picker.vue +++ b/src/client/components/emoji-picker.vue @@ -465,7 +465,7 @@ export default defineComponent({ height: var(--eachSize); border-radius: 4px; - &:focus { + &:focus-visible { outline: solid 2px var(--focus); z-index: 1; } diff --git a/src/client/components/follow-button.vue b/src/client/components/follow-button.vue index 5685b86a51..5eba9b1f6b 100644 --- a/src/client/components/follow-button.vue +++ b/src/client/components/follow-button.vue @@ -161,7 +161,7 @@ export default defineComponent({ width: 31px; } - &:focus { + &:focus-visible { &:after { content: ""; pointer-events: none; diff --git a/src/client/components/forgot-password.vue b/src/client/components/forgot-password.vue index 3b5ad6d6ba..cb2380f483 100644 --- a/src/client/components/forgot-password.vue +++ b/src/client/components/forgot-password.vue @@ -35,7 +35,7 @@ import { defineComponent } from 'vue'; import XModalWindow from '@client/components/ui/modal-window.vue'; import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/ui/input.vue'; +import MkInput from '@client/components/form/input.vue'; import * as os from '@client/os'; export default defineComponent({ diff --git a/src/client/components/form-dialog.vue b/src/client/components/form-dialog.vue index e13592b488..6353b7287e 100644 --- a/src/client/components/form-dialog.vue +++ b/src/client/components/form-dialog.vue @@ -14,23 +14,23 @@ </template> <FormBase class="xkpnjxcv"> <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> - <FormInput v-if="form[item].type === 'number'" v-model:value="values[item]" type="number" :step="form[item].step || 1"> + <FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> </FormInput> - <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model:value="values[item]" type="text"> + <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text"> <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> </FormInput> - <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model:value="values[item]"> + <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]"> <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> </FormTextarea> - <FormSwitch v-else-if="form[item].type === 'boolean'" v-model:value="values[item]"> + <FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> <span v-text="form[item].label || item"></span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> </FormSwitch> - <FormSelect v-else-if="form[item].type === 'enum'" v-model:value="values[item]"> + <FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> <option v-for="item in form[item].enum" :value="item.value" :key="item.value">{{ item.label }}</option> </FormSelect> @@ -38,7 +38,7 @@ <template #desc><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> <option v-for="item in form[item].options" :value="item.value" :key="item.value">{{ item.label }}</option> </FormRadios> - <FormRange v-else-if="form[item].type === 'range'" v-model:value="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step"> + <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> </FormRange> @@ -53,14 +53,14 @@ <script lang="ts"> import { defineComponent } from 'vue'; import XModalWindow from '@client/components/ui/modal-window.vue'; -import FormBase from './form/base.vue'; -import FormInput from './form/input.vue'; -import FormTextarea from './form/textarea.vue'; -import FormSwitch from './form/switch.vue'; -import FormSelect from './form/select.vue'; -import FormRange from './form/range.vue'; -import FormButton from './form/button.vue'; -import FormRadios from './form/radios.vue'; +import FormBase from './debobigego/base.vue'; +import FormInput from './debobigego/input.vue'; +import FormTextarea from './debobigego/textarea.vue'; +import FormSwitch from './debobigego/switch.vue'; +import FormSelect from './debobigego/select.vue'; +import FormRange from './debobigego/range.vue'; +import FormButton from './debobigego/button.vue'; +import FormRadios from './debobigego/radios.vue'; export default defineComponent({ components: { diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue index 942ac4dfd2..d7b6f77519 100644 --- a/src/client/components/form/input.vue +++ b/src/client/components/form/input.vue @@ -1,53 +1,49 @@ <template> -<FormGroup class="_formItem"> - <template #label><slot></slot></template> - <div class="ztzhwixg _formItem" :class="{ inline, disabled }"> - <div class="icon" ref="icon"><slot name="icon"></slot></div> - <div class="input _formPanel"> - <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> - <input ref="inputEl" - :type="type" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - :step="step" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - :list="id" - > - <datalist :id="id" v-if="datalist"> - <option v-for="data in datalist" :value="data"/> - </datalist> - <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> - </div> +<div class="matxzzsk"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ inline, disabled, focused }"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> </div> - <template #caption><slot name="desc"></slot></template> + <div class="caption"><slot name="caption"></slot></div> - <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> -</FormGroup> + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> +</div> </template> <script lang="ts"> import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import './form.scss'; -import FormButton from './button.vue'; -import FormGroup from './group.vue'; +import MkButton from '../ui/button.vue'; +import { debounce } from 'throttle-debounce'; export default defineComponent({ components: { - FormGroup, - FormButton, + MkButton, }, + props: { - value: { - required: false + modelValue: { + required: true }, type: { type: String, @@ -96,16 +92,23 @@ export default defineComponent({ required: false, default: false }, + debounce: { + type: Boolean, + required: false, + default: false + }, manualSave: { type: Boolean, required: false, default: false }, }, - emits: ['change', 'keydown', 'enter'], + + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + setup(props, context) { - const { value, type, autofocus } = toRefs(props); - const v = ref(value.value); + const { modelValue, type, autofocus } = toRefs(props); + const v = ref(modelValue.value); const id = Math.random().toString(); // TODO: uuid? const focused = ref(false); const changed = ref(false); @@ -131,19 +134,25 @@ export default defineComponent({ const updated = () => { changed.value = false; if (type?.value === 'number') { - context.emit('update:value', parseFloat(v.value)); + context.emit('update:modelValue', parseFloat(v.value)); } else { - context.emit('update:value', v.value); + context.emit('update:modelValue', v.value); } }; - watch(value, newValue => { + const debouncedUpdated = debounce(1000, updated); + + watch(modelValue, newValue => { v.value = newValue; }); watch(v, newValue => { if (!props.manualSave) { - updated(); + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } } invalid.value = inputEl.value.validity.badInput; @@ -196,59 +205,66 @@ export default defineComponent({ </script> <style lang="scss" scoped> -.ztzhwixg { - position: relative; +.matxzzsk { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } - > .icon { - position: absolute; - top: 0; - left: 0; - width: 24px; - text-align: center; - line-height: 32px; + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); - &:not(:empty) + .input { - margin-left: 28px; + &:empty { + display: none; } } > .input { - $height: 48px; + $height: 42px; position: relative; > input { + appearance: none; + -webkit-appearance: none; display: block; height: $height; width: 100%; margin: 0; - padding: 0 16px; + padding: 0 12px; font: inherit; font-weight: normal; font-size: 1em; - line-height: $height; - color: var(--inputText); - background: transparent; - border: none; - border-radius: 0; + color: var(--fg); + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; outline: none; box-shadow: none; box-sizing: border-box; + transition: border-color 0.1s ease-out; - &[type='file'] { - display: none; + &:hover { + border-color: var(--inputBorderHover); } } > .prefix, > .suffix { - display: block; + display: flex; + align-items: center; position: absolute; z-index: 1; top: 0; - padding: 0 16px; + padding: 0 12px; font-size: 1em; - line-height: $height; - color: var(--inputLabel); + height: $height; pointer-events: none; &:empty { @@ -267,25 +283,32 @@ export default defineComponent({ > .prefix { left: 0; - padding-right: 8px; + padding-right: 6px; } > .suffix { right: 0; - padding-left: 8px; + padding-left: 6px; } - } - &.inline { - display: inline-block; - margin: 0; - } + &.inline { + display: inline-block; + margin: 0; + } + + &.focused { + > input { + border-color: var(--accent); + //box-shadow: 0 0 0 4px var(--focus); + } + } - &.disabled { - opacity: 0.7; + &.disabled { + opacity: 0.7; - &, * { - cursor: not-allowed !important; + &, * { + cursor: not-allowed !important; + } } } } diff --git a/src/client/components/ui/radio.vue b/src/client/components/form/radio.vue index 0f31d8fa0a..0f31d8fa0a 100644 --- a/src/client/components/ui/radio.vue +++ b/src/client/components/form/radio.vue diff --git a/src/client/components/form/radios.vue b/src/client/components/form/radios.vue index b660c37ace..1d3d80172a 100644 --- a/src/client/components/form/radios.vue +++ b/src/client/components/form/radios.vue @@ -1,7 +1,6 @@ <script lang="ts"> import { defineComponent, h } from 'vue'; -import MkRadio from '@client/components/ui/radio.vue'; -import './form.scss'; +import MkRadio from './radio.vue'; export default defineComponent({ components: { @@ -18,9 +17,6 @@ export default defineComponent({ } }, watch: { - modelValue() { - this.value = this.modelValue; - }, value() { this.$emit('update:modelValue', this.value); } @@ -33,80 +29,38 @@ export default defineComponent({ if (options.length === 1 && options[0].props == null) options = options[0].children; return h('div', { - class: 'cnklmpwm _formItem' + class: 'novjtcto' }, [ - h('div', { - class: '_formLabel', - }, label), - ...options.map(option => h('button', { - class: '_button _formPanel _formClickable', + h('div', { class: 'label' }, label), + ...options.map(option => h(MkRadio, { key: option.key, - onClick: () => this.value = option.props.value, - }, [h('span', { - class: ['check', { checked: this.value === option.props.value }], - }), option.children])) + value: option.props.value, + modelValue: this.value, + 'onUpdate:modelValue': value => this.value = value, + }, option.children)) ]); } }); </script> <style lang="scss"> -.cnklmpwm { - > button { - display: block; - width: 100%; - box-sizing: border-box; - padding: 14px 18px; - text-align: left; - - &:not(:first-of-type) { - border-top: none !important; - border-top-left-radius: 0; - border-top-right-radius: 0; - } +.novjtcto { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; - &:not(:last-of-type) { - border-bottom: solid 0.5px var(--divider); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; + &:empty { + display: none; } + } - > .check { - display: inline-block; - vertical-align: bottom; - position: relative; - width: 16px; - height: 16px; - margin-right: 8px; - background: none; - border: 2px solid var(--inputBorder); - border-radius: 100%; - transition: inherit; - - &:after { - content: ""; - display: block; - position: absolute; - top: 3px; - right: 3px; - bottom: 3px; - left: 3px; - border-radius: 100%; - opacity: 0; - transform: scale(0); - transition: .4s cubic-bezier(.25,.8,.25,1); - } - - &.checked { - border-color: var(--accent); + &:first-child { + margin-top: 0; + } - &:after { - background-color: var(--accent); - transform: scale(1); - opacity: 1; - } - } - } + &:last-child { + margin-bottom: 0; } } </style> diff --git a/src/client/components/form/range.vue b/src/client/components/form/range.vue index 65d665c70a..4cfe66a8fc 100644 --- a/src/client/components/form/range.vue +++ b/src/client/components/form/range.vue @@ -1,21 +1,20 @@ <template> -<div class="ifitouly _formItem" :class="{ focused, disabled }"> - <div class="_formLabel"><slot name="label"></slot></div> - <div class="_formPanel main"> - <input - type="range" - ref="input" - v-model="v" - :disabled="disabled" - :min="min" - :max="max" - :step="step" - @focus="focused = true" - @blur="focused = false" - @input="$emit('update:value', $event.target.value)" - /> - </div> - <div class="_formCaption"><slot name="caption"></slot></div> +<div class="timctyfi" :class="{ focused, disabled }"> + <div class="icon"><slot name="icon"></slot></div> + <span class="label"><slot name="label"></slot></span> + <input + type="range" + ref="input" + v-model="v" + :disabled="disabled" + :min="min" + :max="max" + :step="step" + :autofocus="autofocus" + @focus="focused = true" + @blur="focused = false" + @input="$emit('update:value', $event.target.value)" + /> </div> </template> @@ -49,6 +48,10 @@ export default defineComponent({ required: false, default: 1 }, + autofocus: { + type: Boolean, + required: false + } }, data() { return { @@ -61,61 +64,75 @@ export default defineComponent({ this.v = parseFloat(v); } }, + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$refs.input.focus(); + }); + } + } }); </script> <style lang="scss" scoped> -.ifitouly { +.timctyfi { position: relative; + margin: 8px; - > .main { - padding: 22px 16px; + > .icon { + display: inline-block; + width: 24px; + text-align: center; + } - > input { - display: block; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: var(--X10); - height: 4px; - width: 100%; - box-sizing: border-box; - margin: 0; - outline: 0; - border: 0; - border-radius: 7px; + > .title { + pointer-events: none; + font-size: 16px; + color: var(--inputLabel); + overflow: hidden; + } - &.disabled { - opacity: 0.6; - cursor: not-allowed; - } + > input { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--X10); + height: 7px; + margin: 0 8px; + outline: 0; + border: 0; + border-radius: 7px; - &::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - box-sizing: content-box; - } + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } - &::-moz-range-thumb { - -moz-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - } + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + box-sizing: content-box; + } + + &::-moz-range-thumb { + -moz-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); } } } diff --git a/src/client/components/form/section.vue b/src/client/components/form/section.vue new file mode 100644 index 0000000000..8eac40a0db --- /dev/null +++ b/src/client/components/form/section.vue @@ -0,0 +1,31 @@ +<template> +<div class="vrtktovh" v-size="{ max: [500] }" v-sticky-container> + <div class="label"><slot name="label"></slot></div> + <div class="main"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.vrtktovh { + border-top: solid 0.5px var(--divider); + + > .label { + font-weight: bold; + padding: 24px 0 16px 0; + } + + > .main { + margin-bottom: 32px; + } +} +</style> diff --git a/src/client/components/form/select.vue b/src/client/components/form/select.vue index 1c5a473451..257e2cc990 100644 --- a/src/client/components/form/select.vue +++ b/src/client/components/form/select.vue @@ -1,125 +1,216 @@ <template> -<div class="yrtfrpux _formItem" :class="{ disabled, inline }"> - <div class="_formLabel"><slot name="label"></slot></div> - <div class="icon" ref="icon"><slot name="icon"></slot></div> - <div class="input _formPanel _formClickable" @click="focus"> - <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> - <select ref="input" +<div class="vblkjoeq"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ inline, disabled, focused }"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <select ref="inputEl" v-model="v" - :required="required" :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" @focus="focused = true" @blur="focused = false" + @input="onInput" > <slot></slot> </select> - <div class="suffix"> - <i class="fas fa-chevron-down"></i> - </div> + <div class="suffix" ref="suffixEl"><i class="fas fa-chevron-down"></i></div> </div> - <div class="_formCaption"><slot name="caption"></slot></div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> </div> </template> <script lang="ts"> -import { defineComponent } from 'vue'; -import './form.scss'; +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import MkButton from '../ui/button.vue'; export default defineComponent({ + components: { + MkButton, + }, + props: { - value: { - required: false + modelValue: { + required: true }, required: { type: Boolean, required: false }, + readonly: { + type: Boolean, + required: false + }, disabled: { type: Boolean, required: false }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, inline: { type: Boolean, required: false, default: false }, + manualSave: { + type: Boolean, + required: false, + default: false + }, }, - data() { - return { + + emits: ['change', 'update:modelValue'], + + setup(props, context) { + const { modelValue, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); }; - }, - computed: { - v: { - get() { - return this.value; - }, - set(v) { - this.$emit('update:value', v); + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + watch(modelValue, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); } - }, + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); + + onUnmounted(() => { + clearInterval(clock); + }); + }); + }); + + return { + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + focus, + onInput, + updated, + }; }, - methods: { - focus() { - this.$refs.input.focus(); - } - } }); </script> <style lang="scss" scoped> -.yrtfrpux { - position: relative; +.vblkjoeq { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; - > .icon { - position: absolute; - top: 0; - left: 0; - width: 24px; - text-align: center; - line-height: 32px; + &:empty { + display: none; + } + } - &:not(:empty) + .input { - margin-left: 28px; + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; } } > .input { - display: flex; + $height: 42px; position: relative; > select { + appearance: none; + -webkit-appearance: none; display: block; - flex: 1; + height: $height; width: 100%; - padding: 0 16px; + margin: 0; + padding: 0 12px; font: inherit; font-weight: normal; font-size: 1em; - height: 48px; - background: none; - border: none; - border-radius: 0; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--inputBorder); + border-radius: 6px; outline: none; box-shadow: none; - appearance: none; - -webkit-appearance: none; - color: var(--fg); + box-sizing: border-box; + cursor: pointer; + transition: border-color 0.1s ease-out; - option, - optgroup { - color: var(--fg); - background: var(--bg); + &:hover { + border-color: var(--inputBorderHover); } } > .prefix, > .suffix { - display: block; - align-self: center; - justify-self: center; + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; font-size: 1em; - line-height: 32px; - color: var(--inputLabel); + height: $height; pointer-events: none; &:empty { @@ -127,18 +218,42 @@ export default defineComponent({ } > * { - display: block; + display: inline-block; min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } } > .prefix { - padding-right: 4px; + left: 0; + padding-right: 6px; } > .suffix { - padding: 0 16px 0 0; + right: 0; + padding-left: 6px; + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.focused { + > select { + border-color: var(--accent); + } + } + + &.disabled { opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } } } } diff --git a/src/client/components/form/slot.vue b/src/client/components/form/slot.vue new file mode 100644 index 0000000000..8580c1307d --- /dev/null +++ b/src/client/components/form/slot.vue @@ -0,0 +1,50 @@ +<template> +<div class="adhpbeou"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="content"> + <slot></slot> + </div> + <div class="caption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.adhpbeou { + margin: 1.5em 0; + + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .content { + position: relative; + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; + } +} +</style> diff --git a/src/client/components/form/switch.vue b/src/client/components/form/switch.vue index e7ef714c49..85f8b7c870 100644 --- a/src/client/components/form/switch.vue +++ b/src/client/components/form/switch.vue @@ -1,35 +1,34 @@ <template> -<div class="ijnpvmgr _formItem"> - <div class="main _formPanel _formClickable" - :class="{ disabled, checked }" - :aria-checked="checked" - :aria-disabled="disabled" - @click.prevent="toggle" +<div + class="ziffeoms" + :class="{ disabled, checked }" + role="switch" + :aria-checked="checked" + :aria-disabled="disabled" + @click.prevent="toggle" +> + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" > - <input - type="checkbox" - ref="input" - :disabled="disabled" - @keydown.enter="toggle" - > - <span class="button"> - <span></span> - </span> - <span class="label"> - <span><slot></slot></span> - </span> - </div> - <div class="_formCaption"><slot name="desc"></slot></div> + <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff"> + <span class="handle"></span> + </span> + <span class="label"> + <span><slot></slot></span> + <p><slot name="caption"></slot></p> + </span> </div> </template> <script lang="ts"> import { defineComponent } from 'vue'; -import './form.scss'; export default defineComponent({ props: { - value: { + modelValue: { type: Boolean, default: false }, @@ -40,91 +39,110 @@ export default defineComponent({ }, computed: { checked(): boolean { - return this.value; + return this.modelValue; } }, methods: { toggle() { if (this.disabled) return; - this.$emit('update:value', !this.checked); + this.$emit('update:modelValue', !this.checked); } } }); </script> <style lang="scss" scoped> -.ijnpvmgr { - > .main { - position: relative; - display: flex; - padding: 14px 16px; - cursor: pointer; +.ziffeoms { + position: relative; + display: flex; + cursor: pointer; + transition: all 0.3s; - > * { - user-select: none; - } + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } - &.disabled { - opacity: 0.6; - cursor: not-allowed; + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 0; + width: 36px; + height: 26px; + background: var(--switchBg); + outline: none; + border-radius: 999px; + transition: inherit; + + > .handle { + position: absolute; + top: 0; + bottom: 0; + left: 5px; + margin: auto 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 16px; + height: 16px; + background-color: #fff; } + } - &.checked { - > .button { - background-color: var(--X10); - border-color: var(--X10); + > .label { + margin-left: 16px; + margin-top: 2px; + display: block; + cursor: pointer; + transition: inherit; + color: var(--fg); - > * { - background-color: var(--accent); - transform: translateX(14px); - } - } + > span { + display: block; + line-height: 20px; + transition: inherit; } - > input { - position: absolute; - width: 0; - height: 0; - opacity: 0; + > p { margin: 0; + color: var(--fgTransparentWeak); + font-size: 90%; } + } + &:hover { > .button { - position: relative; - display: inline-block; - flex-shrink: 0; - margin: 3px 0 0 0; - width: 34px; - height: 14px; - background: var(--X6); - outline: none; - border-radius: 14px; - transition: all 0.3s; - cursor: pointer; - - > * { - position: absolute; - top: -3px; - left: 0; - border-radius: 100%; - transition: background-color 0.3s, transform 0.3s; - width: 20px; - height: 20px; - background-color: #fff; - box-shadow: 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12); - } + background-color: var(--accentedBg); } + } - > .label { - margin-left: 12px; - display: block; - transition: inherit; - color: var(--fg); + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent); + border-color: var(--accent); - > span { - display: block; - line-height: 20px; - transition: inherit; + > .handle { + transform: translateX(10px); } } } diff --git a/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue index 8f42581a9b..50be69f930 100644 --- a/src/client/components/form/textarea.vue +++ b/src/client/components/form/textarea.vue @@ -1,40 +1,45 @@ <template> -<FormGroup class="_formItem"> - <template #label><slot></slot></template> - <div class="rivhosbp _formItem" :class="{ tall, pre }"> - <div class="input _formPanel"> - <textarea ref="input" :class="{ code, _monospace: code }" - v-model="v" - :required="required" - :readonly="readonly" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="!code" - @input="onInput" - @focus="focused = true" - @blur="focused = false" - ></textarea> - </div> +<div class="adhpbeos"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ disabled, focused, tall, pre }"> + <textarea ref="inputEl" + :class="{ code, _monospace: code }" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + ></textarea> </div> - <template #caption><slot name="desc"></slot></template> + <div class="caption"><slot name="caption"></slot></div> - <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> -</FormGroup> + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> +</div> </template> <script lang="ts"> -import { defineComponent, ref, toRefs, watch } from 'vue'; -import './form.scss'; -import FormButton from './button.vue'; -import FormGroup from './group.vue'; +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import MkButton from '../ui/button.vue'; +import { debounce } from 'throttle-debounce'; export default defineComponent({ components: { - FormGroup, - FormButton, + MkButton, }, + props: { - value: { + modelValue: { + required: true + }, + type: { + type: String, required: false }, required: { @@ -45,14 +50,29 @@ export default defineComponent({ type: Boolean, required: false }, + disabled: { + type: Boolean, + required: false + }, pattern: { type: String, required: false }, - autocomplete: { + placeholder: { type: String, required: false }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, code: { type: Boolean, required: false @@ -67,91 +87,162 @@ export default defineComponent({ required: false, default: false }, + debounce: { + type: Boolean, + required: false, + default: false + }, manualSave: { type: Boolean, required: false, default: false }, }, + + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + setup(props, context) { - const { value } = toRefs(props); - const v = ref(value.value); + const { modelValue, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const focused = ref(false); const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); const inputEl = ref(null); + const focus = () => inputEl.value.focus(); const onInput = (ev) => { changed.value = true; context.emit('change', ev); }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; const updated = () => { changed.value = false; - context.emit('update:value', v.value); + context.emit('update:modelValue', v.value); }; - watch(value, newValue => { + const debouncedUpdated = debounce(1000, updated); + + watch(modelValue, newValue => { v.value = newValue; }); watch(v, newValue => { if (!props.manualSave) { - updated(); + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } } + + invalid.value = inputEl.value.validity.badInput; }); - + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); + }); + return { v, - updated, + focused, + invalid, changed, + filled, + inputEl, focus, onInput, + onKeydown, + updated, }; - } + }, }); </script> <style lang="scss" scoped> -.rivhosbp { - position: relative; +.adhpbeos { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } > .input { position: relative; - + > textarea { + appearance: none; + -webkit-appearance: none; display: block; width: 100%; min-width: 100%; max-width: 100%; min-height: 130px; margin: 0; - padding: 16px; - box-sizing: border-box; + padding: 12px; font: inherit; font-weight: normal; font-size: 1em; - background: transparent; - border: none; - border-radius: 0; + color: var(--fg); + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; outline: none; box-shadow: none; - color: var(--fg); + box-sizing: border-box; + transition: border-color 0.1s ease-out; - &.code { - tab-size: 2; + &:hover { + border-color: var(--inputBorderHover); + } + } + + &.focused { + > textarea { + border-color: var(--accent); } } - } - &.tall { - > .input { + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + &.tall { > textarea { min-height: 200px; } } - } - &.pre { - > .input { + &.pre { > textarea { white-space: pre; } diff --git a/src/client/components/global/header.vue b/src/client/components/global/header.vue new file mode 100644 index 0000000000..a4466da498 --- /dev/null +++ b/src/client/components/global/header.vue @@ -0,0 +1,359 @@ +<template> +<div class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick" ref="el"> + <template v-if="info"> + <div class="titleContainer" @click="showTabsPopup" v-if="!hideTitle"> + <i v-if="info.icon" class="icon" :class="info.icon"></i> + <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> + + <div class="title"> + <MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="title"/> + <div v-else-if="info.title" class="title">{{ info.title }}</div> + <div class="subtitle" v-if="!narrow && info.subtitle"> + {{ info.subtitle }} + </div> + <div class="subtitle activeTab" v-if="narrow && hasTabs"> + {{ info.tabs.find(tab => tab.active)?.title }} + <i class="chevron fas fa-chevron-down"></i> + </div> + </div> + </div> + <div class="tabs" v-if="!narrow || hideTitle"> + <button class="tab _button" v-for="tab in info.tabs" :class="{ active: tab.active }" @click="tab.onClick" v-tooltip="tab.title"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + </div> + </template> + <div class="buttons right"> + <template v-if="info && info.actions && !narrow"> + <template v-for="action in info.actions"> + <MkButton class="fullButton" v-if="action.asFullButton" @click.stop="action.handler" primary><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag" v-tooltip="action.text"><i :class="action.icon"></i></button> + </template> + </template> + <button v-if="shouldShowMenu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag" v-tooltip="$ts.menu"><i class="fas fa-ellipsis-h"></i></button> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue'; +import * as tinycolor from 'tinycolor2'; +import { popupMenu } from '@client/os'; +import { url } from '@client/config'; +import { scrollToTop } from '@client/scripts/scroll'; +import MkButton from '@client/components/ui/button.vue'; +import { i18n } from '@client/i18n'; +import { globalEvents } from '@client/events'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + info: { + type: Object as PropType<{ + actions?: {}[]; + tabs?: {}[]; + }>, + required: true + }, + menu: { + required: false + }, + thin: { + required: false, + default: false + }, + }, + + setup(props) { + const el = ref<HTMLElement>(null); + const bg = ref(null); + const narrow = ref(false); + const height = ref(0); + const hasTabs = computed(() => { + return props.info.tabs && props.info.tabs.length > 0; + }); + const shouldShowMenu = computed(() => { + if (props.info == null) return false; + if (props.info.actions != null && narrow.value) return true; + if (props.info.menu != null) return true; + if (props.info.share != null) return true; + if (props.menu != null) return true; + return false; + }); + + const share = () => { + navigator.share({ + url: url + props.info.path, + ...props.info.share, + }); + }; + + const showMenu = (ev: MouseEvent) => { + let menu = props.info.menu ? props.info.menu() : []; + if (narrow.value && props.info.actions) { + menu = [...props.info.actions.map(x => ({ + text: x.text, + icon: x.icon, + action: x.handler + })), menu.length > 0 ? null : undefined, ...menu]; + } + if (props.info.share) { + if (menu.length > 0) menu.push(null); + menu.push({ + text: i18n.locale.share, + icon: 'fas fa-share-alt', + action: share + }); + } + if (props.menu) { + if (menu.length > 0) menu.push(null); + menu = menu.concat(props.menu); + } + popupMenu(menu, ev.currentTarget || ev.target); + }; + + const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs.value) return; + if (!narrow.value) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.info.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + action: tab.onClick, + })); + popupMenu(menu, ev.currentTarget || ev.target); + }; + + const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); + }; + + const onClick = () => { + scrollToTop(el.value, { behavior: 'smooth' }); + }; + + const calcBg = () => { + const rawBg = props.info?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); + }; + + onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); + }); + + if (el.value.parentElement) { + narrow.value = el.value.parentElement.offsetWidth < 500; + const ro = new ResizeObserver((entries, observer) => { + if (el.value) { + narrow.value = el.value.parentElement.offsetWidth < 500; + } + }); + ro.observe(el.value.parentElement); + onUnmounted(() => { + ro.disconnect(); + }); + setTimeout(() => { + const currentStickyTop = getComputedStyle(el.value.parentElement).getPropertyValue('--stickyTop') || '0px'; + el.value.style.setProperty('--stickyTop', currentStickyTop); + el.value.parentElement.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${el.value.offsetHeight}px)`); + }, 100); // レンダリング順序の関係で親のstickyTopの設定が少し遅れることがあるため + } + }); + + return { + el, + bg, + narrow, + height, + hasTabs, + shouldShowMenu, + share, + showMenu, + showTabsPopup, + preventDrag, + onClick, + hideTitle: inject('shouldOmitHeaderTitle', false), + thin_: props.thin || inject('shouldHeaderThin', false) + }; + }, +}); +</script> + +<style lang="scss" scoped> +.fdidabkb { + --height: 60px; + display: flex; + position: sticky; + top: var(--stickyTop, 0); + z-index: 1000; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-bottom: solid 0.5px var(--divider); + + &.thin { + --height: 50px; + } + + &.slim { + text-align: center; + + > .titleContainer { + flex: 1; + margin: 0 auto; + margin-left: var(--height); + + > *:first-child { + margin-left: auto; + } + + > *:last-child { + margin-right: auto; + } + } + } + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + + &:after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + margin: 0 auto; + width: 100%; + height: 3px; + background: var(--accent); + } + } + + > .icon + .title { + margin-left: 8px; + } + } + } +} +</style> diff --git a/src/client/components/global/spacer.vue b/src/client/components/global/spacer.vue new file mode 100644 index 0000000000..1129d54c71 --- /dev/null +++ b/src/client/components/global/spacer.vue @@ -0,0 +1,76 @@ +<template> +<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }"> + <div ref="content" :class="$style.content"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; + +export default defineComponent({ + props: { + contentMax: { + type: Number, + required: false, + default: null, + } + }, + + setup(props, context) { + let ro: ResizeObserver; + const root = ref<HTMLElement>(null); + const content = ref<HTMLElement>(null); + const margin = ref(0); + const adjust = (rect: { width: number; height: number; }) => { + if (rect.width > (props.contentMax || 500)) { + margin.value = 32; + } else { + margin.value = 12; + } + }; + + onMounted(() => { + ro = new ResizeObserver((entries) => { + /* iOSが対応していない + adjust({ + width: entries[0].borderBoxSize[0].inlineSize, + height: entries[0].borderBoxSize[0].blockSize, + }); + */ + adjust({ + width: root.value.offsetWidth, + height: root.value.offsetHeight, + }); + }); + ro.observe(root.value); + + if (props.contentMax) { + content.value.style.maxWidth = `${props.contentMax}px`; + } + }); + + onUnmounted(() => { + ro.disconnect(); + }); + + return { + root, + content, + margin, + }; + }, +}); +</script> + +<style lang="scss" module> +.root { + box-sizing: border-box; + width: 100%; +} + +.content { + margin: 0 auto; +} +</style> diff --git a/src/client/components/index.ts b/src/client/components/index.ts index 8b914c5eec..ecf66ea0e8 100644 --- a/src/client/components/index.ts +++ b/src/client/components/index.ts @@ -13,6 +13,8 @@ import i18n from './global/i18n'; import loading from './global/loading.vue'; import error from './global/error.vue'; import ad from './global/ad.vue'; +import header from './global/header.vue'; +import spacer from './global/spacer.vue'; export default function(app: App) { app.component('I18n', i18n); @@ -28,4 +30,6 @@ export default function(app: App) { app.component('MkLoading', loading); app.component('MkError', error); app.component('MkAd', ad); + app.component('MkHeader', header); + app.component('MkSpacer', spacer); } diff --git a/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue index 78044f0b16..5e7c71ea65 100644 --- a/src/client/components/instance-stats.vue +++ b/src/client/components/instance-stats.vue @@ -36,7 +36,7 @@ <script lang="ts"> import { defineComponent, markRaw } from 'vue'; import Chart from 'chart.js'; -import MkSelect from './ui/select.vue'; +import MkSelect from './form/select.vue'; import number from '@client/filters/number'; const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); diff --git a/src/client/components/media-caption.vue b/src/client/components/media-caption.vue index 690927d4c5..b35b101d06 100644 --- a/src/client/components/media-caption.vue +++ b/src/client/components/media-caption.vue @@ -3,10 +3,13 @@ <div class="container"> <div class="fullwidth top-caption"> <div class="mk-dialog"> - <header v-if="title"><Mfm :text="title"/></header> + <header> + <Mfm v-if="title" class="title" :text="title"/> + <span class="text-count" :class="{ over: remainingLength < 0 }">{{ remainingLength }}</span> + </header> <textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea> <div class="buttons" v-if="(showOkButton || showCancelButton)"> - <MkButton inline @click="ok" primary>{{ $ts.ok }}</MkButton> + <MkButton inline @click="ok" primary :disabled="remainingLength < 0">{{ $ts.ok }}</MkButton> <MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton> </div> </div> @@ -26,10 +29,12 @@ <script lang="ts"> import { defineComponent } from 'vue'; +import { length } from 'stringz'; import MkModal from '@client/components/ui/modal.vue'; import MkButton from '@client/components/ui/button.vue'; import bytes from '@client/filters/bytes'; import number from '@client/filters/number'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits'; export default defineComponent({ components: { @@ -79,6 +84,13 @@ export default defineComponent({ document.removeEventListener('keydown', this.onKeydown); }, + computed: { + remainingLength(): number { + if (typeof this.inputValue != "string") return DB_MAX_IMAGE_COMMENT_LENGTH; + return DB_MAX_IMAGE_COMMENT_LENGTH - length(this.inputValue); + } + }, + methods: { bytes, number, @@ -156,8 +168,18 @@ export default defineComponent({ > header { margin: 0 0 8px 0; - font-weight: bold; - font-size: 20px; + position: relative; + + > .title { + font-weight: bold; + font-size: 20px; + } + + > .text-count { + opacity: 0.7; + position: absolute; + right: 0; + } } > .buttons { @@ -184,7 +206,7 @@ export default defineComponent({ min-width: 100%; min-height: 90px; - &:focus { + &:focus-visible { outline: none; } diff --git a/src/client/components/mfm.ts b/src/client/components/mfm.ts index a228ca4b8d..2bdd7d46ee 100644 --- a/src/client/components/mfm.ts +++ b/src/client/components/mfm.ts @@ -185,7 +185,7 @@ export default defineComponent({ } } if (style == null) { - return h('span', {}, ['[', token.props.name, ...genEl(token.children), ']']); + return h('span', {}, ['[', token.props.name, ' ', ...genEl(token.children), ']']); } else { return h('span', { style: 'display: inline-block;' + style, diff --git a/src/client/components/modal-page-window.vue b/src/client/components/modal-page-window.vue index e7d96f7a6f..cb81a974f5 100644 --- a/src/client/components/modal-page-window.vue +++ b/src/client/components/modal-page-window.vue @@ -2,11 +2,15 @@ <MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> <div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> <div class="header" @contextmenu="onContextmenu"> - <span class="title"> - <XHeader :info="pageInfo" :back-button="history.length > 0" @back="back()" :close-button="true" @close="$refs.modal.close()"/> + <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button> + <span v-else style="display: inline-block; width: 20px"></span> + <span v-if="pageInfo" class="title"> + <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i> + <span>{{ pageInfo.title }}</span> </span> + <button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button> </div> - <div class="body _flat_"> + <div class="body _fitSide_"> <keep-alive> <component :is="component" v-bind="props" :ref="changePage"/> </keep-alive> @@ -18,7 +22,6 @@ <script lang="ts"> import { defineComponent } from 'vue'; import MkModal from '@client/components/ui/modal.vue'; -import XHeader from '@client/ui/_common_/header.vue'; import { popout } from '@client/scripts/popout'; import copyToClipboard from '@client/scripts/copy-to-clipboard'; import { resolve } from '@client/router'; @@ -29,7 +32,6 @@ import * as os from '@client/os'; export default defineComponent({ components: { MkModal, - XHeader, }, inject: { @@ -42,7 +44,8 @@ export default defineComponent({ return { navHook: (path) => { this.navigate(path); - } + }, + shouldHeaderThin: true, }; }, @@ -172,19 +175,39 @@ export default defineComponent({ $height-narrow: 42px; display: flex; flex-shrink: 0; + height: $height; + line-height: $height; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; box-shadow: 0px 1px var(--divider); - > .title { - flex: 1; + > button { height: $height; - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + width: $height; + + &:hover { + color: var(--fgHighlighted); + } + } + + @media (max-width: 500px) { + height: $height-narrow; + line-height: $height-narrow; + padding-left: 16px; - @media (max-width: 500px) { + > button { height: $height-narrow; - padding-left: 16px; + width: $height-narrow; + } + } + + > .title { + flex: 1; + + > .icon { + margin-right: 0.5em; } } } diff --git a/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue index e7f116d1fd..40b0a68c58 100644 --- a/src/client/components/note-detailed.vue +++ b/src/client/components/note-detailed.vue @@ -59,7 +59,7 @@ <div class="body"> <p v-if="appearNote.cw != null" class="cw"> <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> - <XCwButton v-model:value="showContent" :note="appearNote"/> + <XCwButton v-model="showContent" :note="appearNote"/> </p> <div class="content" v-show="appearNote.cw == null || showContent"> <div class="text"> @@ -80,7 +80,7 @@ </div> <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="true" class="url-preview"/> - <div class="renote" v-if="appearNote.renote"><XNotePreview :note="appearNote.renote"/></div> + <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> </div> <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> </div> @@ -132,7 +132,7 @@ import * as mfm from 'mfm-js'; import { sum } from '../../prelude/array'; import XSub from './note.sub.vue'; import XNoteHeader from './note-header.vue'; -import XNotePreview from './note-preview.vue'; +import XNoteSimple from './note-simple.vue'; import XReactionsViewer from './reactions-viewer.vue'; import XMediaList from './media-list.vue'; import XCwButton from './cw-button.vue'; @@ -153,7 +153,7 @@ export default defineComponent({ components: { XSub, XNoteHeader, - XNotePreview, + XNoteSimple, XReactionsViewer, XMediaList, XCwButton, diff --git a/src/client/components/note-preview.vue b/src/client/components/note-preview.vue index 4248c2bb1d..a474a01341 100644 --- a/src/client/components/note-preview.vue +++ b/src/client/components/note-preview.vue @@ -1,15 +1,13 @@ <template> -<div class="yohlumlk" v-size="{ min: [350, 500] }"> - <MkAvatar class="avatar" :user="note.user"/> +<div class="fefdfafb" v-size="{ min: [350, 500] }"> + <MkAvatar class="avatar" :user="$i"/> <div class="main"> - <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="header"> + <MkUserName :user="$i"/> + </div> <div class="body"> - <p v-if="note.cw != null" class="cw"> - <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <XCwButton v-model:value="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <XSubNote-content class="text" :note="note"/> + <div class="content"> + <Mfm :text="text" :author="$i" :i="$i"/> </div> </div> </div> @@ -18,35 +16,22 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import XNoteHeader from './note-header.vue'; -import XSubNoteContent from './sub-note-content.vue'; -import XCwButton from './cw-button.vue'; -import * as os from '@client/os'; export default defineComponent({ components: { - XNoteHeader, - XSubNoteContent, - XCwButton, }, props: { - note: { - type: Object, + text: { + type: String, required: true } }, - - data() { - return { - showContent: false - }; - } }); </script> <style lang="scss" scoped> -.yohlumlk { +.fefdfafb { display: flex; margin: 0; padding: 0; diff --git a/src/client/components/note-simple.vue b/src/client/components/note-simple.vue new file mode 100644 index 0000000000..406a475cd9 --- /dev/null +++ b/src/client/components/note-simple.vue @@ -0,0 +1,113 @@ +<template> +<div class="yohlumlk" v-size="{ min: [350, 500] }"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="main"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <XCwButton v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; +import * as os from '@client/os'; + +export default defineComponent({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.yohlumlk { + display: flex; + margin: 0; + padding: 0; + overflow: clip; + font-size: 0.95em; + + &.min-width_350px { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } + + &.min-width_500px { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/src/client/components/note.sub.vue b/src/client/components/note.sub.vue index 899c4b2f16..157b65ec5c 100644 --- a/src/client/components/note.sub.vue +++ b/src/client/components/note.sub.vue @@ -7,7 +7,7 @@ <div class="body"> <p v-if="note.cw != null" class="cw"> <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" /> - <XCwButton v-model:value="showContent" :note="note"/> + <XCwButton v-model="showContent" :note="note"/> </p> <div class="content" v-show="note.cw == null || showContent"> <XSubNote-content class="text" :note="note"/> diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 38b529dd91..91a3e3b87d 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -43,7 +43,7 @@ <div class="body"> <p v-if="appearNote.cw != null" class="cw"> <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> - <XCwButton v-model:value="showContent" :note="appearNote"/> + <XCwButton v-model="showContent" :note="appearNote"/> </p> <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent"> <div class="text"> @@ -64,7 +64,7 @@ </div> <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/> - <div class="renote" v-if="appearNote.renote"><XNotePreview :note="appearNote.renote"/></div> + <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> <button v-if="collapsed" class="fade _button" @click="collapsed = false"> <span>{{ $ts.showMore }}</span> </button> @@ -114,7 +114,7 @@ import * as mfm from 'mfm-js'; import { sum } from '../../prelude/array'; import XSub from './note.sub.vue'; import XNoteHeader from './note-header.vue'; -import XNotePreview from './note-preview.vue'; +import XNoteSimple from './note-simple.vue'; import XReactionsViewer from './reactions-viewer.vue'; import XMediaList from './media-list.vue'; import XCwButton from './cw-button.vue'; @@ -134,7 +134,7 @@ export default defineComponent({ components: { XSub, XNoteHeader, - XNotePreview, + XNoteSimple, XReactionsViewer, XMediaList, XCwButton, @@ -888,7 +888,7 @@ export default defineComponent({ //content-visibility: auto; //contain-intrinsic-size: 0 128px; - &:focus { + &:focus-visible { outline: none; &:after { diff --git a/src/client/components/notification-setting-window.vue b/src/client/components/notification-setting-window.vue index c33106ae15..14e0b76cc6 100644 --- a/src/client/components/notification-setting-window.vue +++ b/src/client/components/notification-setting-window.vue @@ -29,10 +29,10 @@ <script lang="ts"> import { defineComponent, PropType } from 'vue'; import XModalWindow from '@client/components/ui/modal-window.vue'; -import MkSwitch from './ui/switch.vue'; +import MkSwitch from './form/switch.vue'; import MkInfo from './ui/info.vue'; import MkButton from './ui/button.vue'; -import { notificationTypes } from '../../types'; +import { notificationTypes } from '@/types'; export default defineComponent({ components: { diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue index e91f18a693..78c1cce0c7 100644 --- a/src/client/components/notifications.vue +++ b/src/client/components/notifications.vue @@ -26,7 +26,7 @@ import paging from '@client/scripts/paging'; import XNotification from './notification.vue'; import XList from './date-separated-list.vue'; import XNote from './note.vue'; -import { notificationTypes } from '../../types'; +import { notificationTypes } from '@/types'; import * as os from '@client/os'; import MkButton from '@client/components/ui/button.vue'; @@ -48,6 +48,11 @@ export default defineComponent({ required: false, default: null, }, + unreadOnly: { + type: Boolean, + required: false, + default: false, + }, }, data() { @@ -58,6 +63,7 @@ export default defineComponent({ limit: 10, params: () => ({ includeTypes: this.allIncludeTypes || undefined, + unreadOnly: this.unreadOnly, }) }, }; @@ -76,6 +82,11 @@ export default defineComponent({ }, deep: true }, + unreadOnly: { + handler() { + this.reload(); + }, + }, // TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、 // mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す '$i.mutingNotificationTypes': { diff --git a/src/client/components/page-window.vue b/src/client/components/page-window.vue index fbc9f0b7fd..7d15c75d62 100644 --- a/src/client/components/page-window.vue +++ b/src/client/components/page-window.vue @@ -3,14 +3,20 @@ :initial-width="500" :initial-height="500" :can-resize="true" - :close-button="false" + :close-button="true" :contextmenu="contextmenu" @closed="$emit('closed')" > <template #header> - <XHeader :info="pageInfo" :back-button="history.length > 0" @back="back()" :close-button="true" @close="close()" :title-only="true"/> + <template v-if="pageInfo"> + <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i> + <span>{{ pageInfo.title }}</span> + </template> </template> - <div class="yrolvcoq _flat_"> + <template #headerLeft> + <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button> + </template> + <div class="yrolvcoq _fitSide_"> <component :is="component" v-bind="props" :ref="changePage"/> </div> </XWindow> @@ -19,7 +25,6 @@ <script lang="ts"> import { defineComponent } from 'vue'; import XWindow from '@client/components/ui/window.vue'; -import XHeader from '@client/ui/_common_/header.vue'; import { popout } from '@client/scripts/popout'; import copyToClipboard from '@client/scripts/copy-to-clipboard'; import { resolve } from '@client/router'; @@ -29,7 +34,6 @@ import * as symbols from '@client/symbols'; export default defineComponent({ components: { XWindow, - XHeader, }, inject: { @@ -42,7 +46,8 @@ export default defineComponent({ return { navHook: (path) => { this.navigate(path); - } + }, + shouldHeaderThin: true, }; }, diff --git a/src/client/components/page/page.number-input.vue b/src/client/components/page/page.number-input.vue index 9c4a537e15..5d9168f130 100644 --- a/src/client/components/page/page.number-input.vue +++ b/src/client/components/page/page.number-input.vue @@ -8,7 +8,7 @@ <script lang="ts"> import { computed, defineComponent, PropType } from 'vue'; -import MkInput from '../ui/input.vue'; +import MkInput from '../form/input.vue'; import * as os from '@client/os'; import { Hpml } from '@client/scripts/hpml/evaluator'; import { NumberInputVarBlock } from '@client/scripts/hpml/block'; diff --git a/src/client/components/page/page.post.vue b/src/client/components/page/page.post.vue index 7b061d8cda..c20d7cade1 100644 --- a/src/client/components/page/page.post.vue +++ b/src/client/components/page/page.post.vue @@ -10,7 +10,7 @@ <script lang="ts"> import { defineComponent, PropType } from 'vue'; -import MkTextarea from '../ui/textarea.vue'; +import MkTextarea from '../form/textarea.vue'; import MkButton from '../ui/button.vue'; import { apiUrl } from '@client/config'; import * as os from '@client/os'; diff --git a/src/client/components/page/page.radio-button.vue b/src/client/components/page/page.radio-button.vue index f6f146b52f..590e59d706 100644 --- a/src/client/components/page/page.radio-button.vue +++ b/src/client/components/page/page.radio-button.vue @@ -7,7 +7,7 @@ <script lang="ts"> import { computed, defineComponent, PropType } from 'vue'; -import MkRadio from '../ui/radio.vue'; +import MkRadio from '../form/radio.vue'; import * as os from '@client/os'; import { Hpml } from '@client/scripts/hpml/evaluator'; import { RadioButtonVarBlock } from '@client/scripts/hpml/block'; diff --git a/src/client/components/page/page.switch.vue b/src/client/components/page/page.switch.vue index 8818e6cbcf..4d74e5df39 100644 --- a/src/client/components/page/page.switch.vue +++ b/src/client/components/page/page.switch.vue @@ -6,7 +6,7 @@ <script lang="ts"> import { computed, defineComponent, PropType } from 'vue'; -import MkSwitch from '../ui/switch.vue'; +import MkSwitch from '../form/switch.vue'; import * as os from '@client/os'; import { Hpml } from '@client/scripts/hpml/evaluator'; import { SwitchVarBlock } from '@client/scripts/hpml/block'; diff --git a/src/client/components/page/page.text-input.vue b/src/client/components/page/page.text-input.vue index 752d3d7257..6e9ac0b543 100644 --- a/src/client/components/page/page.text-input.vue +++ b/src/client/components/page/page.text-input.vue @@ -8,7 +8,7 @@ <script lang="ts"> import { computed, defineComponent, PropType } from 'vue'; -import MkInput from '../ui/input.vue'; +import MkInput from '../form/input.vue'; import * as os from '@client/os'; import { Hpml } from '@client/scripts/hpml/evaluator'; import { TextInputVarBlock } from '@client/scripts/hpml/block'; diff --git a/src/client/components/page/page.textarea-input.vue b/src/client/components/page/page.textarea-input.vue index e6cf5117f9..dfcb398937 100644 --- a/src/client/components/page/page.textarea-input.vue +++ b/src/client/components/page/page.textarea-input.vue @@ -8,7 +8,7 @@ <script lang="ts"> import { computed, defineComponent, PropType } from 'vue'; -import MkTextarea from '../ui/textarea.vue'; +import MkTextarea from '../form/textarea.vue'; import * as os from '@client/os'; import { Hpml } from '@client/scripts/hpml/evaluator'; import { HpmlTextInput } from '@client/scripts/hpml'; diff --git a/src/client/components/page/page.textarea.vue b/src/client/components/page/page.textarea.vue index 974c7f2c57..cf953bf041 100644 --- a/src/client/components/page/page.textarea.vue +++ b/src/client/components/page/page.textarea.vue @@ -6,7 +6,7 @@ import { TextBlock } from '@client/scripts/hpml/block'; import { Hpml } from '@client/scripts/hpml/evaluator'; import { defineComponent, PropType } from 'vue'; -import MkTextarea from '../ui/textarea.vue'; +import MkTextarea from '../form/textarea.vue'; export default defineComponent({ components: { diff --git a/src/client/components/poll-editor.vue b/src/client/components/poll-editor.vue index 0a9a1c6a03..b28a1c8baa 100644 --- a/src/client/components/poll-editor.vue +++ b/src/client/components/poll-editor.vue @@ -51,9 +51,9 @@ import { defineComponent } from 'vue'; import { addTime } from '../../prelude/time'; import { formatDateTimeString } from '@/misc/format-time-string'; -import MkInput from './ui/input.vue'; -import MkSelect from './ui/select.vue'; -import MkSwitch from './ui/switch.vue'; +import MkInput from './form/input.vue'; +import MkSelect from './form/select.vue'; +import MkSwitch from './form/switch.vue'; import MkButton from './ui/button.vue'; export default defineComponent({ diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue index 657053cc93..a1d89d2a2e 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -1,6 +1,6 @@ <template> <div class="gafaadew" :class="{ modal, _popup: modal }" - v-size="{ max: [500] }" + v-size="{ max: [310, 500] }" @dragover.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @@ -17,12 +17,13 @@ <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span> <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span> </button> - <button class="submit _buttonPrimary" :disabled="!canPost" @click="post" data-cy-open-post-form-submit>{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> + <button class="_button preview" @click="showPreview = !showPreview" :class="{ active: showPreview }" v-tooltip="$ts.previewNoteText"><i class="fas fa-file-code"></i></button> + <button class="submit _buttonGradate" :disabled="!canPost" @click="post" data-cy-open-post-form-submit>{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> </div> </header> <div class="form" :class="{ fixed }"> - <XNotePreview class="preview" v-if="reply" :note="reply"/> - <XNotePreview class="preview" v-if="renote" :note="renote"/> + <XNoteSimple class="preview" v-if="reply" :note="reply"/> + <XNoteSimple class="preview" v-if="renote" :note="renote"/> <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div> <div v-if="visibility === 'specified'" class="to-specified"> <span style="margin-right: 8px;">{{ $ts.recipient }}</span> @@ -40,6 +41,7 @@ <input v-show="withHashtags" ref="hashtags" class="hashtags" v-model="hashtags" :placeholder="$ts.hashtags" list="hashtags"> <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> + <XNotePreview class="preview" v-if="showPreview" :text="text"/> <footer> <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button> <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button> @@ -61,6 +63,7 @@ import { defineComponent, defineAsyncComponent } from 'vue'; import insertTextAtCursor from 'insert-text-at-cursor'; import { length } from 'stringz'; import { toASCII } from 'punycode/'; +import XNoteSimple from './note-simple.vue'; import XNotePreview from './note-preview.vue'; import * as mfm from 'mfm-js'; import { host, url } from '@client/config'; @@ -80,6 +83,7 @@ import { defaultStore } from '@client/store'; export default defineComponent({ components: { + XNoteSimple, XNotePreview, XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')), XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')), @@ -143,6 +147,7 @@ export default defineComponent({ files: [], poll: null, useCw: false, + showPreview: false, cw: null, localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility, @@ -717,7 +722,7 @@ export default defineComponent({ > .visibility { height: 34px; width: 34px; - margin: 0 8px; + margin: 0 0 0 8px; & + .localOnly { margin-left: 0 !important; @@ -729,6 +734,24 @@ export default defineComponent({ opacity: 0.7; } + > .preview { + display: inline-block; + padding: 0; + margin: 0 8px 0 0; + font-size: 16px; + width: 34px; + height: 34px; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + > .submit { margin: 16px 16px 16px 0; padding: 0 12px; @@ -736,6 +759,7 @@ export default defineComponent({ font-weight: bold; vertical-align: bottom; border-radius: 4px; + font-size: 0.9em; &:disabled { opacity: 0.7; @@ -819,7 +843,7 @@ export default defineComponent({ color: var(--fg); font-family: inherit; - &:focus { + &:focus-visible { outline: none; } @@ -914,5 +938,17 @@ export default defineComponent({ } } } + + &.max-width_310px { + > .form { + > footer { + > button { + font-size: 14px; + width: 44px; + height: 44px; + } + } + } + } } </style> diff --git a/src/client/components/sample.vue b/src/client/components/sample.vue index bce02466f6..c8b46a80e7 100644 --- a/src/client/components/sample.vue +++ b/src/client/components/sample.vue @@ -30,10 +30,10 @@ <script lang="ts"> import { defineComponent } from 'vue'; import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/ui/input.vue'; -import MkSwitch from '@client/components/ui/switch.vue'; -import MkTextarea from '@client/components/ui/textarea.vue'; -import MkRadio from '@client/components/ui/radio.vue'; +import MkInput from '@client/components/form/input.vue'; +import MkSwitch from '@client/components/form/switch.vue'; +import MkTextarea from '@client/components/form/textarea.vue'; +import MkRadio from '@client/components/form/radio.vue'; import * as os from '@client/os'; import * as config from '@client/config'; diff --git a/src/client/components/signin.vue b/src/client/components/signin.vue index 69f527b7d6..d6e1ee8b68 100755 --- a/src/client/components/signin.vue +++ b/src/client/components/signin.vue @@ -1,17 +1,17 @@ <template> <form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> - <div class="auth _section"> + <div class="auth _section _formRoot"> <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div> <div class="normal-signin" v-if="!totpLogin"> - <MkInput v-model="username" :placeholder="$ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @update:modelValue="onUsernameChange" data-cy-signin-username> + <MkInput class="_formBlock" v-model="username" :placeholder="$ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @update:modelValue="onUsernameChange" data-cy-signin-username> <template #prefix>@</template> <template #suffix>@{{ host }}</template> </MkInput> - <MkInput v-model="password" :placeholder="$ts.password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required data-cy-signin-password> + <MkInput class="_formBlock" v-model="password" :placeholder="$ts.password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required data-cy-signin-password> <template #prefix><i class="fas fa-lock"></i></template> <template #caption><button class="_textButton" @click="resetPassword" type="button">{{ $ts.forgotPassword }}</button></template> </MkInput> - <MkButton type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton> + <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton> </div> <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }"> <div v-if="user && user.securityKeys" class="twofa-group tap-group"> @@ -49,7 +49,7 @@ import { defineComponent } from 'vue'; import { toUnicode } from 'punycode/'; import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/ui/input.vue'; +import MkInput from '@client/components/form/input.vue'; import { apiUrl, host } from '@client/config'; import { byteify, hexify } from '@client/scripts/2fa'; import * as os from '@client/os'; diff --git a/src/client/components/signup-dialog.vue b/src/client/components/signup-dialog.vue index df1a525055..9741e8c73b 100644 --- a/src/client/components/signup-dialog.vue +++ b/src/client/components/signup-dialog.vue @@ -9,7 +9,7 @@ <div class="_monolithic_"> <div class="_section"> - <XSignup :auto-set="autoSet" @signup="onSignup"/> + <XSignup :auto-set="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> </div> </div> </XModalWindow> @@ -40,6 +40,10 @@ export default defineComponent({ onSignup(res) { this.$emit('done', res); this.$refs.dialog.close(); + }, + + onSignupEmailPending() { + this.$refs.dialog.close(); } } }); diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue index d332274111..cb25eadf06 100644 --- a/src/client/components/signup.vue +++ b/src/client/components/signup.vue @@ -1,25 +1,35 @@ <template> -<form class="qlvuhzng" @submit.prevent="onSubmit" :autocomplete="Math.random()"> +<form class="qlvuhzng _formRoot" @submit.prevent="onSubmit" :autocomplete="Math.random()"> <template v-if="meta"> - <MkInput class="_inputNoTopMargin" v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> + <MkInput class="_formBlock" v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> <template #label>{{ $ts.invitationCode }}</template> <template #prefix><i class="fas fa-key"></i></template> </MkInput> - <MkInput class="_inputNoTopMargin" v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeUsername" data-cy-signup-username> + <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeUsername" data-cy-signup-username> <template #label>{{ $ts.username }} <div class="_button _help" v-tooltip:dialog="$ts.usernameInfo"><i class="far fa-question-circle"></i></div></template> <template #prefix>@</template> <template #suffix>@{{ host }}</template> <template #caption> - <span v-if="usernameState == 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> - <span v-if="usernameState == 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> - <span v-if="usernameState == 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> - <span v-if="usernameState == 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> - <span v-if="usernameState == 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span> - <span v-if="usernameState == 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span> - <span v-if="usernameState == 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span> + <span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> + <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> + <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> + <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> + <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span> + <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span> + <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span> </template> </MkInput> - <MkInput v-model="password" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePassword" data-cy-signup-password> + <MkInput v-if="meta.emailRequiredForSignup" class="_formBlock" v-model="email" type="email" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeEmail" data-cy-signup-email> + <template #label>{{ $ts.emailAddress }} <div class="_button _help" v-tooltip:dialog="$ts._signup.emailAddressInfo"><i class="far fa-question-circle"></i></div></template> + <template #prefix><i class="fas fa-envelope"></i></template> + <template #caption> + <span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> + <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> + <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> + <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> + </template> + </MkInput> + <MkInput class="_formBlock" v-model="password" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePassword" data-cy-signup-password> <template #label>{{ $ts.password }}</template> <template #prefix><i class="fas fa-lock"></i></template> <template #caption> @@ -28,7 +38,7 @@ <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span> </template> </MkInput> - <MkInput v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePasswordRetype" data-cy-signup-password-retype> + <MkInput class="_formBlock" v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePasswordRetype" data-cy-signup-password-retype> <template #label>{{ $ts.password }} ({{ $ts.retype }})</template> <template #prefix><i class="fas fa-lock"></i></template> <template #caption> @@ -36,7 +46,7 @@ <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span> </template> </MkInput> - <label v-if="meta.tosUrl" class="tou"> + <label v-if="meta.tosUrl" class="_formBlock tou"> <input type="checkbox" v-model="ToSAgreement"> <I18n :src="$ts.agreeTo"> <template #0> @@ -44,9 +54,9 @@ </template> </I18n> </label> - <captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model:value="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> - <captcha v-if="meta.enableRecaptcha" class="captcha" provider="recaptcha" ref="recaptcha" v-model:value="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> - <MkButton type="submit" :disabled="shouldDisableSubmitting" primary data-cy-signup-submit>{{ $ts.start }}</MkButton> + <captcha v-if="meta.enableHcaptcha" class="_formBlock captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> + <captcha v-if="meta.enableRecaptcha" class="_formBlock captcha" provider="recaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> + <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton> </template> </form> </template> @@ -57,8 +67,8 @@ const getPasswordStrength = require('syuilo-password-strength'); import { toUnicode } from 'punycode/'; import { host, url } from '@client/config'; import MkButton from './ui/button.vue'; -import MkInput from './ui/input.vue'; -import MkSwitch from './ui/switch.vue'; +import MkInput from './form/input.vue'; +import MkSwitch from './form/switch.vue'; import * as os from '@client/os'; import { login } from '@client/account'; @@ -87,8 +97,10 @@ export default defineComponent({ password: '', retypedPassword: '', invitationCode: '', + email: '', url, usernameState: null, + emailState: null, passwordStrength: '', passwordRetypeState: null, submitting: false, @@ -148,6 +160,23 @@ export default defineComponent({ }); }, + onChangeEmail() { + if (this.email == '') { + this.emailState = null; + return; + } + + this.emailState = 'wait'; + + os.api('email-address/available', { + emailAddress: this.email + }).then(result => { + this.emailState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.emailState = 'error'; + }); + }, + onChangePassword() { if (this.password == '') { this.passwordStrength = ''; @@ -174,20 +203,30 @@ export default defineComponent({ os.api('signup', { username: this.username, password: this.password, + emailAddress: this.email, invitationCode: this.invitationCode, 'hcaptcha-response': this.hCaptchaResponse, 'g-recaptcha-response': this.reCaptchaResponse, }).then(() => { - return os.api('signin', { - username: this.username, - password: this.password - }).then(res => { - this.$emit('signup', res); + if (this.meta.emailRequiredForSignup) { + os.dialog({ + type: 'success', + title: this.$ts._signup.almostThere, + text: this.$t('_signup.emailSent', { email: this.email }), + }); + this.$emit('signupEmailPending'); + } else { + os.api('signin', { + username: this.username, + password: this.password + }).then(res => { + this.$emit('signup', res); - if (this.autoSet) { - return login(res.i); - } - }); + if (this.autoSet) { + login(res.i); + } + }); + } }).catch(() => { this.submitting = false; this.$refs.hcaptcha?.reset?.(); diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue index 3902b7f98f..ce86af8f95 100644 --- a/src/client/components/tab.vue +++ b/src/client/components/tab.vue @@ -3,7 +3,7 @@ import { defineComponent, h, resolveDirective, withDirectives } from 'vue'; export default defineComponent({ props: { - value: { + modelValue: { required: true, }, }, @@ -13,11 +13,11 @@ export default defineComponent({ return withDirectives(h('div', { class: 'pxhvhrfw', }, options.map(option => withDirectives(h('button', { - class: ['_button', { active: this.value === option.props.value }], + class: ['_button', { active: this.modelValue === option.props.value }], key: option.key, - disabled: this.value === option.props.value, + disabled: this.modelValue === option.props.value, onClick: () => { - this.$emit('update:value', option.props.value); + this.$emit('update:modelValue', option.props.value); } }, option.children), [ [resolveDirective('click-anime')] @@ -35,8 +35,8 @@ export default defineComponent({ > button { flex: 1; - padding: 15px 12px 12px 12px; - border-bottom: solid 3px transparent; + padding: 10px 8px; + border-radius: 6px; &:disabled { opacity: 1 !important; @@ -45,11 +45,16 @@ export default defineComponent({ &.active { color: var(--accent); - border-bottom-color: var(--accent); + background: var(--accentedBg); } &:not(.active):hover { color: var(--fgHighlighted); + background: var(--panelHighlight); + } + + &:not(:first-child) { + margin-left: 8px; } > .icon { @@ -61,7 +66,7 @@ export default defineComponent({ font-size: 80%; > button { - padding: 11px 8px 8px 8px; + padding: 11px 8px; } } } diff --git a/src/client/components/taskmanager.api-window.vue b/src/client/components/taskmanager.api-window.vue index c9b2c43413..807e4a0075 100644 --- a/src/client/components/taskmanager.api-window.vue +++ b/src/client/components/taskmanager.api-window.vue @@ -9,7 +9,7 @@ <template #header>Req Viewer</template> <div class="rlkneywz"> - <MkTab v-model:value="tab" style="border-bottom: solid 0.5px var(--divider);"> + <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);"> <option value="req">Request</option> <option value="res">Response</option> </MkTab> diff --git a/src/client/components/taskmanager.vue b/src/client/components/taskmanager.vue index cb8cb78748..6f3d1b0354 100644 --- a/src/client/components/taskmanager.vue +++ b/src/client/components/taskmanager.vue @@ -4,7 +4,7 @@ <i class="fas fa-terminal" style="margin-right: 0.5em;"></i>Task Manager </template> <div class="qljqmnzj _monospace"> - <MkTab v-model:value="tab" style="border-bottom: solid 0.5px var(--divider);"> + <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);"> <option value="windows">Windows</option> <option value="stream">Stream</option> <option value="streamPool">Stream (Pool)</option> diff --git a/src/client/components/token-generate-window.vue b/src/client/components/token-generate-window.vue index fe61f61efa..86312564cc 100644 --- a/src/client/components/token-generate-window.vue +++ b/src/client/components/token-generate-window.vue @@ -31,9 +31,9 @@ import { defineComponent } from 'vue'; import { kinds } from '@/misc/api-permissions'; import XModalWindow from '@client/components/ui/modal-window.vue'; -import MkInput from './ui/input.vue'; -import MkTextarea from './ui/textarea.vue'; -import MkSwitch from './ui/switch.vue'; +import MkInput from './form/input.vue'; +import MkTextarea from './form/textarea.vue'; +import MkSwitch from './form/switch.vue'; import MkButton from './ui/button.vue'; import MkInfo from './ui/info.vue'; diff --git a/src/client/components/ui/button.vue b/src/client/components/ui/button.vue index d6ac42994f..b5f4547c84 100644 --- a/src/client/components/ui/button.vue +++ b/src/client/components/ui/button.vue @@ -1,7 +1,6 @@ <template> -<component class="bghgjjyj _button" - :is="link ? 'MkA' : 'button'" - :class="{ inline, primary, danger, full }" +<button v-if="!link" class="bghgjjyj _button" + :class="{ inline, primary, gradate, danger, rounded, full }" :type="type" @click="$emit('click', $event)" @mousedown="onMousedown" @@ -10,7 +9,17 @@ <div class="content"> <slot></slot> </div> -</component> +</button> +<MkA v-else class="bghgjjyj _button" + :class="{ inline, primary, gradate, danger, rounded, full }" + :to="to" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</MkA> </template> <script lang="ts"> @@ -27,6 +36,16 @@ export default defineComponent({ required: false, default: false }, + gradate: { + type: Boolean, + required: false, + default: false + }, + rounded: { + type: Boolean, + required: false, + default: false + }, inline: { type: Boolean, required: false, @@ -37,6 +56,10 @@ export default defineComponent({ required: false, default: false }, + to: { + type: String, + required: false + }, autofocus: { type: Boolean, required: false, @@ -119,13 +142,13 @@ export default defineComponent({ padding: 8px 14px; text-align: center; font-weight: normal; - font-size: 0.9em; - line-height: 24px; + font-size: 0.8em; + line-height: 22px; box-shadow: none; text-decoration: none; background: var(--buttonBg); - border-radius: 999px; - overflow: hidden; + border-radius: 4px; + overflow: clip; box-sizing: border-box; transition: background 0.1s ease; @@ -141,6 +164,10 @@ export default defineComponent({ width: 100%; } + &.rounded { + border-radius: 999px; + } + &.primary { font-weight: bold; color: var(--fgOnAccent) !important; @@ -155,6 +182,20 @@ export default defineComponent({ } } + &.gradate { + font-weight: bold; + color: var(--fgOnAccent) !important; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + + &:not(:disabled):hover { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + + &:not(:disabled):active { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + } + &.danger { color: #ff2a2a; @@ -176,19 +217,11 @@ export default defineComponent({ opacity: 0.7; } - &:focus { + &:focus-visible { outline: solid 2px var(--focus); outline-offset: 2px; } - &.inline + .bghgjjyj { - margin-left: 12px; - } - - &:not(.inline) + .bghgjjyj { - margin-top: 16px; - } - &.inline { display: inline-block; width: auto; diff --git a/src/client/components/ui/folder.vue b/src/client/components/ui/folder.vue index eecf1d8be1..d0616a57c1 100644 --- a/src/client/components/ui/folder.vue +++ b/src/client/components/ui/folder.vue @@ -1,6 +1,6 @@ <template> <div class="ssazuxis" v-size="{ max: [500] }"> - <header @click="showBody = !showBody" class="_button"> + <header @click="showBody = !showBody" class="_button" :style="{ background: bg }"> <div class="title"><slot name="header"></slot></div> <div class="divider"></div> <button class="_button"> @@ -23,6 +23,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; const localStoragePrefix = 'ui:folder:'; @@ -41,6 +42,7 @@ export default defineComponent({ }, data() { return { + bg: null, showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded, }; }, @@ -51,6 +53,21 @@ export default defineComponent({ } } }, + mounted() { + function getParentBg(el: Element | null): string { + if (el == null || el.tagName === 'BODY') return 'var(--bg)'; + const bg = el.style.background || el.style.backgroundColor; + if (bg) { + return bg; + } else { + return getParentBg(el.parentElement); + } + } + const rawBg = getParentBg(this.$el); + const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + bg.setAlpha(0.85); + this.bg = bg.toRgbString(); + }, methods: { toggleContent(show: boolean) { this.showBody = show; @@ -100,12 +117,8 @@ export default defineComponent({ position: sticky; top: var(--stickyTop, 0px); padding: var(--x-padding); - background: var(--x-header, var(--panel)); - /* TODO panelの半透明バージョンをプログラマティックに作りたい - background: var(--X17); -webkit-backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(20px)); - */ > .title { margin: 0; @@ -141,7 +154,7 @@ export default defineComponent({ } } -._flat_ .ssazuxis { +._fitSide_ .ssazuxis { > header { padding: 0 16px; } diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue index 513682ef55..e16f2736f1 100644 --- a/src/client/components/ui/info.vue +++ b/src/client/components/ui/info.vue @@ -27,7 +27,6 @@ export default defineComponent({ <style lang="scss" scoped> .fpezltsf { - margin: 16px 0; padding: 16px; font-size: 90%; background: var(--infoBg); @@ -39,20 +38,12 @@ export default defineComponent({ color: var(--infoWarnFg); } - &:first-child { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } - > i { margin-right: 4px; } } -._flat_ .fpezltsf { +._fitSide_ .fpezltsf { border-radius: 0; } </style> diff --git a/src/client/components/ui/menu.vue b/src/client/components/ui/menu.vue index 26b4b04b11..da24d90170 100644 --- a/src/client/components/ui/menu.vue +++ b/src/client/components/ui/menu.vue @@ -1,5 +1,5 @@ <template> -<div class="rrevdjwt" :class="{ left: align === 'left', pointer: point === 'top' }" +<div class="rrevdjwt" :class="{ center: align === 'center' }" ref="items" @contextmenu.self="e => e.preventDefault()" v-hotkey="keymap" @@ -27,7 +27,7 @@ <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> </button> - <button v-else @click="clicked(item.action, $event)" :tabindex="i" class="_button item" :class="{ danger: item.danger }"> + <button v-else @click="clicked(item.action, $event)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active"> <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> <span>{{ item.text }}</span> @@ -59,10 +59,6 @@ export default defineComponent({ type: String, requried: false }, - point: { - type: String, - requried: false - }, }, emits: ['close'], data() { @@ -145,68 +141,83 @@ export default defineComponent({ <style lang="scss" scoped> .rrevdjwt { padding: 8px 0; + min-width: 200px; - &.pointer { - &:before { - --size: 8px; - content: ''; - display: block; - position: absolute; - top: calc(0px - (var(--size) * 2)); - left: 0; - right: 0; - width: 0; - margin: auto; - border: solid var(--size) transparent; - border-bottom-color: var(--popup); - } - } - - &.left { + &.center { > .item { - text-align: left; + text-align: center; } } > .item { display: block; position: relative; - padding: 8px 16px; + padding: 8px 18px; width: 100%; box-sizing: border-box; white-space: nowrap; font-size: 0.9em; line-height: 20px; - text-align: center; + text-align: left; overflow: hidden; text-overflow: ellipsis; + &:before { + content: ""; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + margin: auto; + width: calc(100% - 16px); + height: 100%; + border-radius: 6px; + } + + > * { + position: relative; + } + &.danger { color: #ff2a2a; &:hover { color: #fff; - background: #ff4242; + + &:before { + background: #ff4242; + } } &:active { color: #fff; - background: #d42e2e; + + &:before { + background: #d42e2e; + } } } - &:hover { + &.active { color: var(--fgOnAccent); - background: var(--accent); - text-decoration: none; + opacity: 1; + + &:before { + background: var(--accent); + } } - &:active { - color: var(--fgOnAccent); - background: var(--accentDarken); + &:not(:disabled):hover { + color: var(--accent); + text-decoration: none; + + &:before { + background: var(--accentedBg); + } } - &:not(:active):focus { + &:not(:active):focus-visible { box-shadow: 0 0 0 2px var(--focus) inset; } @@ -231,12 +242,12 @@ export default defineComponent({ } > i { - margin-right: 4px; + margin-right: 5px; width: 20px; } > .avatar { - margin-right: 4px; + margin-right: 5px; width: 20px; height: 20px; } diff --git a/src/client/components/ui/popup-menu.vue b/src/client/components/ui/popup-menu.vue index 3590426172..23f7c89f3b 100644 --- a/src/client/components/ui/popup-menu.vue +++ b/src/client/components/ui/popup-menu.vue @@ -1,6 +1,6 @@ <template> -<MkPopup ref="popup" :src="src" @closed="$emit('closed')" #default="{point}"> - <MkMenu :items="items" :align="align" :point="point" @close="$refs.popup.close()" class="_popup _shadow"/> +<MkPopup ref="popup" :src="src" @closed="$emit('closed')"> + <MkMenu :items="items" :align="align" @close="$refs.popup.close()" class="_popup _shadow"/> </MkPopup> </template> diff --git a/src/client/components/ui/popup.vue b/src/client/components/ui/popup.vue index 8497eedecb..0fb1780cc5 100644 --- a/src/client/components/ui/popup.vue +++ b/src/client/components/ui/popup.vue @@ -1,7 +1,7 @@ <template> <transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered"> <div v-show="manualShowing != null ? manualShowing : showing" class="ccczpooj" :class="{ front, fixed, top: position === 'top' }" ref="content" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> - <slot :point="point"></slot> + <slot></slot> </div> </transition> </template> @@ -52,7 +52,6 @@ export default defineComponent({ fixed: false, transformOrigin: 'center', contentClicking: false, - point: null, }; }, @@ -136,10 +135,8 @@ export default defineComponent({ } if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { - this.point = 'top'; this.transformOrigin = 'center top'; } else { - this.point = null; this.transformOrigin = 'center'; } diff --git a/src/client/components/ui/radios.vue b/src/client/components/ui/radios.vue deleted file mode 100644 index 8a62b87683..0000000000 --- a/src/client/components/ui/radios.vue +++ /dev/null @@ -1,58 +0,0 @@ -<script lang="ts"> -import { defineComponent, h } from 'vue'; -import MkRadio from '@client/components/ui/radio.vue'; - -export default defineComponent({ - components: { - MkRadio - }, - props: { - modelValue: { - required: false - }, - }, - data() { - return { - value: this.modelValue, - } - }, - watch: { - value() { - this.$emit('update:modelValue', this.value); - } - }, - render() { - const label = this.$slots.desc(); - let options = this.$slots.default(); - - // なぜかFragmentになることがあるため - if (options.length === 1 && options[0].props == null) options = options[0].children; - - return h('div', { - class: 'novjtcto' - }, [ - h('div', label), - ...options.map(option => h(MkRadio, { - key: option.key, - value: option.props.value, - modelValue: this.value, - 'onUpdate:modelValue': value => this.value = value, - }, option.children)) - ]); - } -}); -</script> - -<style lang="scss"> -.novjtcto { - margin: 32px 0; - - &:first-child { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } -} -</style> diff --git a/src/client/components/ui/range.vue b/src/client/components/ui/range.vue deleted file mode 100644 index 4cfe66a8fc..0000000000 --- a/src/client/components/ui/range.vue +++ /dev/null @@ -1,139 +0,0 @@ -<template> -<div class="timctyfi" :class="{ focused, disabled }"> - <div class="icon"><slot name="icon"></slot></div> - <span class="label"><slot name="label"></slot></span> - <input - type="range" - ref="input" - v-model="v" - :disabled="disabled" - :min="min" - :max="max" - :step="step" - :autofocus="autofocus" - @focus="focused = true" - @blur="focused = false" - @input="$emit('update:value', $event.target.value)" - /> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - value: { - type: Number, - required: false, - default: 0 - }, - disabled: { - type: Boolean, - required: false, - default: false - }, - min: { - type: Number, - required: false, - default: 0 - }, - max: { - type: Number, - required: false, - default: 100 - }, - step: { - type: Number, - required: false, - default: 1 - }, - autofocus: { - type: Boolean, - required: false - } - }, - data() { - return { - v: this.value, - focused: false - }; - }, - watch: { - value(v) { - this.v = parseFloat(v); - } - }, - mounted() { - if (this.autofocus) { - this.$nextTick(() => { - this.$refs.input.focus(); - }); - } - } -}); -</script> - -<style lang="scss" scoped> -.timctyfi { - position: relative; - margin: 8px; - - > .icon { - display: inline-block; - width: 24px; - text-align: center; - } - - > .title { - pointer-events: none; - font-size: 16px; - color: var(--inputLabel); - overflow: hidden; - } - - > input { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: var(--X10); - height: 7px; - margin: 0 8px; - outline: 0; - border: 0; - border-radius: 7px; - - &.disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - box-sizing: content-box; - } - - &::-moz-range-thumb { - -moz-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - } - } -} -</style> diff --git a/src/client/components/ui/select.vue b/src/client/components/ui/select.vue deleted file mode 100644 index e9d43d8a64..0000000000 --- a/src/client/components/ui/select.vue +++ /dev/null @@ -1,262 +0,0 @@ -<template> -<div class="vblkjoeq"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ inline, disabled, focused }"> - <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> - <select ref="inputEl" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - @focus="focused = true" - @blur="focused = false" - @input="onInput" - > - <slot></slot> - </select> - <div class="suffix" ref="suffixEl"><i class="fas fa-chevron-down"></i></div> - </div> - <div class="caption"><slot name="caption"></slot></div> - - <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from './button.vue'; - -export default defineComponent({ - components: { - MkButton, - }, - - props: { - modelValue: { - required: true - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - inline: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['change', 'update:modelValue'], - - setup(props, context) { - const { modelValue, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); - const prefixEl = ref(null); - const suffixEl = ref(null); - - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; - - watch(modelValue, newValue => { - v.value = newValue; - }); - - watch(v, newValue => { - if (!props.manualSave) { - updated(); - } - - invalid.value = inputEl.value.validity.badInput; - }); - - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } - - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); - - onUnmounted(() => { - clearInterval(clock); - }); - }); - }); - - return { - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - focus, - onInput, - updated, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.vblkjoeq { - margin: 1.5em 0; - - > .label { - font-size: 0.85em; - padding: 0 0 8px 12px; - user-select: none; - - &:empty { - display: none; - } - } - - > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; - color: var(--fgTransparentWeak); - - &:empty { - display: none; - } - } - - > .input { - $height: 42px; - position: relative; - - > select { - appearance: none; - -webkit-appearance: none; - display: block; - height: $height; - width: 100%; - margin: 0; - padding: 0 12px; - font: inherit; - font-weight: normal; - font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 1px var(--inputBorder); - border-radius: 6px; - outline: none; - box-shadow: none; - box-sizing: border-box; - cursor: pointer; - transition: border-color 0.1s ease-out; - - &:hover { - border-color: var(--inputBorderHover); - } - } - - > .prefix, - > .suffix { - display: flex; - align-items: center; - position: absolute; - z-index: 1; - top: 0; - padding: 0 12px; - font-size: 1em; - height: $height; - pointer-events: none; - - &:empty { - display: none; - } - - > * { - display: inline-block; - min-width: 16px; - max-width: 150px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - > .prefix { - left: 0; - padding-right: 6px; - } - - > .suffix { - right: 0; - padding-left: 6px; - } - - &.inline { - display: inline-block; - margin: 0; - } - - &.focused { - > select { - border-color: var(--accent); - } - } - - &.disabled { - opacity: 0.7; - - &, * { - cursor: not-allowed !important; - } - } - } -} -</style> diff --git a/src/client/components/ui/super-menu.vue b/src/client/components/ui/super-menu.vue new file mode 100644 index 0000000000..35fc81550d --- /dev/null +++ b/src/client/components/ui/super-menu.vue @@ -0,0 +1,151 @@ +<template> +<div class="rrevdjwu" :class="{ grid }"> + <div class="group" v-for="group in def"> + <div class="title" v-if="group.title">{{ group.title }}</div> + + <div class="items"> + <template v-for="(item, i) in group.items"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </a> + <button v-else-if="item.type === 'button'" @click="ev => item.action(ev)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </button> + <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </MkA> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref, unref } from 'vue'; + +export default defineComponent({ + props: { + def: { + type: Array, + required: true + }, + grid: { + type: Boolean, + required: false, + default: false, + }, + }, +}); +</script> + +<style lang="scss" scoped> +.rrevdjwu { + > .group { + & + .group { + margin-top: 16px; + padding-top: 16px; + border-top: solid 0.5px var(--divider); + } + + margin-left: 16px; + margin-right: 16px; + + > .title { + font-size: 0.9em; + opacity: 0.7; + margin: 0 0 8px 12px; + } + + > .items { + > .item { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 10px 16px 10px 8px; + border-radius: 9px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--panelHighlight); + } + + &.active { + color: var(--accent); + background: var(--accentedBg); + } + + &.danger { + color: var(--error); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + } + + > .text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 12px; + } + + } + } + } + + &.grid { + > .group { + & + .group { + padding-top: 0; + border-top: none; + } + + margin-left: 0; + margin-right: 0; + + > .title { + font-size: 1em; + opacity: 0.7; + margin: 0 0 8px 16px; + } + + > .items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + grid-gap: 8px; + padding: 0 16px; + + > .item { + flex-direction: column; + padding: 18px 16px 16px 16px; + background: var(--panel); + border-radius: 8px; + text-align: center; + + > .icon { + display: block; + margin-right: 0; + margin-bottom: 12px; + font-size: 1.5em; + } + + > .text { + padding-right: 0; + width: 100%; + font-size: 0.8em; + } + } + } + } + } +} +</style> diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue deleted file mode 100644 index 7aa9c0619d..0000000000 --- a/src/client/components/ui/switch.vue +++ /dev/null @@ -1,144 +0,0 @@ -<template> -<div - class="ziffeoms" - :class="{ disabled, checked }" - role="switch" - :aria-checked="checked" - :aria-disabled="disabled" - @click.prevent="toggle" -> - <input - type="checkbox" - ref="input" - :disabled="disabled" - @keydown.enter="toggle" - > - <span class="button"> - <span></span> - </span> - <span class="label"> - <span><slot></slot></span> - <p><slot name="caption"></slot></p> - </span> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - modelValue: { - type: Boolean, - default: false - }, - disabled: { - type: Boolean, - default: false - } - }, - computed: { - checked(): boolean { - return this.modelValue; - } - }, - methods: { - toggle() { - if (this.disabled) return; - this.$emit('update:modelValue', !this.checked); - } - } -}); -</script> - -<style lang="scss" scoped> -.ziffeoms { - position: relative; - display: flex; - margin: 32px 0; - cursor: pointer; - transition: all 0.3s; - - &:first-child { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } - - > * { - user-select: none; - } - - &.disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &.checked { - > .button { - background-color: var(--X10); - border-color: var(--X10); - - > * { - background-color: var(--accent); - transform: translateX(14px); - } - } - } - - > input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; - } - - > .button { - position: relative; - display: inline-block; - flex-shrink: 0; - margin: 3px 0 0 0; - width: 34px; - height: 14px; - background: var(--X6); - outline: none; - border-radius: 14px; - transition: inherit; - - > * { - position: absolute; - top: -3px; - left: 0; - border-radius: 100%; - transition: background-color 0.3s, transform 0.3s; - width: 20px; - height: 20px; - background-color: #fff; - box-shadow: 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12); - } - } - - > .label { - margin-left: 8px; - display: block; - cursor: pointer; - transition: inherit; - color: var(--fg); - - > span { - display: block; - line-height: 20px; - transition: inherit; - } - - > p { - margin: 0; - color: var(--fgTransparentWeak); - font-size: 90%; - } - } -} -</style> diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue deleted file mode 100644 index 08ac3182a9..0000000000 --- a/src/client/components/ui/textarea.vue +++ /dev/null @@ -1,254 +0,0 @@ -<template> -<div class="adhpbeos"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ disabled, focused, tall, pre }"> - <textarea ref="inputEl" - :class="{ code, _monospace: code }" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - ></textarea> - </div> - <div class="caption"><slot name="caption"></slot></div> - - <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from './button.vue'; -import { debounce } from 'throttle-debounce'; - -export default defineComponent({ - components: { - MkButton, - }, - - props: { - modelValue: { - required: true - }, - type: { - type: String, - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - autocomplete: { - required: false - }, - spellcheck: { - required: false - }, - code: { - type: Boolean, - required: false - }, - tall: { - type: Boolean, - required: false, - default: false - }, - pre: { - type: Boolean, - required: false, - default: false - }, - debounce: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['change', 'keydown', 'enter', 'update:modelValue'], - - setup(props, context) { - const { modelValue, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); - - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - const onKeydown = (ev: KeyboardEvent) => { - context.emit('keydown', ev); - - if (ev.code === 'Enter') { - context.emit('enter'); - } - }; - - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; - - const debouncedUpdated = debounce(1000, updated); - - watch(modelValue, newValue => { - v.value = newValue; - }); - - watch(v, newValue => { - if (!props.manualSave) { - if (props.debounce) { - debouncedUpdated(); - } else { - updated(); - } - } - - invalid.value = inputEl.value.validity.badInput; - }); - - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } - }); - }); - - return { - v, - focused, - invalid, - changed, - filled, - inputEl, - focus, - onInput, - onKeydown, - updated, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.adhpbeos { - margin: 1.5em 0; - - > .label { - font-size: 0.85em; - padding: 0 0 8px 12px; - user-select: none; - - &:empty { - display: none; - } - } - - > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; - color: var(--fgTransparentWeak); - - &:empty { - display: none; - } - } - - > .input { - position: relative; - - > textarea { - appearance: none; - -webkit-appearance: none; - display: block; - width: 100%; - min-width: 100%; - max-width: 100%; - min-height: 130px; - margin: 0; - padding: 12px; - font: inherit; - font-weight: normal; - font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 0.5px var(--inputBorder); - border-radius: 6px; - outline: none; - box-shadow: none; - box-sizing: border-box; - transition: border-color 0.1s ease-out; - - &:hover { - border-color: var(--inputBorderHover); - } - } - - &.focused { - > textarea { - border-color: var(--accent); - } - } - - &.disabled { - opacity: 0.7; - - &, * { - cursor: not-allowed !important; - } - } - - &.tall { - > textarea { - min-height: 200px; - } - } - - &.pre { - > textarea { - white-space: pre; - } - } - } -} -</style> diff --git a/src/client/components/ui/window.vue b/src/client/components/ui/window.vue index 773c3b9b13..00284b0467 100644 --- a/src/client/components/ui/window.vue +++ b/src/client/components/ui/window.vue @@ -3,11 +3,16 @@ <div class="ebkgocck" :class="{ front }" v-if="showing"> <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> - <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button> - + <span class="left"> + <slot name="headerLeft"></slot> + </span> <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> <slot name="header"></slot> </span> + <span class="right"> + <slot name="headerRight"></slot> + <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button> + </span> </div> <div class="body" v-if="padding"> <div class="_section"> @@ -377,7 +382,7 @@ export default defineComponent({ <style lang="scss" scoped> .window-enter-active, .window-leave-active { - transition: opacity 0.3s, transform 0.3s !important; + transition: opacity 0.2s, transform 0.2s !important; } .window-enter-from, .window-leave-to { pointer-events: none; @@ -418,12 +423,14 @@ export default defineComponent({ height: var(--height); border-bottom: solid 1px var(--divider); - > ::v-deep(button) { - height: var(--height); - width: var(--height); + > .left, > .right { + > ::v-deep(button) { + height: var(--height); + width: var(--height); - &:hover { - color: var(--fgHighlighted); + &:hover { + color: var(--fgHighlighted); + } } } diff --git a/src/client/components/user-select-dialog.vue b/src/client/components/user-select-dialog.vue index 87c32dab25..0f3ee2a126 100644 --- a/src/client/components/user-select-dialog.vue +++ b/src/client/components/user-select-dialog.vue @@ -10,7 +10,7 @@ <template #header>{{ $ts.selectUser }}</template> <div class="tbhwbxda _monolithic_"> <div class="_section"> - <div class="_inputSplit _inputNoTopMargin _inputNoBottomMargin"> + <div class="_inputSplit"> <MkInput v-model="username" class="input" @update:modelValue="search" ref="username"> <template #label>{{ $ts.username }}</template> <template #prefix>@</template> @@ -52,7 +52,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import MkInput from './ui/input.vue'; +import MkInput from './form/input.vue'; import XModalWindow from '@client/components/ui/modal-window.vue'; import * as os from '@client/os'; diff --git a/src/client/components/widgets.vue b/src/client/components/widgets.vue index 150d61c027..aef5de453c 100644 --- a/src/client/components/widgets.vue +++ b/src/client/components/widgets.vue @@ -30,7 +30,7 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; import { v4 as uuid } from 'uuid'; -import MkSelect from '@client/components/ui/select.vue'; +import MkSelect from '@client/components/form/select.vue'; import MkButton from '@client/components/ui/button.vue'; import { widgets as widgetDefs } from '@client/widgets'; |