diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-15 22:45:13 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-15 22:45:13 +0900 |
| commit | 4c8a1867f09efe6b2475e37efa1dce5d89c56a54 (patch) | |
| tree | 1d97e465f7dd0d4814568db8670ef4588ac48ef6 /packages/client | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.114.0 (diff) | |
| download | misskey-4c8a1867f09efe6b2475e37efa1dce5d89c56a54.tar.gz misskey-4c8a1867f09efe6b2475e37efa1dce5d89c56a54.tar.bz2 misskey-4c8a1867f09efe6b2475e37efa1dce5d89c56a54.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/client')
37 files changed, 1149 insertions, 898 deletions
diff --git a/packages/client/package.json b/packages/client/package.json index 8d2efd0b92..94f6688dd2 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -56,7 +56,6 @@ "random-seed": "0.3.0", "reflect-metadata": "0.1.13", "rndstr": "1.0.0", - "rollup": "2.76.0", "s-age": "1.1.2", "sass": "1.53.0", "seedrandom": "3.0.5", @@ -102,6 +101,7 @@ "@types/ws": "8.5.3", "@typescript-eslint/eslint-plugin": "5.30.6", "@typescript-eslint/parser": "5.30.6", + "rollup": "2.76.0", "cross-env": "7.0.3", "cypress": "10.3.0", "eslint": "8.19.0", diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue index 6c2c8acad0..9e2ef1b930 100644 --- a/packages/client/src/components/drive.vue +++ b/packages/client/src/components/drive.vue @@ -26,7 +26,8 @@ </div> <button class="menu _button" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> </nav> - <div ref="main" class="main" + <div + ref="main" class="main" :class="{ uploading: uploadings.length > 0, fetching }" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @@ -142,7 +143,7 @@ const isDragSource = ref(false); const fetching = ref(true); const ilFilesObserver = new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles() + (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(), ); watch(folder, () => emit('cd', folder.value)); @@ -232,7 +233,7 @@ function onDrop(ev: DragEvent): any { removeFile(file.id); os.api('drive/files/update', { fileId: file.id, - folderId: folder.value ? folder.value.id : null + folderId: folder.value ? folder.value.id : null, }); } //#endregion @@ -248,7 +249,7 @@ function onDrop(ev: DragEvent): any { removeFolder(droppedFolder.id); os.api('drive/folders/update', { folderId: droppedFolder.id, - parentId: folder.value ? folder.value.id : null + parentId: folder.value ? folder.value.id : null, }).then(() => { // noop }).catch(err => { @@ -256,13 +257,13 @@ function onDrop(ev: DragEvent): any { case 'detected-circular-definition': os.alert({ title: i18n.ts.unableToProcess, - text: i18n.ts.circularReferenceFolder + text: i18n.ts.circularReferenceFolder, }); break; default: os.alert({ type: 'error', - text: i18n.ts.somethingHappened + text: i18n.ts.somethingHappened, }); } }); @@ -278,17 +279,17 @@ function urlUpload() { os.inputText({ title: i18n.ts.uploadFromUrl, type: 'url', - placeholder: i18n.ts.uploadFromUrlDescription + placeholder: i18n.ts.uploadFromUrlDescription, }).then(({ canceled, result: url }) => { if (canceled || !url) return; os.api('drive/files/upload-from-url', { url: url, - folderId: folder.value ? folder.value.id : undefined + folderId: folder.value ? folder.value.id : undefined, }); os.alert({ title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime + text: i18n.ts.uploadFromUrlMayTakeTime, }); }); } @@ -296,12 +297,12 @@ function urlUpload() { function createFolder() { os.inputText({ title: i18n.ts.createFolder, - placeholder: i18n.ts.folderName + placeholder: i18n.ts.folderName, }).then(({ canceled, result: name }) => { if (canceled) return; os.api('drive/folders/create', { name: name, - parentId: folder.value ? folder.value.id : undefined + parentId: folder.value ? folder.value.id : undefined, }).then(createdFolder => { addFolder(createdFolder, true); }); @@ -312,12 +313,12 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) { os.inputText({ title: i18n.ts.renameFolder, placeholder: i18n.ts.inputNewFolderName, - default: folderToRename.name + default: folderToRename.name, }).then(({ canceled, result: name }) => { if (canceled) return; os.api('drive/folders/update', { folderId: folderToRename.id, - name: name + name: name, }).then(updatedFolder => { // FIXME: 画面を更新するために自分自身に移動 move(updatedFolder); @@ -327,7 +328,7 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) { function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { os.api('drive/folders/delete', { - folderId: folderToDelete.id + folderId: folderToDelete.id, }).then(() => { // 削除時に親フォルダに移動 move(folderToDelete.parentId); @@ -337,15 +338,15 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { os.alert({ type: 'error', title: i18n.ts.unableToDelete, - text: i18n.ts.hasChildFilesOrFolders + text: i18n.ts.hasChildFilesOrFolders, }); break; default: os.alert({ type: 'error', - text: i18n.ts.unableToDelete + text: i18n.ts.unableToDelete, }); - } + } }); } @@ -411,7 +412,7 @@ function move(target?: Misskey.entities.DriveFolder) { fetching.value = true; os.api('drive/folders/show', { - folderId: target + folderId: target, }).then(folderToMove => { folder.value = folderToMove; hierarchyFolders.value = []; @@ -510,7 +511,7 @@ async function fetch() { const foldersPromise = os.api('drive/folders', { folderId: folder.value ? folder.value.id : null, - limit: foldersMax + 1 + limit: foldersMax + 1, }).then(fetchedFolders => { if (fetchedFolders.length === foldersMax + 1) { moreFolders.value = true; @@ -522,7 +523,7 @@ async function fetch() { const filesPromise = os.api('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, - limit: filesMax + 1 + limit: filesMax + 1, }).then(fetchedFiles => { if (fetchedFiles.length === filesMax + 1) { moreFiles.value = true; @@ -549,7 +550,7 @@ function fetchMoreFiles() { folderId: folder.value ? folder.value.id : null, type: props.type, untilId: files.value[files.value.length - 1].id, - limit: max + 1 + limit: max + 1, }).then(files => { if (files.length === max + 1) { moreFiles.value = true; @@ -569,30 +570,30 @@ function getMenu() { ref: keepOriginal, }, null, { text: i18n.ts.addFile, - type: 'label' + type: 'label', }, { text: i18n.ts.upload, icon: 'fas fa-upload', - action: () => { selectLocalFile(); } + action: () => { selectLocalFile(); }, }, { text: i18n.ts.fromUrl, icon: 'fas fa-link', - action: () => { urlUpload(); } + action: () => { urlUpload(); }, }, null, { text: folder.value ? folder.value.name : i18n.ts.drive, - type: 'label' + type: 'label', }, folder.value ? { text: i18n.ts.renameFolder, icon: 'fas fa-i-cursor', - action: () => { renameFolder(folder.value); } + action: () => { renameFolder(folder.value); }, } : undefined, folder.value ? { text: i18n.ts.deleteFolder, icon: 'fas fa-trash-alt', - action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); } + action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); }, } : undefined, { text: i18n.ts.createFolder, icon: 'fas fa-folder-plus', - action: () => { createFolder(); } + action: () => { createFolder(); }, }]; } @@ -662,14 +663,14 @@ onBeforeUnmount(() => { > .path { display: inline-block; vertical-align: bottom; - line-height: 50px; + line-height: 42px; white-space: nowrap; > * { display: inline-block; margin: 0; padding: 0 8px; - line-height: 50px; + line-height: 42px; cursor: pointer; * { diff --git a/packages/client/src/components/global/loading.vue b/packages/client/src/components/global/loading.vue index 5a7e362fcf..bcc6dfac01 100644 --- a/packages/client/src/components/global/loading.vue +++ b/packages/client/src/components/global/loading.vue @@ -16,9 +16,7 @@ </template> <script lang="ts" setup> -import { useCssModule } from 'vue'; - -useCssModule(); +import { } from 'vue'; const props = withDefaults(defineProps<{ inline?: boolean; diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue index a6025f8b27..4693df2916 100644 --- a/packages/client/src/components/launch-pad.vue +++ b/packages/client/src/components/launch-pad.vue @@ -36,7 +36,7 @@ <script lang="ts" setup> import { } from 'vue'; import MkModal from '@/components/ui/modal.vue'; -import { menuDef } from '@/menu'; +import { navbarItemDef } from '@/navbar'; import { instanceName } from '@/config'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; @@ -62,7 +62,7 @@ const modal = $ref<InstanceType<typeof MkModal>>(); const menu = defaultStore.state.menu; -const items = Object.keys(menuDef).filter(k => !menu.includes(k)).map(k => menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ +const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ type: def.to ? 'link' : 'button', text: i18n.ts[def.title], icon: def.icon, diff --git a/packages/client/src/components/mention.vue b/packages/client/src/components/mention.vue index cf69437771..3091b435e4 100644 --- a/packages/client/src/components/mention.vue +++ b/packages/client/src/components/mention.vue @@ -16,7 +16,7 @@ <script lang="ts" setup> import { toUnicode } from 'punycode'; -import { useCssModule } from 'vue'; +import { } from 'vue'; import tinycolor from 'tinycolor2'; import { host as localHost } from '@/config'; import { $i } from '@/account'; @@ -37,8 +37,6 @@ const isMe = $i && ( const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); bg.setAlpha(0.1); const bgCss = bg.toRgbString(); - -useCssModule(); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index c96cdddef2..27716cf73d 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -592,8 +592,6 @@ function readPromo() { } &.max-width_300px { - font-size: 0.825em; - > .article { > .avatar { width: 44px; diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue index 32f9fd07d8..10cbe20902 100644 --- a/packages/client/src/components/notification.vue +++ b/packages/client/src/components/notification.vue @@ -177,13 +177,7 @@ useTooltip(reactionRef, (showing) => { &.max-width_500px { padding: 12px; - font-size: 0.8em; - } - - &:after { - content: ""; - display: block; - clear: both; + font-size: 0.85em; } > .head { diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue index dd4a2b18b8..c35d65d5de 100644 --- a/packages/client/src/components/signup.vue +++ b/packages/client/src/components/signup.vue @@ -1,255 +1,234 @@ <template> <form class="qlvuhzng _formRoot" autocomplete="new-password" @submit.prevent="onSubmit"> - <template v-if="meta"> - <MkInput v-if="meta.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required> - <template #label>{{ $ts.invitationCode }}</template> - <template #prefix><i class="fas fa-key"></i></template> - </MkInput> - <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername"> - <template #label>{{ $ts.username }} <div v-tooltip:dialog="$ts.usernameInfo" class="_button _help"><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-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> + <MkInput v-if="instance.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required> + <template #label>{{ $ts.invitationCode }}</template> + <template #prefix><i class="fas fa-key"></i></template> + </MkInput> + <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername"> + <template #label>{{ $ts.username }} <div v-tooltip:dialog="$ts.usernameInfo" class="_button _help"><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-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-if="instance.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail"> + <template #label>{{ $ts.emailAddress }} <div v-tooltip:dialog="$ts._signup.emailAddressInfo" class="_button _help"><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:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span> + <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span> + <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span> + <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span> + <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</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 v-model="password" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword"> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="fas fa-lock"></i></template> + <template #caption> + <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span> + <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span> + <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" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype"> + <template #label>{{ $ts.password }} ({{ $ts.retype }})</template> + <template #prefix><i class="fas fa-lock"></i></template> + <template #caption> + <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span> + <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span> + </template> + </MkInput> + <MkSwitch v-if="instance.tosUrl" v-model="ToSAgreement" class="_formBlock tou"> + <I18n :src="$ts.agreeTo"> + <template #0> + <a :href="instance.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a> </template> - </MkInput> - <MkInput v-if="meta.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail"> - <template #label>{{ $ts.emailAddress }} <div v-tooltip:dialog="$ts._signup.emailAddressInfo" class="_button _help"><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:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span> - <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span> - <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span> - <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span> - <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</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 v-model="password" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword"> - <template #label>{{ $ts.password }}</template> - <template #prefix><i class="fas fa-lock"></i></template> - <template #caption> - <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span> - <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span> - <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" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype"> - <template #label>{{ $ts.password }} ({{ $ts.retype }})</template> - <template #prefix><i class="fas fa-lock"></i></template> - <template #caption> - <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span> - <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span> - </template> - </MkInput> - <MkSwitch v-if="meta.tosUrl" v-model="ToSAgreement" class="_formBlock tou"> - <I18n :src="$ts.agreeTo"> - <template #0> - <a :href="meta.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a> - </template> - </I18n> - </MkSwitch> - <MkCaptcha v-if="meta.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="meta.hcaptchaSiteKey"/> - <MkCaptcha v-if="meta.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="meta.recaptchaSiteKey"/> - <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton> - </template> + </I18n> + </MkSwitch> + <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> + <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton> </form> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import getPasswordStrength from 'syuilo-password-strength'; import { toUnicode } from 'punycode/'; import MkButton from './ui/button.vue'; +import MkCaptcha from './captcha.vue'; import MkInput from './form/input.vue'; import MkSwitch from './form/switch.vue'; -import { host, url } from '@/config'; +import * as config from '@/config'; import * as os from '@/os'; import { login } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - MkInput, - MkSwitch, - MkCaptcha: defineAsyncComponent(() => import('./captcha.vue')), - }, - - props: { - autoSet: { - type: Boolean, - required: false, - default: false, - }, - }, +const props = withDefaults(defineProps<{ + autoSet?: boolean; +}>(), { + autoSet: false, +}); - emits: ['signup'], +const emit = defineEmits<{ + (ev: 'signup', user: Record<string, any>): void; + (ev: 'signupEmailPending'): void; +}>(); - data() { - return { - host: toUnicode(host), - username: '', - password: '', - retypedPassword: '', - invitationCode: '', - email: '', - url, - usernameState: null, - emailState: null, - passwordStrength: '', - passwordRetypeState: null, - submitting: false, - ToSAgreement: false, - hCaptchaResponse: null, - reCaptchaResponse: null, - }; - }, +const host = toUnicode(config.host); - computed: { - meta() { - return this.$instance; - }, +let hcaptcha = $ref(); +let recaptcha = $ref(); - shouldDisableSubmitting(): boolean { - return this.submitting || - this.meta.tosUrl && !this.ToSAgreement || - this.meta.enableHcaptcha && !this.hCaptchaResponse || - this.meta.enableRecaptcha && !this.reCaptchaResponse || - this.passwordRetypeState === 'not-match'; - }, +let username: string = $ref(''); +let password: string = $ref(''); +let retypedPassword: string = $ref(''); +let invitationCode: string = $ref(''); +let email = $ref(''); +let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null); +let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null); +let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref(''); +let passwordRetypeState: null | 'match' | 'not-match' = $ref(null); +let submitting: boolean = $ref(false); +let ToSAgreement: boolean = $ref(false); +let hCaptchaResponse = $ref(null); +let reCaptchaResponse = $ref(null); - shouldShowProfileUrl(): boolean { - return (this.username !== '' && - this.usernameState !== 'invalid-format' && - this.usernameState !== 'min-range' && - this.usernameState !== 'max-range'); - }, - }, +const shouldDisableSubmitting = $computed((): boolean => { + return submitting || + instance.tosUrl && !ToSAgreement || + instance.enableHcaptcha && !hCaptchaResponse || + instance.enableRecaptcha && !reCaptchaResponse || + passwordRetypeState === 'not-match'; +}); - methods: { - onChangeUsername() { - if (this.username === '') { - this.usernameState = null; - return; - } +function onChangeUsername(): void { + if (username === '') { + usernameState = null; + return; + } - const err = - !this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : - this.username.length < 1 ? 'min-range' : - this.username.length > 20 ? 'max-range' : - null; + { + const err = + !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : + username.length < 1 ? 'min-range' : + username.length > 20 ? 'max-range' : + null; - if (err) { - this.usernameState = err; - return; - } + if (err) { + usernameState = err; + return; + } + } - this.usernameState = 'wait'; + usernameState = 'wait'; - os.api('username/available', { - username: this.username, - }).then(result => { - this.usernameState = result.available ? 'ok' : 'unavailable'; - }).catch(err => { - this.usernameState = 'error'; - }); - }, + os.api('username/available', { + username, + }).then(result => { + usernameState = result.available ? 'ok' : 'unavailable'; + }).catch(() => { + usernameState = 'error'; + }); +} - onChangeEmail() { - if (this.email === '') { - this.emailState = null; - return; - } +function onChangeEmail(): void { + if (email === '') { + emailState = null; + return; + } - this.emailState = 'wait'; + emailState = 'wait'; - os.api('email-address/available', { - emailAddress: this.email, - }).then(result => { - this.emailState = result.available ? 'ok' : - result.reason === 'used' ? 'unavailable:used' : - result.reason === 'format' ? 'unavailable:format' : - result.reason === 'disposable' ? 'unavailable:disposable' : - result.reason === 'mx' ? 'unavailable:mx' : - result.reason === 'smtp' ? 'unavailable:smtp' : - 'unavailable'; - }).catch(err => { - this.emailState = 'error'; - }); - }, + os.api('email-address/available', { + emailAddress: email, + }).then(result => { + emailState = result.available ? 'ok' : + result.reason === 'used' ? 'unavailable:used' : + result.reason === 'format' ? 'unavailable:format' : + result.reason === 'disposable' ? 'unavailable:disposable' : + result.reason === 'mx' ? 'unavailable:mx' : + result.reason === 'smtp' ? 'unavailable:smtp' : + 'unavailable'; + }).catch(() => { + emailState = 'error'; + }); +} - onChangePassword() { - if (this.password === '') { - this.passwordStrength = ''; - return; - } +function onChangePassword(): void { + if (password === '') { + passwordStrength = ''; + return; + } - const strength = getPasswordStrength(this.password); - this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; - }, + const strength = getPasswordStrength(password); + passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; +} - onChangePasswordRetype() { - if (this.retypedPassword === '') { - this.passwordRetypeState = null; - return; - } +function onChangePasswordRetype(): void { + if (retypedPassword === '') { + passwordRetypeState = null; + return; + } - this.passwordRetypeState = this.password === this.retypedPassword ? 'match' : 'not-match'; - }, + passwordRetypeState = password === retypedPassword ? 'match' : 'not-match'; +} - onSubmit() { - if (this.submitting) return; - this.submitting = true; +function onSubmit(): void { + if (submitting) return; + submitting = true; - 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(() => { - if (this.meta.emailRequiredForSignup) { - os.alert({ - 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); + os.api('signup', { + username, + password, + emailAddress: email, + invitationCode, + 'hcaptcha-response': hCaptchaResponse, + 'g-recaptcha-response': reCaptchaResponse, + }).then(() => { + if (instance.emailRequiredForSignup) { + os.alert({ + type: 'success', + title: i18n.ts._signup.almostThere, + text: i18n.t('_signup.emailSent', { email }), + }); + emit('signupEmailPending'); + } else { + os.api('signin', { + username, + password, + }).then(res => { + emit('signup', res); - if (this.autoSet) { - login(res.i); - } - }); + if (props.autoSet) { + login(res.i); } - }).catch(() => { - this.submitting = false; - this.$refs.hcaptcha?.reset?.(); - this.$refs.recaptcha?.reset?.(); - - os.alert({ - type: 'error', - text: this.$ts.somethingHappened, - }); }); - }, - }, -}); + } + }).catch(() => { + submitting = false; + hcaptcha.reset?.(); + recaptcha.reset?.(); + + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + }); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue index 1f3d508975..6ad63c2ad7 100644 --- a/packages/client/src/components/ui/menu.vue +++ b/packages/client/src/components/ui/menu.vue @@ -140,7 +140,7 @@ function focusDown() { width: 100%; box-sizing: border-box; white-space: nowrap; - font-size: 0.85em; + font-size: 0.9em; line-height: 20px; text-align: left; overflow: hidden; diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue index b7faea736b..b29ea4fd81 100644 --- a/packages/client/src/components/ui/modal-window.vue +++ b/packages/client/src/components/ui/modal-window.vue @@ -98,7 +98,7 @@ defineExpose({ } > .header { - $height: 58px; + $height: 46px; $height-narrow: 42px; display: flex; flex-shrink: 0; @@ -138,6 +138,7 @@ defineExpose({ } > .body { + flex: 1; overflow: auto; background: var(--panel); } diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue index 152c939a1a..f81bf2fc5b 100644 --- a/packages/client/src/components/ui/tooltip.vue +++ b/packages/client/src/components/ui/tooltip.vue @@ -116,7 +116,7 @@ const setPosition = () => { let top: number; if (props.targetElement) { - left = (rect.left + window.pageXOffset) + props.innerMargin; + left = (rect.left + props.targetElement.offsetWidth + window.pageXOffset) + props.innerMargin; top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2); } else { left = props.x + props.innerMargin; diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue index 6892b1924e..d155033824 100644 --- a/packages/client/src/components/ui/window.vue +++ b/packages/client/src/components/ui/window.vue @@ -393,7 +393,7 @@ export default defineComponent({ border-radius: var(--radius); > .header { - --height: 45px; + --height: 42px; &.mini { --height: 38px; diff --git a/packages/client/src/directives/index.ts b/packages/client/src/directives/index.ts index fc9b6f86da..401a917cba 100644 --- a/packages/client/src/directives/index.ts +++ b/packages/client/src/directives/index.ts @@ -8,7 +8,6 @@ import tooltip from './tooltip'; import hotkey from './hotkey'; import appear from './appear'; import anim from './anim'; -import stickyContainer from './sticky-container'; import clickAnime from './click-anime'; import panel from './panel'; import adaptiveBorder from './adaptive-border'; @@ -24,7 +23,6 @@ export default function(app: App) { app.directive('appear', appear); app.directive('anim', anim); app.directive('click-anime', clickAnime); - app.directive('sticky-container', stickyContainer); app.directive('panel', panel); app.directive('adaptive-border', adaptiveBorder); } diff --git a/packages/client/src/directives/sticky-container.ts b/packages/client/src/directives/sticky-container.ts deleted file mode 100644 index 3cf813054b..0000000000 --- a/packages/client/src/directives/sticky-container.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Directive } from 'vue'; - -export default { - mounted(src, binding, vn) { - //const query = binding.value; - - const header = src.children[0]; - const body = src.children[1]; - const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px'; - src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); - if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString(); - header.style.setProperty('--stickyTop', currentStickyTop); - header.style.position = 'sticky'; - header.style.top = 'var(--stickyTop)'; - header.style.zIndex = '1'; - }, -} as Directive; diff --git a/packages/client/src/menu.ts b/packages/client/src/navbar.ts index 31b2ed597c..03e00b1c17 100644 --- a/packages/client/src/menu.ts +++ b/packages/client/src/navbar.ts @@ -6,7 +6,7 @@ import { i18n } from '@/i18n'; import { ui } from '@/config'; import { unisonReload } from '@/scripts/unison-reload'; -export const menuDef = reactive({ +export const navbarItemDef = reactive({ notifications: { title: 'notifications', icon: 'fas fa-bell', diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue index 74fa0bc926..cd2bcc581a 100644 --- a/packages/client/src/pages/settings/general.vue +++ b/packages/client/src/pages/settings/general.vue @@ -56,10 +56,10 @@ <FormRadios v-model="fontSize" class="_formBlock"> <template #label>{{ i18n.ts.fontSize }}</template> - <option value="small"><span style="font-size: 14px;">Aa</span></option> - <option :value="null"><span style="font-size: 16px;">Aa</span></option> - <option value="large"><span style="font-size: 18px;">Aa</span></option> - <option value="veryLarge"><span style="font-size: 20px;">Aa</span></option> + <option :value="null"><span style="font-size: 14px;">Aa</span></option> + <option value="1"><span style="font-size: 15px;">Aa</span></option> + <option value="2"><span style="font-size: 16px;">Aa</span></option> + <option value="3"><span style="font-size: 17px;">Aa</span></option> </FormRadios> </FormSection> diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue index 76410ec12f..f970660a4a 100644 --- a/packages/client/src/pages/settings/index.vue +++ b/packages/client/src/pages/settings/index.vue @@ -114,16 +114,16 @@ const menuDef = computed(() => [{ to: '/settings/theme', active: props.initialPage === 'theme', }, { - icon: 'fas fa-list-ul', + icon: 'fas fa-bars', + text: i18n.ts.navbar, + to: '/settings/navbar', + active: props.initialPage === 'navbar', + }, { + icon: 'fas fa-bars-progress', text: i18n.ts.statusbar, to: '/settings/statusbars', active: props.initialPage === 'statusbars', }, { - icon: 'fas fa-list-ul', - text: i18n.ts.menu, - to: '/settings/menu', - active: props.initialPage === 'menu', - }, { icon: 'fas fa-music', text: i18n.ts.sounds, to: '/settings/sounds', @@ -225,7 +225,7 @@ const component = computed(() => { case 'theme': return defineAsyncComponent(() => import('./theme.vue')); case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); - case 'menu': return defineAsyncComponent(() => import('./menu.vue')); + case 'navbar': return defineAsyncComponent(() => import('./navbar.vue')); case 'statusbars': return defineAsyncComponent(() => import('./statusbars.vue')); case 'sounds': return defineAsyncComponent(() => import('./sounds.vue')); case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue')); @@ -291,6 +291,8 @@ const headerActions = $computed(() => []); const headerTabs = $computed(() => []); definePageMetadata(INFO); +// w 890 +// h 700 </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/navbar.vue index 076654c105..534112c3e0 100644 --- a/packages/client/src/pages/settings/menu.vue +++ b/packages/client/src/pages/settings/navbar.vue @@ -1,7 +1,7 @@ <template> <div class="_formRoot"> <FormTextarea v-model="items" tall manual-save class="_formBlock"> - <template #label>{{ i18n.ts.menu }}</template> + <template #label>{{ i18n.ts.navbar }}</template> <template #caption><button class="_textButton" @click="addItem">{{ i18n.ts.addItem }}</button></template> </FormTextarea> @@ -23,7 +23,7 @@ import FormTextarea from '@/components/form/textarea.vue'; import FormRadios from '@/components/form/radios.vue'; import FormButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import { menuDef } from '@/menu'; +import { navbarItemDef } from '@/navbar'; import { defaultStore } from '@/store'; import { unisonReload } from '@/scripts/unison-reload'; import { i18n } from '@/i18n'; @@ -45,11 +45,11 @@ async function reloadAsk() { } async function addItem() { - const menu = Object.keys(menuDef).filter(k => !defaultStore.state.menu.includes(k)); + const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k)); const { canceled, result: item } = await os.select({ title: i18n.ts.addItem, items: [...menu.map(k => ({ - value: k, text: i18n.ts[menuDef[k].title], + value: k, text: i18n.ts[navbarItemDef[k].title], })), { value: '-', text: i18n.ts.divider, }], @@ -81,7 +81,7 @@ const headerActions = $computed(() => []); const headerTabs = $computed(() => []); definePageMetadata({ - title: i18n.ts.menu, + title: i18n.ts.navbar, icon: 'fas fa-list-ul', }); </script> diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue index eb3efa9afb..d926acce5d 100644 --- a/packages/client/src/pages/settings/security.vue +++ b/packages/client/src/pages/settings/security.vue @@ -12,7 +12,7 @@ <FormSection> <template #label>{{ i18n.ts.signinHistory }}</template> - <MkPagination :pagination="pagination"> + <MkPagination :pagination="pagination" disable-auto-load> <template #default="{items}"> <div> <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> diff --git a/packages/client/src/pages/settings/statusbars.statusbar.vue b/packages/client/src/pages/settings/statusbars.statusbar.vue index 206979925e..2f0c6fc1ee 100644 --- a/packages/client/src/pages/settings/statusbars.statusbar.vue +++ b/packages/client/src/pages/settings/statusbars.statusbar.vue @@ -28,6 +28,9 @@ <MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url"> <template #label>URL</template> </MkInput> + <MkSwitch v-model="statusbar.props.shuffle" class="_formBlock"> + <template #label>{{ i18n.ts.shuffle }}</template> + </MkSwitch> <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> <template #label>{{ i18n.ts.refreshInterval }}</template> </MkInput> @@ -86,7 +89,6 @@ import FormRadios from '@/components/form/radios.vue'; import FormButton from '@/components/ui/button.vue'; import FormRange from '@/components/form/range.vue'; import * as os from '@/os'; -import { menuDef } from '@/menu'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; @@ -101,6 +103,7 @@ watch(() => statusbar.type, () => { if (statusbar.type === 'rss') { statusbar.name = 'NEWS'; statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'; + statusbar.props.shuffle = true; statusbar.props.refreshIntervalSec = 120; statusbar.props.display = 'marquee'; statusbar.props.marqueeDuration = 100; diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue index a1329a7411..1bcc0a1b85 100644 --- a/packages/client/src/pages/user/index.timeline.vue +++ b/packages/client/src/pages/user/index.timeline.vue @@ -1,12 +1,14 @@ <template> -<div v-sticky-container class="yrzkoczt"> - <MkTab v-model="include" class="tab"> - <option :value="null">{{ $ts.notes }}</option> - <option value="replies">{{ $ts.notesAndReplies }}</option> - <option value="files">{{ $ts.withFiles }}</option> - </MkTab> +<MkStickyContainer> + <template #header> + <MkTab v-model="include" :class="$style.tab"> + <option :value="null">{{ $ts.notes }}</option> + <option value="replies">{{ $ts.notesAndReplies }}</option> + <option value="files">{{ $ts.withFiles }}</option> + </MkTab> + </template> <XNotes :no-gap="true" :pagination="pagination"/> -</div> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -33,12 +35,10 @@ const pagination = { }; </script> -<style lang="scss" scoped> -.yrzkoczt { - > .tab { - margin: calc(var(--margin) / 2) 0; - padding: calc(var(--margin) / 2) 0; - background: var(--bg); - } +<style lang="scss" module> +.tab { + margin: calc(var(--margin) / 2) 0; + padding: calc(var(--margin) / 2) 0; + background: var(--bg); } </style> diff --git a/packages/client/src/scripts/shuffle.ts b/packages/client/src/scripts/shuffle.ts new file mode 100644 index 0000000000..05e6cdfbcf --- /dev/null +++ b/packages/client/src/scripts/shuffle.ts @@ -0,0 +1,19 @@ +/** + * 配列をシャッフル (破壊的) + */ +export function shuffle<T extends any[]>(array: T): T { + let currentIndex = array.length, randomIndex; + + // While there remain elements to shuffle. + while (currentIndex !== 0) { + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], array[currentIndex]]; + } + + return array; +} diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss index 0f892d2e19..27e33702ad 100644 --- a/packages/client/src/style.scss +++ b/packages/client/src/style.scss @@ -30,7 +30,7 @@ html { overflow: auto; overflow-wrap: break-word; font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; - font-size: 15px; + font-size: 14px; line-height: 1.35; text-size-adjust: 100%; tab-size: 2; @@ -61,16 +61,16 @@ html { } } - &.f-small { - font-size: 0.9em; + &.f-1 { + font-size: 15px; } - &.f-large { - font-size: 1.1em; + &.f-2 { + font-size: 16px; } - &.f-veryLarge { - font-size: 1.2em; + &.f-3 { + font-size: 17px; } &.useSystemFont { diff --git a/packages/client/src/ui/_common_/navbar-for-mobile.vue b/packages/client/src/ui/_common_/navbar-for-mobile.vue new file mode 100644 index 0000000000..cae1d25304 --- /dev/null +++ b/packages/client/src/ui/_common_/navbar-for-mobile.vue @@ -0,0 +1,284 @@ +<template> +<div class="kmwsukvl"> + <div class="body"> + <div class="top"> + <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> + <button v-click-anime v-tooltip.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + </button> + </div> + <div class="middle"> + <MkA v-click-anime class="item index" active-class="active" to="/" exact> + <i class="icon fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="icon fa-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon fas fa-circle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> + <i class="icon fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="icon fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> + <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon fas fa-circle"></i></span> + </button> + <MkA v-click-anime class="item" active-class="active" to="/settings"> + <i class="icon fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> + </MkA> + </div> + <div class="bottom"> + <button class="item _button post" data-cy-open-post-form @click="os.post"> + <i class="icon fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> + </button> + <button v-click-anime class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { openAccountMenu as openAccountMenu_ } from '@/account'; +import { defaultStore } from '@/store'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +const menu = toRef(defaultStore.state, 'menu'); +const otherMenuItemIndicated = computed(() => { + for (const def in navbarItemDef) { + if (menu.value.includes(def)) continue; + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} + +function openInstanceMenu(ev: MouseEvent) { + os.popupMenu([{ + text: instance.name ?? host, + type: 'label', + }, { + type: 'link', + text: i18n.ts.instanceInfo, + icon: 'fas fa-info-circle', + to: '/about', + }, { + type: 'link', + text: i18n.ts.customEmojis, + icon: 'fas fa-laugh', + to: '/about#emojis', + }, { + type: 'link', + text: i18n.ts.federation, + icon: 'fas fa-globe', + to: '/about#federation', + }], ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +function more() { + os.popup(defineAsyncComponent(() => import('@/components/launch-pad.vue')), {}, { + }, 'closed'); +} +</script> + +<style lang="scss" scoped> +.kmwsukvl { + > .body { + display: flex; + flex-direction: column; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + } + + > .instance { + position: relative; + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + position: relative; + display: block; + width: 100%; + height: 40px; + color: var(--fgOnAccent); + font-weight: bold; + text-align: left; + + &:before { + content: ""; + display: block; + width: calc(100% - 38px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } + + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + + > .icon { + position: relative; + margin-left: 30px; + margin-right: 8px; + width: 32px; + } + + > .text { + position: relative; + } + } + + > .account { + position: relative; + display: flex; + align-items: center; + padding-left: 30px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + margin-top: 16px; + + > .avatar { + position: relative; + width: 32px; + aspect-ratio: 1; + margin-right: 8px; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); + } + + > .item { + position: relative; + display: block; + padding-left: 24px; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > .icon { + position: relative; + width: 32px; + margin-right: 8px; + } + + > .indicator { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + > .text { + position: relative; + font-size: 0.9em; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + + &:hover, &.active { + &:before { + content: ""; + display: block; + width: calc(100% - 24px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/navbar.vue b/packages/client/src/ui/_common_/navbar.vue new file mode 100644 index 0000000000..fbac8425d7 --- /dev/null +++ b/packages/client/src/ui/_common_/navbar.vue @@ -0,0 +1,492 @@ +<template> +<div class="mvcprjjd" :class="{ iconOnly }"> + <div class="body"> + <div class="top"> + <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> + <button v-click-anime v-tooltip.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + </button> + </div> + <div class="middle"> + <MkA v-click-anime v-tooltip.right="i18n.ts.timeline" class="item index" active-class="active" to="/" exact> + <i class="icon fas fa-home fa-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component + :is="navbarItemDef[item].to ? 'MkA' : 'button'" + v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" + v-click-anime + v-tooltip.right="i18n.ts[navbarItemDef[item].title]" + class="item _button" + :class="[item, { active: navbarItemDef[item].active }]" + active-class="active" + :to="navbarItemDef[item].to" + v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}" + > + <i class="icon fa-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon fas fa-circle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip.right="i18n.ts.controlPanel" class="item" active-class="active" to="/admin"> + <i class="icon fas fa-door-open fa-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="icon fa fa-ellipsis-h fa-fw"></i><span class="text">{{ i18n.ts.more }}</span> + <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon fas fa-circle"></i></span> + </button> + <MkA v-click-anime v-tooltip.right="i18n.ts.settings" class="item" active-class="active" to="/settings"> + <i class="icon fas fa-cog fa-fw"></i><span class="text">{{ i18n.ts.settings }}</span> + </MkA> + </div> + <div class="bottom"> + <button v-tooltip.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post"> + <i class="icon fas fa-pencil-alt fa-fw"></i><span class="text">{{ i18n.ts.note }}</span> + </button> + <button v-click-anime v-tooltip.right="i18n.ts.account" class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { host } from '@/config'; + +const iconOnly = ref(false); + +const menu = computed(() => defaultStore.state.menu); +const otherMenuItemIndicated = computed(() => { + for (const def in navbarItemDef) { + if (menu.value.includes(def)) continue; + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +const calcViewState = () => { + iconOnly.value = (window.innerWidth <= 1279) || (defaultStore.state.menuDisplay === 'sideIcon'); +}; + +calcViewState(); + +window.addEventListener('resize', calcViewState); + +watch(defaultStore.reactiveState.menuDisplay, () => { + calcViewState(); +}); + +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} + +function openInstanceMenu(ev: MouseEvent) { + os.popupMenu([{ + text: instance.name ?? host, + type: 'label', + }, { + type: 'link', + text: i18n.ts.instanceInfo, + icon: 'fas fa-info-circle', + to: '/about', + }, { + type: 'link', + text: i18n.ts.customEmojis, + icon: 'fas fa-laugh', + to: '/about#emojis', + }, { + type: 'link', + text: i18n.ts.federation, + icon: 'fas fa-globe', + to: '/about#federation', + }], ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +function more(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/launch-pad.vue')), { + src: ev.currentTarget ?? ev.target, + }, { + }, 'closed'); +} +</script> + +<style lang="scss" scoped> +.mvcprjjd { + $nav-width: 250px; + $nav-icon-only-width: 86px; + + flex: 0 0 $nav-width; + width: $nav-width; + box-sizing: border-box; + + > .body { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: $nav-icon-only-width; + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + overflow: auto; + overflow-x: clip; + background: var(--navBg); + contain: strict; + display: flex; + flex-direction: column; + } + + &:not(.iconOnly) { + > .body { + width: $nav-width; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + } + + > .instance { + position: relative; + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + position: relative; + display: block; + width: 100%; + height: 40px; + color: var(--fgOnAccent); + font-weight: bold; + text-align: left; + + &:before { + content: ""; + display: block; + width: calc(100% - 38px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } + + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + + > .icon { + position: relative; + margin-left: 30px; + margin-right: 8px; + width: 32px; + } + + > .text { + position: relative; + } + } + + > .account { + position: relative; + display: flex; + align-items: center; + padding-left: 30px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + margin-top: 16px; + + > .avatar { + position: relative; + width: 32px; + aspect-ratio: 1; + margin-right: 8px; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); + } + + > .item { + position: relative; + display: block; + padding-left: 30px; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > .icon { + position: relative; + width: 32px; + margin-right: 8px; + } + + > .indicator { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + > .text { + position: relative; + font-size: 0.9em; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + + &:hover, &.active { + color: var(--accent); + + &:before { + content: ""; + display: block; + width: calc(100% - 34px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + } + } + } + } + } + + &.iconOnly { + flex: 0 0 $nav-icon-only-width; + width: $nav-icon-only-width; + + > .body { + width: $nav-icon-only-width; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .instance { + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + display: block; + position: relative; + width: 100%; + height: 52px; + margin-bottom: 16px; + text-align: center; + + &:before { + content: ""; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 52px; + aspect-ratio: 1/1; + border-radius: 100%; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } + + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + + > .icon { + position: relative; + color: var(--fgOnAccent); + } + + > .text { + display: none; + } + } + + > .account { + display: block; + text-align: center; + width: 100%; + + > .avatar { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + + > .text { + display: none; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 8px auto; + width: calc(100% - 32px); + border-top: solid 0.5px var(--divider); + } + + > .item { + display: block; + position: relative; + padding: 18px 0; + width: 100%; + text-align: center; + + > .icon { + display: block; + margin: 0 auto; + opacity: 0.7; + } + + > .text { + display: none; + } + + > .indicator { + position: absolute; + top: 6px; + left: 24px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover, &.active { + text-decoration: none; + color: var(--accent); + + &:before { + content: ""; + display: block; + height: 100%; + aspect-ratio: 1; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + + > .icon, > .text { + opacity: 1; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/sidebar-for-mobile.vue b/packages/client/src/ui/_common_/sidebar-for-mobile.vue deleted file mode 100644 index e789ae5e06..0000000000 --- a/packages/client/src/ui/_common_/sidebar-for-mobile.vue +++ /dev/null @@ -1,209 +0,0 @@ -<template> -<div class="kmwsukvl"> - <div class="body"> - <button v-click-anime class="item _button account" @click="openAccountMenu"> - <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> - </button> - <MkA v-click-anime class="item index" active-class="active" to="/" exact> - <i class="icon fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> - </MkA> - <template v-for="item in menu"> - <div v-if="item === '-'" class="divider"></div> - <component :is="menuDef[item].to ? 'MkA' : 'button'" v-else-if="menuDef[item] && (menuDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" :to="menuDef[item].to" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}"> - <i class="icon fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> - <span v-if="menuDef[item].indicated" class="indicator"><i class="icon fas fa-circle"></i></span> - </component> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> - <i class="icon fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> - </MkA> - <button v-click-anime class="item _button" @click="more"> - <i class="icon fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> - <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon fas fa-circle"></i></span> - </button> - <MkA v-click-anime class="item" active-class="active" to="/settings"> - <i class="icon fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> - </MkA> - <button class="item _button post" data-cy-open-post-form @click="post"> - <i class="icon fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> - </button> - </div> -</div> -</template> - -<script lang="ts"> -import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue'; -import { host } from '@/config'; -import { search } from '@/scripts/search'; -import * as os from '@/os'; -import { menuDef } from '@/menu'; -import { openAccountMenu } from '@/account'; -import { defaultStore } from '@/store'; - -export default defineComponent({ - setup(props, context) { - const menu = toRef(defaultStore.state, 'menu'); - const otherMenuItemIndicated = computed(() => { - for (const def in menuDef) { - if (menu.value.includes(def)) continue; - if (menuDef[def].indicated) return true; - } - return false; - }); - - return { - host: host, - accounts: [], - connection: null, - menu, - menuDef: menuDef, - otherMenuItemIndicated, - post: os.post, - search, - openAccountMenu: (ev) => { - openAccountMenu({ - withExtraOperation: true, - }, ev); - }, - more: () => { - os.popup(defineAsyncComponent(() => import('@/components/launch-pad.vue')), {}, { - }, 'closed'); - }, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.kmwsukvl { - $ui-font-size: 1em; // TODO: どこかに集約したい - $avatar-size: 32px; - $avatar-margin: 8px; - - > .body { - - > .divider { - margin: 16px 16px; - border-top: solid 0.5px var(--divider); - } - - > .item { - position: relative; - display: block; - padding-left: 24px; - font-size: $ui-font-size; - line-height: 2.85rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - color: var(--navFg); - - > .icon { - position: relative; - width: 32px; - } - - > .icon, - > .avatar { - margin-right: $avatar-margin; - } - - > .avatar { - width: $avatar-size; - height: $avatar-size; - vertical-align: middle; - } - - > .indicator { - position: absolute; - top: 0; - left: 20px; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } - - > .text { - position: relative; - font-size: 0.9em; - } - - &:hover { - text-decoration: none; - color: var(--navHoverFg); - } - - &.active { - color: var(--navActive); - } - - &:hover, &.active { - &:before { - content: ""; - display: block; - width: calc(100% - 24px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: var(--accentedBg); - } - } - - &:first-child, &:last-child { - position: sticky; - z-index: 1; - padding-top: 8px; - padding-bottom: 8px; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - } - - &:first-child { - top: 0; - - &:hover, &.active { - &:before { - content: none; - } - } - } - - &:last-child { - bottom: 0; - color: var(--fgOnAccent); - - &:before { - content: ""; - display: block; - width: calc(100% - 20px); - height: calc(100% - 20px); - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); - } - - &:hover, &.active { - &:before { - background: var(--accentLighten); - } - } - } - } - } -} -</style> diff --git a/packages/client/src/ui/_common_/sidebar.vue b/packages/client/src/ui/_common_/sidebar.vue deleted file mode 100644 index a72bf786ad..0000000000 --- a/packages/client/src/ui/_common_/sidebar.vue +++ /dev/null @@ -1,303 +0,0 @@ -<template> -<div class="mvcprjjd" :class="{ iconOnly }"> - <div class="body"> - <button v-click-anime class="item _button account" @click="openAccountMenu"> - <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> - </button> - <MkA v-click-anime class="item index" active-class="active" to="/" exact> - <i class="icon fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> - </MkA> - <template v-for="item in menu"> - <div v-if="item === '-'" class="divider"></div> - <component :is="menuDef[item].to ? 'MkA' : 'button'" v-else-if="menuDef[item] && (menuDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" :to="menuDef[item].to" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}"> - <i class="icon fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> - <span v-if="menuDef[item].indicated" class="indicator"><i class="icon fas fa-circle"></i></span> - </component> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> - <i class="icon fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> - </MkA> - <button v-click-anime class="item _button" @click="more"> - <i class="icon fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> - <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon fas fa-circle"></i></span> - </button> - <MkA v-click-anime class="item" active-class="active" to="/settings"> - <i class="icon fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> - </MkA> - <button class="item _button post" data-cy-open-post-form @click="os.post"> - <i class="icon fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> - </button> - </div> -</div> -</template> - -<script lang="ts" setup> -import { computed, defineAsyncComponent, ref, watch } from 'vue'; -import * as os from '@/os'; -import { menuDef } from '@/menu'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; -import { defaultStore } from '@/store'; - -const iconOnly = ref(false); - -const menu = computed(() => defaultStore.state.menu); -const otherMenuItemIndicated = computed(() => { - for (const def in menuDef) { - if (menu.value.includes(def)) continue; - if (menuDef[def].indicated) return true; - } - return false; -}); - -const calcViewState = () => { - iconOnly.value = (window.innerWidth <= 1279) || (defaultStore.state.menuDisplay === 'sideIcon'); -}; - -calcViewState(); - -window.addEventListener('resize', calcViewState); - -watch(defaultStore.reactiveState.menuDisplay, () => { - calcViewState(); -}); - -function openAccountMenu(ev: MouseEvent) { - openAccountMenu_({ - withExtraOperation: true, - }, ev); -} - -function more(ev: MouseEvent) { - os.popup(defineAsyncComponent(() => import('@/components/launch-pad.vue')), { - src: ev.currentTarget ?? ev.target, - }, { - }, 'closed'); -} -</script> - -<style lang="scss" scoped> -.mvcprjjd { - $ui-font-size: 1em; // TODO: どこかに集約したい - $nav-width: 250px; - $nav-icon-only-width: 86px; - $avatar-size: 32px; - $avatar-margin: 8px; - - flex: 0 0 $nav-width; - width: $nav-width; - box-sizing: border-box; - - > .body { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - width: $nav-width; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); - box-sizing: border-box; - overflow: auto; - overflow-x: clip; - background: var(--navBg); - contain: strict; - - > .divider { - margin: 16px 16px; - border-top: solid 0.5px var(--divider); - } - - > .item { - position: relative; - display: block; - padding-left: 24px; - font-size: $ui-font-size; - line-height: 2.85rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - color: var(--navFg); - - > .icon { - position: relative; - width: 32px; - } - - > .icon, - > .avatar { - margin-right: $avatar-margin; - } - - > .avatar { - width: $avatar-size; - height: $avatar-size; - vertical-align: middle; - } - - > .indicator { - position: absolute; - top: 0; - left: 20px; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } - - > .text { - position: relative; - font-size: 0.9em; - } - - &:hover { - text-decoration: none; - color: var(--navHoverFg); - } - - &.active { - color: var(--navActive); - } - - &:hover, &.active { - color: var(--accent); - - &:before { - content: ""; - display: block; - width: calc(100% - 24px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: var(--accentedBg); - } - } - - &:first-child, &:last-child { - position: sticky; - z-index: 1; - padding-top: 8px; - padding-bottom: 8px; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - } - - &:first-child { - top: 0; - - &:hover, &.active { - &:before { - content: none; - } - } - } - - &:last-child { - bottom: 0; - color: var(--fgOnAccent); - - &:before { - content: ""; - display: block; - width: calc(100% - 20px); - height: calc(100% - 20px); - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); - } - - &:hover, &.active { - &:before { - background: var(--accentLighten); - } - } - } - } - } - - &.iconOnly { - flex: 0 0 $nav-icon-only-width; - width: $nav-icon-only-width; - - > .body { - width: $nav-icon-only-width; - - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - } - - > .item { - padding-left: 0; - padding: 18px 0; - width: 100%; - text-align: center; - font-size: $ui-font-size * 1.1; - line-height: initial; - - > .icon, - > .avatar { - display: block; - margin: 0 auto; - } - - > .icon { - opacity: 0.7; - } - - > .text { - display: none; - } - - &:hover, &.active { - > .icon, > .text { - opacity: 1; - } - } - - &:first-child { - margin-bottom: 8px; - } - - &:last-child { - margin-top: 8px; - } - - &:before { - width: min-content; - height: 100%; - aspect-ratio: 1/1; - border-radius: 8px; - } - - &.post { - height: $nav-icon-only-width; - - > .icon { - opacity: 1; - } - } - - &.post:before { - width: calc(100% - 28px); - height: auto; - aspect-ratio: 1/1; - border-radius: 100%; - } - } - } - } -} -</style> diff --git a/packages/client/src/ui/_common_/statusbar-rss.vue b/packages/client/src/ui/_common_/statusbar-rss.vue index 88604a38a7..635b875ca1 100644 --- a/packages/client/src/ui/_common_/statusbar-rss.vue +++ b/packages/client/src/ui/_common_/statusbar-rss.vue @@ -20,9 +20,11 @@ import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; import MarqueeText from '@/components/marquee.vue'; import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; +import { shuffle } from '@/scripts/shuffle'; const props = defineProps<{ url?: string; + shuffle?: boolean; display?: 'marquee' | 'oneByOne'; marqueeDuration?: number; marqueeReverse?: boolean; @@ -37,6 +39,9 @@ let key = $ref(0); const tick = () => { fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { res.json().then(feed => { + if (props.shuffle) { + shuffle(feed.items); + } items.value = feed.items; fetching.value = false; key++; diff --git a/packages/client/src/ui/_common_/statusbars.vue b/packages/client/src/ui/_common_/statusbars.vue index e50b4e54c3..114ca5be8c 100644 --- a/packages/client/src/ui/_common_/statusbars.vue +++ b/packages/client/src/ui/_common_/statusbars.vue @@ -10,7 +10,7 @@ }]" > <span class="name">{{ x.name }}</span> - <XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url"/> + <XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/> <XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/> <XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/> </div> @@ -28,6 +28,7 @@ const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue') <style lang="scss" scoped> .dlrsnxqu { + font-size: 15px; background: var(--panel); > .item { diff --git a/packages/client/src/ui/classic.header.vue b/packages/client/src/ui/classic.header.vue index 57008aeaed..131767c0e3 100644 --- a/packages/client/src/ui/classic.header.vue +++ b/packages/client/src/ui/classic.header.vue @@ -7,9 +7,9 @@ </MkA> <template v-for="item in menu"> <div v-if="item === '-'" class="divider"></div> - <component :is="menuDef[item].to ? 'MkA' : 'button'" v-else-if="menuDef[item] && (menuDef[item].show !== false)" v-click-anime v-tooltip="$ts[menuDef[item].title]" class="item _button" :class="item" active-class="active" :to="menuDef[item].to" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}"> - <i class="fa-fw" :class="menuDef[item].icon"></i> - <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="$ts[navbarItemDef[item].title]" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="fa-fw" :class="navbarItemDef[item].icon"></i> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> </component> </template> <div class="divider"></div> @@ -43,7 +43,7 @@ import { defineAsyncComponent, defineComponent } from 'vue'; import { host } from '@/config'; import { search } from '@/scripts/search'; import * as os from '@/os'; -import { menuDef } from '@/menu'; +import { navbarItemDef } from '@/navbar'; import { openAccountMenu } from '@/account'; import MkButton from '@/components/ui/button.vue'; @@ -57,7 +57,7 @@ export default defineComponent({ host: host, accounts: [], connection: null, - menuDef: menuDef, + navbarItemDef: navbarItemDef, settingsWindowed: false, }; }, @@ -68,9 +68,9 @@ export default defineComponent({ }, otherNavItemIndicated(): boolean { - for (const def in this.menuDef) { + for (const def in this.navbarItemDef) { if (this.menu.includes(def)) continue; - if (this.menuDef[def].indicated) return true; + if (this.navbarItemDef[def].indicated) return true; } return false; }, @@ -113,7 +113,7 @@ export default defineComponent({ withExtraOperation: true, }, ev); }, - } + }, }); </script> diff --git a/packages/client/src/ui/classic.sidebar.vue b/packages/client/src/ui/classic.sidebar.vue index 6c0ce023e4..172401f420 100644 --- a/packages/client/src/ui/classic.sidebar.vue +++ b/packages/client/src/ui/classic.sidebar.vue @@ -14,9 +14,9 @@ </MkA> <template v-for="item in menu"> <div v-if="item === '-'" class="divider"></div> - <component :is="menuDef[item].to ? 'MkA' : 'button'" v-else-if="menuDef[item] && (menuDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="menuDef[item].to" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}"> - <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> - <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="fa-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> </component> </template> <div class="divider"></div> @@ -45,7 +45,7 @@ import { defineAsyncComponent, defineComponent } from 'vue'; import { host } from '@/config'; import { search } from '@/scripts/search'; import * as os from '@/os'; -import { menuDef } from '@/menu'; +import { navbarItemDef } from '@/navbar'; import { openAccountMenu } from '@/account'; import MkButton from '@/components/ui/button.vue'; import { StickySidebar } from '@/scripts/sticky-sidebar'; @@ -62,7 +62,7 @@ export default defineComponent({ host: host, accounts: [], connection: null, - menuDef: menuDef, + navbarItemDef: navbarItemDef, iconOnly: false, settingsWindowed: false, }; @@ -74,9 +74,9 @@ export default defineComponent({ }, otherNavItemIndicated(): boolean { - for (const def in this.menuDef) { + for (const def in this.navbarItemDef) { if (this.menu.includes(def)) continue; - if (this.menuDef[def].indicated) return true; + if (this.navbarItemDef[def].indicated) return true; } return false; }, @@ -131,7 +131,7 @@ export default defineComponent({ withExtraOperation: true, }, ev); }, - } + }, }); </script> diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue index 70db7ed12b..c42407f5b0 100644 --- a/packages/client/src/ui/classic.vue +++ b/packages/client/src/ui/classic.vue @@ -47,7 +47,6 @@ import XCommon from './_common_/common.vue'; import { instanceName } from '@/config'; import { StickySidebar } from '@/scripts/sticky-sidebar'; import * as os from '@/os'; -import { menuDef } from '@/menu'; import { mainRouter } from '@/router'; import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; import { defaultStore } from '@/store'; diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue index 19a99a95aa..94fee1424e 100644 --- a/packages/client/src/ui/deck.vue +++ b/packages/client/src/ui/deck.vue @@ -69,12 +69,12 @@ import { v4 as uuid } from 'uuid'; import XCommon from './_common_/common.vue'; import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store'; import DeckColumnCore from '@/ui/deck/column-core.vue'; -import XSidebar from '@/ui/_common_/sidebar.vue'; -import XDrawerMenu from '@/ui/_common_/sidebar-for-mobile.vue'; +import XSidebar from '@/ui/_common_/navbar.vue'; +import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; import MkButton from '@/components/ui/button.vue'; import { getScrollContainer } from '@/scripts/scroll'; import * as os from '@/os'; -import { menuDef } from '@/menu'; +import { navbarItemDef } from '@/navbar'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { mainRouter } from '@/router'; @@ -105,8 +105,8 @@ const columns = deckStore.reactiveState.columns; const layout = deckStore.reactiveState.layout; const menuIndicated = computed(() => { if ($i == null) return false; - for (const def in menuDef) { - if (menuDef[def].indicated) return true; + for (const def in navbarItemDef) { + if (navbarItemDef[def].indicated) return true; } return false; }); @@ -359,9 +359,10 @@ function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { height: calc(var(--vh, 1vh) * 100); width: 240px; box-sizing: border-box; + contain: strict; overflow: auto; overscroll-behavior: contain; - background: var(--bg); + background: var(--navBg); } } </style> diff --git a/packages/client/src/ui/deck/column.vue b/packages/client/src/ui/deck/column.vue index e8e554d72b..4d34ca9b8e 100644 --- a/packages/client/src/ui/deck/column.vue +++ b/packages/client/src/ui/deck/column.vue @@ -23,7 +23,7 @@ <slot name="action"></slot> </div> <span class="header"><slot name="header"></slot></span> - <button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="fas fa-cog"></i></button> + <button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="fas fa-ellipsis"></i></button> </header> <div v-show="active" ref="body"> <slot></slot> @@ -361,7 +361,6 @@ function onDrop(ev) { z-index: 1; width: var(--deckColumnHeaderHeight); line-height: var(--deckColumnHeaderHeight); - font-size: 16px; color: var(--faceTextButton); &:hover { diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue index 2edfb3f12d..e4b5de9918 100644 --- a/packages/client/src/ui/universal.vue +++ b/packages/client/src/ui/universal.vue @@ -61,17 +61,17 @@ import { defineAsyncComponent, provide, onMounted, computed, ref, watch, Compute import XCommon from './_common_/common.vue'; import { instanceName } from '@/config'; import { StickySidebar } from '@/scripts/sticky-sidebar'; -import XDrawerMenu from '@/ui/_common_/sidebar-for-mobile.vue'; +import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; -import { menuDef } from '@/menu'; +import { navbarItemDef } from '@/navbar'; import { i18n } from '@/i18n'; import { $i } from '@/account'; import { Router } from '@/nirax'; import { mainRouter } from '@/router'; import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); -const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/sidebar.vue')); +const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const DESKTOP_THRESHOLD = 1100; @@ -97,9 +97,9 @@ provideMetadataReceiver((info) => { }); const menuIndicated = computed(() => { - for (const def in menuDef) { + for (const def in navbarItemDef) { if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから - if (menuDef[def].indicated) return true; + if (navbarItemDef[def].indicated) return true; } return false; }); @@ -365,11 +365,11 @@ const wallpaper = localStorage.getItem('wallpaper') != null; height: calc(var(--vh, 1vh) * 100); width: 240px; box-sizing: border-box; + contain: strict; overflow: auto; overscroll-behavior: contain; - background: var(--bg); + background: var(--navBg); } - } </style> diff --git a/packages/client/src/widgets/rss-ticker.vue b/packages/client/src/widgets/rss-ticker.vue index 06995bc865..c692c0c4ff 100644 --- a/packages/client/src/widgets/rss-ticker.vue +++ b/packages/client/src/widgets/rss-ticker.vue @@ -26,6 +26,7 @@ import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import MkContainer from '@/components/ui/container.vue'; import { useInterval } from '@/scripts/use-interval'; +import { shuffle } from '@/scripts/shuffle'; const name = 'rssTicker'; @@ -34,6 +35,10 @@ const widgetPropsDef = { type: 'string' as const, default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', }, + shuffle: { + type: 'boolean' as const, + default: true, + }, refreshIntervalSec: { type: 'number' as const, default: 60, @@ -80,6 +85,9 @@ let key = $ref(0); const tick = () => { fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { res.json().then(feed => { + if (widgetProps.shuffle) { + shuffle(feed.items); + } items.value = feed.items; fetching.value = false; key++; diff --git a/packages/client/vite.json5.ts b/packages/client/vite.json5.ts index 693ee7be06..0a37fbff44 100644 --- a/packages/client/vite.json5.ts +++ b/packages/client/vite.json5.ts @@ -6,33 +6,33 @@ import { createFilter, dataToEsm } from '@rollup/pluginutils'; import { RollupJsonOptions } from '@rollup/plugin-json'; export default function json5(options: RollupJsonOptions = {}): Plugin { - const filter = createFilter(options.include, options.exclude); - const indent = 'indent' in options ? options.indent : '\t'; + const filter = createFilter(options.include, options.exclude); + const indent = 'indent' in options ? options.indent : '\t'; - return { - name: 'json5', + return { + name: 'json5', - // eslint-disable-next-line no-shadow - transform(json, id) { - if (id.slice(-6) !== '.json5' || !filter(id)) return null; + // eslint-disable-next-line no-shadow + transform(json, id) { + if (id.slice(-6) !== '.json5' || !filter(id)) return null; - try { - const parsed = JSON5.parse(json); - return { - code: dataToEsm(parsed, { - preferConst: options.preferConst, - compact: options.compact, - namedExports: options.namedExports, - indent - }), - map: { mappings: '' } - }; - } catch (err) { - const message = 'Could not parse JSON file'; - const position = parseInt(/[\d]/.exec(err.message)[0], 10); - this.warn({ message, id, position }); - return null; - } - } - }; + try { + const parsed = JSON5.parse(json); + return { + code: dataToEsm(parsed, { + preferConst: options.preferConst, + compact: options.compact, + namedExports: options.namedExports, + indent, + }), + map: { mappings: '' }, + }; + } catch (err) { + const message = 'Could not parse JSON file'; + const position = parseInt(/[\d]/.exec(err.message)[0], 10); + this.warn({ message, id, position }); + return null; + } + }, + }; } |